Module 36 · 55 min

Each Agent Keeps Its Own Notes

You can hand two agents the same wiki and prove neither can overwrite the other's notes, and read what each one actually did from a footer the agent did not write.

Surface
a scratchpad per agent · did on the wire
Workbench tag
module-36
Claude Code
v2.1.251 (bundled)
claude-agent-sdk
v0.3.251
Docs checked
2026-08-30

An agent says it updated the wiki. The footer underneath says otherwise. Which do you trust?

Two ideas land together here, and they are the same idea seen from two sides: what an agent writes about itself is not the record. The first side gives each agent one file that is genuinely its own, a scratchpad, so its working notes have somewhere to live that no other agent shares. The second side gives every turn a footer the agent did not write and cannot edit: did, the harness’s own list of what the turn actually touched, assembled from the tool events the server was already routing. An agent’s report says what it believes it did. The footer says what happened. When the two disagree, the disagreement is the finding.

flowchart TD
wiki["knowledge-base/<br/>shared, schema-governed,<br/>edits go through capture-fact"]
a["Scout"] -->|append| an["agents/agent-1-scout.md"]
b["Ledger"] -->|append| bn["agents/agent-2-ledger.md"]
a -.->|read| wiki
b -.->|read| wiki
Two agents, two scratchpads, one shared wiki above them. Reads flow in from everywhere; each agent's writes flow out only to its own file, by stated convention.

Both sides ship as two small files, and the arguments for both live in the code as comments rather than only on this page. Read agent-memory.js first, header down.

chat-workbench/
├─ server/
│ └─ src/
└─ knowledge-base/
└─ agents/
server/src/agent-memory.js

Names, finds and creates one scratchpad. It never reads one back.

// One file per agent, written by the harness, owned by that agent.
//
// Role: name, find and create the scratchpad an agent may append to. Nothing
// here reads a scratchpad, parses one, or decides what goes in it after the
// stub. The agent writes the body; this module writes the header and gets out
// of the way.
//
// The file lives under `knowledge-base/agents/`, one directory below the wiki.
// That placement is the whole of what separates working notes from curated
// pages, and `tools/kb-lint.mjs` reads it the same way: `listMarkdown` is
// non-recursive, so nothing under `agents/` is ever linted. See
// docs/decisions/0116.
//
// Invariants:
// - The file name carries the agentId AND the name slug, id first:
//   `agent-1-research.md`. Two agents both called "Research" get two files.
//   Record 0115.
// - Lookup is by id prefix, never by the current name. A rename does not move
//   the file, so recomputing the name from the record would name a file that is
//   not there. Record 0117.
// - Creating a scratchpad never blocks a start. Every failure is logged and
//   swallowed, and the caller gets `undefined` rather than an exception.
//   Record 0118.
// - The header is written schema-clean even though nothing lints it. Record
//   0116 says why both halves of that sentence are true at once.
// - No date is written into the body. `last-verified` is the only date, which
//   is `knowledge-base/_schema.md`'s rule and applies here whether or not the
//   lint is watching.

import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
import path from "node:path";

/** @typedef {import("./roster.js").AgentRecord} AgentRecord */
/** @typedef {import("./log.js").Logger} Logger */

/**
* The directory scratchpads live in, under the knowledge base.
*
* A subdirectory rather than a prefix on a root-level file name. The wiki's
* schema says a page is one Markdown file at the root of the wiki directory and
* that the lint walks exactly those; a scratchpad at the root would be a page by
* that definition, whatever it was called, and would be judged as one.
*/
export const AGENT_NOTES_DIRNAME = "agents";

/**
* The volatility tier a scratchpad's header carries.
*
* `high` is chosen for a reader rather than for the lint, which never sees this
* file. An agent's working notes are the fastest-rotting thing in the
* repository: they are true for one session and stop being true when the agent
* is told something else.
*/
export const NOTES_VOLATILITY = "high";

/**
* The provenance tier a scratchpad's header carries.
*
* `extracted` is the honest one of the five for a stub. A tool wrote it and no
* person has read it against anything, which is the value's definition. It also
* pairs correctly with `verified-by: machine` under the schema's first pairing
* rule, and it is the one tier that does not demand a checker under the fourth.
*/
export const NOTES_PROVENANCE = "extracted";

/**
* Turn an agent's name into the slug half of its file name.
*
* Lower-case, runs of anything that is not a letter or a digit collapsed to one
* hyphen, no hyphen at either end. A name that leaves nothing behind, punctuation
* only, or a script this crude rule does not keep, becomes `agent`, so the file
* is still distinct: the id in front of the slug is what makes it so.
*
* @param {string} name
* @returns {string}
*/
export function slugify(name) {
const slug = name
  .toLowerCase()
  .replace(/[^a-z0-9]+/g, "-")
  .replace(/^-+|-+$/g, "");
return slug === "" ? "agent" : slug;
}

/**
* The file name for one agent's scratchpad. The id comes first.
*
* The id first is not a formatting choice. Two agents may be called "Research"
*, the roster trims names and caps their length and does nothing at all about
* uniqueness, and a file named `research.md` would be one file that two agents
* were both told was theirs. The first would write notes, the second would
* append to them, and each would read the other's as its own. The id is the only
* thing on a record that cannot collide, so the id is what the name rests on.
*
* Leading rather than trailing so a directory listing sorts by agent, and so a
* lookup can match on `${agentId}-` and cannot half-match a longer id:
* `agent-1-` is not a prefix of `agent-11-scout.md`, because the hyphen after
* the number is part of what is compared.
*
* @param {AgentRecord} agent
* @returns {string}
*/
export function scratchpadFileName(agent) {
return `${agent.agentId}-${slugify(agent.name)}.md`;
}

/**
* The scratchpad this agent already has, or undefined.
*
* By id prefix, never by the current name. `agent-rename` changes the record and
* leaves the file where it is (record 0117), so the name in the file is the name
* the agent had when the file was made and computing the name again would look
* for a file nobody wrote.
*
* A missing directory is not an error here. It means no scratchpad has ever been
* written, which is the answer.
*
* @param {string} dir The scratchpad directory.
* @param {string} agentId
* @returns {string | undefined} The absolute path, if one exists.
*/
export function findScratchpad(dir, agentId) {
const prefix = `${agentId}-`;
let names;
try {
  names = readdirSync(dir);
} catch {
  return undefined;
}
const found = names
  .filter((name) => name.startsWith(prefix) && name.endsWith(".md"))
  .sort();
return found.length === 0 ? undefined : path.join(dir, found[0]);
}

/**
* The whole of a fresh scratchpad: header, then one paragraph.
*
* The body says two things and no more. Whose notes these are, and that the
* agent appends below. It does not tell the agent what to write, because a
* harness that filled in the headings would be deciding what an agent's own
* notes are for.
*
* @param {AgentRecord} agent
* @param {string} today `YYYY-MM-DD`. An argument, so a test can assert on the
*   whole file and so the clock lives at one seam. The same argument-not-a-call
*   rule `tools/kb-lint.mjs` took for its own clock.
* @returns {string}
*/
export function scratchpadText(agent, today) {
return [
  "---",
  `owner: ${agent.agentId}`,
  `volatility: ${NOTES_VOLATILITY}`,
  `last-verified: ${today}`,
  "verified-by: machine",
  `provenance: ${NOTES_PROVENANCE}`,
  "---",
  "",
  `# ${agent.name}'s notes`,
  "",
  `These are ${agent.name}'s working notes, and nothing else writes below this`,
  "line. The harness wrote the header and this paragraph when the agent record",
  "was created; everything under it is the agent's own, appended as it goes.",
  "Every other file in the knowledge base is shared.",
  "",
].join("\n");
}

/**
* Make sure this agent has a scratchpad, and say where it is.
*
* Called when a record is created, including the orchestrator's, on a first
* boot, and again when a conversation starts, which is the repair path for a
* record that predates this module or whose file was deleted. It is idempotent:
* an agent that already has a file gets that file's path back and nothing is
* written.
*
* Nothing here throws. A read-only knowledge base, a full disk, a permission
* refusal, every one of them is a reason a person's agent should still start.
* A harness that refused to run an agent because it could not write the agent's
* notebook would have made the notebook more important than the agent, and the
* failure would arrive as a dead chat window rather than as a log line. So the
* failure is logged, `undefined` comes back, and the caller carries on without
* the notes sentences in the persona, which is the honest persona for an agent
* that has no file. Record 0118.
*
* @param {object} deps
* @param {AgentRecord} deps.agent
* @param {string} deps.dir The scratchpad directory, absolute.
* @param {string} deps.today `YYYY-MM-DD`.
* @param {Logger} deps.logger
* @param {{ mkdirSync: typeof mkdirSync, writeFileSync: typeof writeFileSync }}
*   [deps.fs] The two writes, injected so a test can make them fail. Reading is
*   `findScratchpad`'s and is already failure-tolerant.
* @returns {string | undefined} The scratchpad's absolute path, or undefined
*   when there is not one and could not be one.
*/
export function ensureAgentMemory({
agent,
dir,
today,
logger,
fs = { mkdirSync, writeFileSync },
}) {
const existing = findScratchpad(dir, agent.agentId);
if (existing !== undefined) return existing;

const file = path.join(dir, scratchpadFileName(agent));
try {
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(file, scratchpadText(agent, today), "utf8");
} catch (cause) {
  logger.warn("notes.write_failed", {
    agentId: agent.agentId,
    file,
    message: String(cause),
  });
  return undefined;
}
logger.info("notes.created", { agentId: agent.agentId, file });
return file;
}

/**
* Today, as `YYYY-MM-DD`, in the local timezone.
*
* The one place in this module that reads a clock, and it is not called from
* anywhere below, `ensureAgentMemory` takes the date. Callers that have no
* opinion use this.
*
* @param {Date} [now]
* @returns {string}
*/
export function isoDay(now = new Date()) {
const pad = (/** @type {number} */ value) => String(value).padStart(2, "0");
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
The two modules and one of the files they write, at tag module-36. agent-memory.js is whole; turn-record.js is its header and its three constants.

A file of one’s own

When an agent record is created, so is its scratchpad: knowledge-base/agents/<agentId>-<name-slug>.md. The file name’s shape is the anti-clobber argument in miniature. The id comes first because ids are unique by module 31’s counter and names are not, two agents both called “Research” get two files, where a name-only slug would hand the second one’s “append anything worth knowing” into the first one’s notes with no warning (record 0115 in the chat app’s repository). Lookup is by id prefix, never by the current name, because a rename mutates the record and does not move the file (record 0117). Creation never blocks a start: every failure is logged and swallowed, since notes are an amenity and the conversation is the product. An agent whose scratchpad could not be written is not told it has one, pointing it at a file that is not there is worse than silence (record 0118).

The scratchpads live one directory below the wiki root, and that placement does real work: module 34’s lint walks exactly the root, so working notes are never judged as curated pages. Their headers follow the schema anyway, volatility: high and provenance: extracted, because a human reading one gets the same header grammar as everywhere else even though no lint is watching (record 0116). The directory is runtime state, per machine, and gitignored like the workspace.

The boundary itself is four sentences in each agent’s persona: this file is yours, the rest of the wiki is not, the way into the rest is the capture skill from module 35. One more sentence makes the security story honest: the boundary is stated, not enforced. Nothing in this server stops an agent writing wherever the workspace allows, and the persona says so plainly rather than implying a permission system that is not there (record 0123). Enforcement is module 38’s subject; if you assumed this sentence was a wall, you learn otherwise here, not in an incident.

Every result frame now carries did: short strings, one per notable thing the turn did, in the order the calls finished, the order you saw if you were watching the activity feed. It hangs on the same two event branches that produce the activity frames and on nothing else, which is what lets fake mode fill it by the identical route the real SDK does (record 0121). Two refusals keep it trustworthy. The list is capped with the oldest kept, because the start of a turn is where intent shows and a frame must stay a frame, not a log file (record 0120). No entry ever carries an exit code the wire did not provide: the SDK’s tool result exposes is_error and nothing more, and inventing precision in the one field whose purpose is being trustworthy would defeat it (record 0122).

The list always closes with how the turn ended, so from this server it is never empty. From the real demo:

 1423ms    did       Read notes.md
 1423ms    did       Grep TODO
 1423ms    did       mail to agent-2
 1423ms    did       turn ended: success

The browser draws it under the answer as a marked block, “Recorded by the harness, not the agent,” in the server’s words untouched. One honest limit is pinned by a client test rather than hidden: an empty record renders nothing, including under an answer that claims work, because a client cannot tell a claim from an ordinary reply and any keyword rule fails in both directions (record 0126). You, reading a claim above no record block, are the detection mechanism, and the open ask for a better signal is filed, not forgotten.

One more piece landed through the repo’s own request pipeline. The turn you most want the record of is the one you cancelled halfway through a write, and did lived only on result. So the protocol grew, additively: turn-ending error frames may carry did too, and a cancelled turn’s is cut at the moment of the cancel, which is the honest cut because everything after it is suppressed from the wire anyway (record 0127, superseding part of 0124).

Watch the disagreement machine

git checkout module-36
WORKBENCH_FAKE_SDK=1 npm run dev

Create an agent and open knowledge-base/agents/ in your editor: the scratchpad is there before the agent could be told about it, header and all. Then run the mail exchange from module 32 and read each reply’s footer, the Read, the Grep, the delivery, the ending, none of it written by the model whose answer sits above it.

Build

The ladder:

  1. Run it. Two agents, one exchange, both footers read. Then cancel a turn mid-work and read the record on the error card: cut at your cancel, closed with interrupted.
  2. Read one file. server/src/agent-memory.js, whose header comments carry this module’s arguments in the code’s own words.
  3. Change one line and see it. Rename a live agent, then look at agents/: the file did not move, and record 0117 is why the lookup still finds it.
  4. Build. Add two agents with the same name and prove the anti-clobber rule: two files, ids first, and each agent’s persona names only its own.
The mistake most people make first

You trust the report. An agent answers “I updated the wiki and logged the change,” reads as done, and everyone moves on. The footer under that answer shows a Read and a Grep and no write at all. Nothing errored, and the agent was not lying in any way it could know about: models narrate intentions as completions constantly, and a system with no independent record has no way to notice. Scale it up and your wiki’s history is whatever the most confident narrator said it was.

The rule: the harness records what an agent did, and the agent’s own report is prose. The footer is assembled from events the model cannot touch, rendered in the server’s words with a mark saying who wrote it, and when report and record disagree, the record wins. That is also why this module refuses two tempting embellishments: no invented exit codes, and no unbounded list, because the footer’s entire value is that every line in it is true.

Does this travel?

The harness-written record travels to any runtime that sees tool events, and it is the strongest pattern in this module: the same idea guards every agent system where reports and reality can drift. The scratchpad is a file; the boundary-by-persona is a convention any harness can state. What does not travel is the wiring: the event branches, the frame field, and the schema header conventions are this app’s own.

Check yourself

  1. Two agents are both named “Research.” Walk what happens to the second one’s notes under a name-only slug, and name the property of ids that makes the shipped rule safe.
  2. An agent’s answer claims a wiki update; its footer shows no write. Which one does the client trust, which record says why, and what is the client honestly unable to detect on its own?
  3. Why does a cancelled turn’s record stop at the moment of the cancel rather than at the turn’s real end, and where does the rest of it go?