This document explains precisely how the three scratchpad files are created, when each is written, and how state evolves through a cycle. It is intended to explain how the agent's memory system behaves.
For a high-level description of the files and the YAML schema, see README.md §6. For a quick operator reference, see CLAUDE.md.
scratchpad/
active.yaml ← the agent's live, durable memory
20260401_100137_before.yaml ← state as read at the start of that cycle
20260401_100137_after.yaml ← state as left at the end of that cycle
The timestamp prefix encodes the cycle start time (YYYYMMDD_HHMMSS). Every cycle produces one _before and one _after pair. active.yaml is overwritten every cycle.
Everything happens inside agent.run_once(). Reading it literally:
def run_once(self):
cycle_id = datetime.now().strftime("%Y%m%d_%H%M%S") # 1. name the cycle
state_before = self.wake() # 2. read active.yaml
self.scratchpad.snapshot(state_before, cycle_id, "before") # 3. write _before
try:
state_after = self.run_cycle(state_before) # 4. evolve state in memory
except Exception as e:
state_after = state_before
state_after["notes"] = f"Cycle {cycle_id} failed: {e}"
self.scratchpad.snapshot(state_after, cycle_id, "after") # 5. write _after
self.sleep(state_after) # 6. write active.yamlThere are six distinct events. Each is explained below.
cycle_id = datetime.now().strftime("%Y%m%d_%H%M%S")cycle_id is just a timestamp string used as a filename prefix. It is set once and never changes within the cycle, so both snapshot files always share the same prefix.
# agent.py
state_before = self.wake()
# scratchpad.py
def load(self) -> dict:
if not self.path.exists():
return dict(DEFAULT_SCHEMA)
with open(self.path) as f:
data = yaml.safe_load(f) or {}
for k, v in DEFAULT_SCHEMA.items():
data.setdefault(k, v)
return dataload() reads active.yaml from disk and returns a plain Python dict. This is the only disk read of the cycle. From this point on the agent works entirely in memory — active.yaml is not touched again until Event 6.
If active.yaml does not exist (first run, or after bin/reset.sh), load() returns the default schema:
DEFAULT_SCHEMA = {
"goals": [],
"current_task": None,
"pending_actions": [],
"completed_tasks": [],
"notes": "",
"last_updated": None,
}self.scratchpad.snapshot(state_before, cycle_id, "before")
def snapshot(self, state: dict, cycle_id: str, label: str):
snap_path = self.path.parent / f"{cycle_id}_{label}.yaml"
with open(snap_path, "w") as f:
yaml.dump(state, f, default_flow_style=False, sort_keys=False)snapshot() writes the dict to disk without mutating it. The _before file is an exact copy of what active.yaml contained when the cycle started. It is never modified again.
What _before.yaml tells you: the state the agent inherited — what it was tasked with, what it already knew, what it had already done in previous cycles.
This is where almost all the action happens, and it all occurs in memory. Nothing is written to disk during iteration.
def run_cycle(self, state: dict) -> dict:
iteration = 0
while iteration < self.max_iterations:
iteration += 1
prompt = self._build_prompt(state)
response = self.model.complete(system=self.rules, user=prompt)
state, done = self.dispatcher.dispatch(response, state)
if done:
break
return stateEach iteration:
- Builds a prompt by serializing the current
statedict into the user message - Sends it to the model and gets a JSON response
- Passes the response to
dispatcher.dispatch(), which mutatesstateand returns it
dispatcher.dispatch() parses the model's JSON and takes one of three branches:
Tool call ({"tool": "shell", "args": {"command": "date -u"}}):
result = self.tools.call(tool_name, args)
state["notes"] += f"\n[TOOL] {tool_name}({args}) → {str(result)[:300]}"The tool result is appended to notes. The task queue is not changed.
Scratchpad update ({"tool": "update_scratchpad", "args": {...}}):
for key, value in args.items():
if key == "completed_tasks":
state["completed_tasks"].extend(value)
elif key == "pending_actions":
state["pending_actions"] = value
else:
state[key] = valueAny field can be updated. This is how the agent sets current_task, rewrites goals, or clears pending_actions.
Done signal ({"tool": "done", "args": {"summary": "..."}}):
if state.get("current_task"):
state["completed_tasks"].append({
"task": state["current_task"],
"summary": summary
})
state["current_task"] = (
state["pending_actions"].pop(0)
if state.get("pending_actions") else None
)
state["notes"] += f"\n[COMPLETED] {summary}"
return state, True # done=True → exits the while loopThe current task is moved to completed_tasks, the next item from pending_actions becomes current_task, and the loop exits.
Parse failure: if the model returns something that isn't valid JSON, the dispatcher logs a [PARSE ERROR] to notes and returns done=False. The loop continues to the next iteration.
After two tool calls and before done is signaled, state in memory might look like:
{
"goals": ["Demonstrate the wake/run/sleep cycle", ...],
"current_task": "Run the echo tool with a hello message",
"pending_actions": ["Write hello.txt", "Signal done"],
"completed_tasks": [],
"notes": "\n[TOOL] echo({'message': 'Hello!'}) → ECHO: Hello!\n[TOOL] shell({'command': 'date -u'}) → Wed Apr 1 15:01:45 UTC 2026\n",
"last_updated": None,
}Note that current_task has not changed yet — it only advances when done is dispatched. Every tool call just appends to notes.
self.scratchpad.snapshot(state_after, cycle_id, "after")Same snapshot() call as Event 3, same dict-to-YAML write, no mutation. The _after file captures the fully-evolved in-memory state at the moment the cycle ended — either because done was signaled or max_iterations was reached.
Important detail: snapshot() does not set last_updated. That field is only written by save() (Event 6). So _after.yaml will show last_updated: null (or whatever value it had at the start of the cycle). This is visible in real output:
# 20260401_100137_after.yaml
notes: "\n[TOOL] echo(...) → ...\n[TOOL] shell(...) → ..."
last_updated: null # ← snapshot was taken before save() ranself.sleep(state_after)
def save(self, state: dict):
state["last_updated"] = datetime.now().isoformat() # mutates the dict
with open(self.path, "w") as f:
yaml.dump(state, f, default_flow_style=False, sort_keys=False)save() mutates state before writing — it stamps last_updated with the current time. Because Python dicts are passed by reference, this mutation happens after the _after snapshot was written, so the snapshot does not get the timestamp.
active.yaml is then nearly identical to _after.yaml with one difference: a fresh last_updated timestamp.
# active.yaml (after same cycle)
notes: "\n[TOOL] echo(...) → ...\n[TOOL] shell(...) → ..."
last_updated: '2026-04-01T15:01:54.203841' # ← set by save()The next cycle's _before.yaml will be an exact copy of this active.yaml.
Disk Memory (state dict) Disk
────────────────────────────────────────────────────────────────────────
active.yaml (N-1)
│
│ load() [Event 2]
▼
state_before ─────────────────────────────► _before.yaml [Event 3]
│
│ dispatcher iteration 1:
│ notes += "[TOOL] ..."
▼
state (mid-cycle, notes growing)
│
│ dispatcher iteration 2:
│ notes += "[TOOL] ..."
▼
state (mid-cycle)
│
│ dispatcher iteration N:
│ done=True → current_task advances
▼
state_after ──────────────────────────────► _after.yaml [Event 5]
│ (last_updated: null)
│ save() stamps last_updated [Event 6]
▼
active.yaml [Event 6]
(last_updated: now)
Agent completed the task correctly:
_before.yaml:current_task= the task;completed_tasksis shorter_after.yaml:current_taskhas advanced to the next item; old task is incompleted_tasks;notesshows the tool calls that were made
Agent hit max_iterations without finishing:
_before.yamland_after.yamlhave the samecurrent_task— it never signaleddonenotesin_after.yamlshows how many iterations were spent and what tools were called- Common cause: the model called the same tool repeatedly (e.g.,
date -ufour times) instead of advancing
Cycle crashed:
state_after = state_beforeis used as the fallbackstate_after["notes"]is overwritten with"Cycle {cycle_id} failed: {error}"- Both
active.yamland_after.yamlwill show this error innotes - The full traceback is in
logs/cycle_{cycle_id}.log
Nothing changed between before and after:
- The dispatcher received
update_scratchpadbut the args didn't change any meaningful fields, or - The model emitted well-formed JSON for a no-op, or
max_iterationswas 1 and only one non-completing action was taken
| Document | Focus |
|---|---|
README.md §6 |
What the files are; how to read them from the command line |
CLAUDE.md |
Schema field reference; operator quick-start |
SCRATCHPAD.md (this file) |
Precise lifecycle mechanics; how state evolves; debugging |
The README gives you orientation. This document gives you the code-level mechanics you need to understand why the files contain what they contain and how to interpret discrepancies between them.