Module 31 · 55 min

A Roster of Agents

You can add an agent from the sidebar, and no process exists until you send it a message.

Surface
systemPrompt append · model
Workbench tag
module-31
Claude Code
v2.1.251 (bundled)
claude-agent-sdk
v0.3.251
Docs checked
2026-08-30

Why does adding an agent to the sidebar not spawn a Claude Code process? Because an agent is a record until the first message names it, and that distinction is about cost. One agent became a roster. The sidebar lists them, each with a name, a colour, and a model, and you add one by typing a name, rename it inline, remove it with a confirm step that names what is still open. A record is a line in a JSON file. A process is a running Claude Code subprocess with everything that costs. The roster holds records; the first message, and nothing else, turns one into a process.

flowchart TD
subgraph roster["agents.json: records"]
  a["orchestrator"]
  b["Scout"]
  c["Ledger"]
end
a --> ap["process: live<br/>(someone spoke to it)"]
b -.-> bp["no process"]
c -.-> cp["no process"]
Three records, one process. The dotted boxes cost nothing; the first message to an agent is the only thing that draws one solid.

Records, and the two lessons they taught

The roster lives in agents.json in the app-data directory module 30 created, written atomically and read back at boot. A corrupt file throws with its path rather than silently starting empty (record 0043). Each record is an id, a name, a model, and a colour. The orchestrator is a permanent record with the fixed id orchestrator: you name it whatever you like, and the remove control does not exist on its row, absent rather than greyed out, refused again in the frame builders and once more on the server.

The file that holds all of it is server/src/roster.js . Read the persona first, then the counter, then the writer that puts both on disk. The shape of agents.json is the object handed to JSON.stringify at the bottom of the file, so the format and the code that writes it are one thing to read.

chat-workbench @ module-31/
└─ server/
└─ src/
server/src/roster.js

Four regions of a 486-line file: the persona, the id counter, the id minter, and the atomic write that defines the agents.json shape.

// Lines 1 to 100 elided: the constants, the eight measured colours, and the AgentRecord typedef. Full file: server/src/roster.js at tag module-31.

/**
* The persona appended to one agent's system prompt.
*
* Short and factual on purpose. It says three things and stops: who this agent
* is, that there are others, and that it only sees its own conversation. It
* does not describe a job, assign a speciality, or ask for a tone, a persona
* that did any of that would be this module inventing behaviour the person did
* not ask for, on top of a preset it did not write.
*
* The orchestrator's line differs by one clause, because its role in the roster
* is a fact about the roster rather than a personality: it is the agent a
* message with no `agentId` reaches.
*
* This is `append`, never a replacement. See docs/decisions/0047.
*
* @param {AgentRecord} agent
* @returns {string}
*/
export function personaFor(agent) {
const role =
  agent.agentId === ORCHESTRATOR_ID
    ? "the orchestrator of a shared workbench roster, the agent a message reaches when no other is named"
    : "one agent on a shared workbench roster";
return [
  `You are ${agent.name}, ${role}.`,
  "Other agents on the roster have their own conversations; you cannot see them and they cannot see yours.",
  "Answer as yourself.",
].join(" ");
}

// The record validators and the class head elided. Next: the counter, as a field on the Roster class.

/**
 * The number the next `agent-N` id will use. Persisted, and only ever counts
 * up.
 *
 * A counter rather than "one past the highest id in the list", which was the
 * first version of this and was wrong: remove the highest-numbered agent and
 * the next one added takes its id back. A client holding frames from the old
 * `agent-3` would file the new one's under them. Counting on disk is what
 * makes "never reused" true across a removal and across a reboot.
 *
 * @type {number}
 */
#nextNumber = 1;

// The constructor and the load path elided. Next: the writer, and the object it serialises is the agents.json format.

/**
 * Write the roster, atomically.
 *
 * Two steps and the order is the whole property. The temporary file is
 * written and closed first, so by the time anything is renamed the bytes are
 * complete. Then `renameSync` replaces the target, on every platform this
 * server runs on, a rename within one directory either happened or did not,
 * so a reader either sees the whole old file or the whole new one. Writing
 * `agents.json` in place would leave a truncated file behind a crash, and the
 * next boot would refuse to start on it.
 *
 * The temporary file is in the same directory on purpose: a rename across
 * filesystems is a copy, and a copy is not atomic.
 */
#save() {
  const temp = `${this.#file}.${process.pid}.tmp`;
  const body = JSON.stringify(
    {
      version: ROSTER_FILE_VERSION,
      nextId: this.#nextNumber,
      agents: this.#agents,
    },
    null,
    2,
  );
  writeFileSync(temp, body + "\n", "utf8");
  renameSync(temp, this.#file);
  this.#logger.debug("roster.saved", { agents: this.#agents.length });
}

// The colour chooser elided.

/**
 * The next agentId. `agent-1`, `agent-2`, and so on. Consumes the counter.
 *
 * @returns {string}
 */
#nextId() {
  const id = `agent-${this.#nextNumber}`;
  this.#nextNumber += 1;
  return id;
}

// list, add, rename and remove follow, and end the file at line 486.
Read more

The rest of the file is the palette, the record validators, the load path that refuses a corrupt file with its path in the error, and add, rename and remove. roster.js never starts a session; session.js never writes a file.

Two files at the module-31 tag. roster.js first: the persona, the counter, and the writer that puts agents.json on disk.

Two records from this round teach something bigger than this app. Record 0044: agent ids come from a persisted counter, never from max(existing) + 1, because the first attempt used the maximum and its own test caught the consequence, remove agent-3 and the next add mints a second agent-3, inheriting any state keyed to the dead one. Ids are never reused; the protocol now says so as a guarantee. And record 0047: an agent’s persona travels as systemPrompt: { type: "preset", preset: "claude_code", append: <persona> }, never as a bare string, because a bare string does not add to the harness’s system prompt, it replaces it, and the agent that results has lost the instructions that make the tools work. Module 07 taught append-versus-replace on the CLI; this is the same cliff on the SDK surface, one wrong shape away.

One boundary per agent

Each spoken-to agent has its own conversation: its own queue, its own turn boundary, its own session frame now carrying agentId. Nothing serializes across agents, so Scout can be mid-turn while Ledger answers, and the module 29 rules hold per thread. Removing a live agent closes its conversation the polite way: every queued and in-flight message is answered with an error first, then the roster answer arrives, an ordering the tests assert as a sequence rather than as two facts (record 0049).

The look lands here

The sidebar is the app’s first real layout, so this is where the design commits: the site’s own grounds, white and #141414, colour reserved for the one place it carries meaning, the agent swatch. The eight agent colours were measured, not picked: they sit at the relative luminance where contrast against white and against #141414 come out equal, table in record 0045. And committing to the ground caught a real bug: re-measuring the existing palette against #141414 exposed the old accent blue at 3.68:1 on raised surfaces, an AA failure the pure-black ground had been masking, fixed with the site’s own blue at 7.25:1 (record 0068). A colour is only right relative to its ground; change the ground and every ratio is a new question.

Watch the lazy start

git checkout module-31
WORKBENCH_FAKE_SDK=1 npm run dev

Add two agents. Open your process list, Task Manager or ps, and count the claude processes: none for either. Message one of them, and one appears. The demo shows the same fact as frames, from a real run:

    9ms  roster [add-2] orchestrator record | agent-1="Scout" record | agent-2="Ledger" record
   10ms  session      agentId=agent-1 sessionId=fake-session-agent-1
 1211ms  error [m2] the agent was removed from the roster
 1212ms  roster [remove-1] orchestrator | agent-1="Scout" live
 1212ms  error [remove-2] the orchestrator cannot be removed from the roster

Three agents and zero processes at nine milliseconds; a session only where a message went; the removal’s errors landing before its roster; the orchestrator refusing removal. In fake mode the process being saved is a fake, so the count you should trust is the one from your own process list against a real key.

Build

The ladder:

  1. Run it. Two agents added, one messaged, the process count watched. Then remove the messaged one mid-turn and read the order the frames arrive in.
  2. Read one file. server/src/roster.js, with records 0043 and 0044 beside it, and find where the counter is persisted.
  3. Change one line and see it. Start with WORKBENCH_MODEL=claude-sonnet-5 and read the model on the roster rows and the next session frame. The sentinel default sends no model key at all, which is not the same thing as sending it empty.
  4. Build. Rename an agent, then confirm two facts: agents.json changed on disk, and no second session frame arrived. Record 0048 names the cost of that design: a live session keeps its old name in its persona until its next conversation. Decide whether you would pay it the other way, and write your reasoning down the way these records do.
The mistake most people make first

You start every roster entry at launch, because lazy start is more code and eager start makes the sidebar’s dots all green. Eight agents, eight Claude Code subprocesses, before anyone has said a word to any of them. Each idle one holds memory all day for a conversation that may never happen: the app this course studied recorded roughly 400 MB per idle session, a figure measured on its owner’s machine in August 2026, not a documented number, and the right response to it is to measure your own rather than quote theirs. The cost is real even if your number differs; the shape of the mistake is paying per record when you only need to pay per conversation.

Lazy start is one rule in one place: conversationFor is the only site in the server that constructs a conversation, and the only caller that matters is the first message naming the agent (record 0046). The test that keeps it honest asserts the session factory is never called by add, rename, remove, or the roster listing, which is a stronger claim than “it seemed fast”.

Does this travel?

The roster pattern travels whole: records until first use, one runtime per active thing, ids from a counter, and the eager-start trap are general to every multi-agent runtime. What does not travel is the persona mechanism, the preset-plus-append shape and the model option are this SDK’s, and module 25’s SDK subagents are a different thing again: those share their parent’s process, and these each own one.

Check yourself

  1. A teammate implements agent ids as agent-${list.length + 1}. Walk the sequence of adds and removes that makes two different agents share an id, and name the state that gets crossed when it happens.
  2. An agent’s persona is passed as systemPrompt: "You are Scout." and the agent stops being able to use its tools properly. What happened, and which one-line shape fixes it?
  3. Your app shows eight agents, two of them with live dots. How many Claude Code subprocesses exist, what did the other six cost, and which single function would you read to prove it?