Skip to content

Course · Pi · Teams

Orchestrator, lead, worker

Go back to how the last chapter’s fan-out actually worked: your driver spawned the import’s 26 batch children with the same spawn("pi", ["--mode","json","-p", ...]) call from the spawn lesson, capped five at a time, collected them with Promise.allSettled, and read the summaries back. Flat, one hop, done. That shape was exactly right for the import, because every child was doing the same narrow job on a different batch - there was nothing to coordinate, only work to divide.

The readability migration in stash (the course’s running example, a small FastAPI feed reader) doesn’t divide that cleanly. It splits into genuinely different domains - the extraction code in stash/extract.py and the sync-path step that feeds it, the storage in stash/db.py and stash/models.py that richer rows have to live in, the HTTP surface in stash/main.py that has to answer for them - and inside each domain there’s more than one worker’s worth of work.

Fan all of that out flat from one thread and you’re not delegating anymore, you’re relaying: reading every domain’s status, deciding what each one should do next, holding all three domains’ context in your own head at once. More agents working the migration, and the coordination lands on you anyway. The layer you’re about to add exists to move it off you.

Count the messages before you add anything

Section titled “Count the messages before you add anything”

Before reaching for the fix, guess. Here’s the fixed roster this migration needs, and it’s small enough to count by hand. The extraction lead needs three workers - one to rebuild extract_meta on readability-lxml and pin it with tests, one to wire the fetch-page-and-extract step into the sync path, one to classify the stored entries whose pages yield nothing instead of forcing them.

The storage lead needs two - carry the richer article through the Article model and turn save_article’s insert-or-ignore (the context chapter’s ON CONFLICT(link) DO NOTHING) into an upsert that updates the existing row, then re-process every stored row and confirm nothing lands twice. The interface lead needs two - reshape what GET /articles returns, and make POST /feeds/{id}/sync report how many rows actually got extracted. Three domains, three leads, seven workers.

Fan all seven out flat, straight from your own thread, with nothing standing between you and them. Guess: how many separate status messages land in your window before the migration is called done?

Seven, at minimum - one per worker, more if any of them report progress before they report done. Now run the same migration through a lead per domain instead. You still commission the exact same seven workers’ worth of work; nothing about the migration got smaller. But you never read a worker’s report. You read three - one per lead, each one already digested down to “domain done, here’s what changed.”

What you personally have to read drops from seven to three, and that ratio gets better the more workers a domain needs, not worse - a lead running eight workers instead of two still hands you back exactly one report.

The fix isn’t a new primitive - it’s the same spawn mechanism from the last chapter, used one level deeper. A lead is a spawned pi process, exactly like a worker, except its job is never to edit a file. Its job is to plan its domain’s worker briefs, then spawn and manage the workers underneath it; when it needs eyes on its own code, it dispatches a read-only worker rather than looking itself, and the lock below makes that the only path. For the migration, that’s three leads:

  • Extraction lead - owns stash/extract.py and the sync-path step that fetches an entry’s page and runs the extractor. Delegates workers for “rebuild extract_meta on readability-lxml and pin it with tests,” “wire the fetch step into the sync path,” and “classify the entries whose pages yield nothing.”
  • Storage lead - owns stash/db.py and stash/models.py. Delegates workers for “carry the richer article through the Article model and turn save_article’s insert-or-ignore into an upsert” and “re-process every stored row; confirm nothing lands twice.”
  • Interface lead - owns stash/main.py’s HTTP surface. Delegates one worker per endpoint concern: what GET /articles returns now, and what the sync endpoint reports as extracted versus saved.

You - or your primary Pi session - become the orchestrator: you talk to three leads, not to every worker underneath them. Your own prompt burden doesn’t grow whether each lead is running one worker or five; that scaling happens entirely underneath the leads, out of your sight.

The hard rule: leads don’t work, they delegate

Section titled “The hard rule: leads don’t work, they delegate”

This only holds together if leads are structurally prevented from doing direct work, not just asked nicely not to. The mechanism is the tool-surface lock you’ll recognize from the last chapter’s spawn primitive: strip a lead down to a single dispatch tool on session start, so it cannot open edit or write even if it wanted to -

pi.registerFlag("role", {
description: "Which role this spawned agent plays: \"lead\" or \"worker\"",
type: "string",
default: "worker",
});
pi.on("session_start", async (event, ctx) => {
if (pi.getFlag("role") === "lead") {
pi.setActiveTools(["dispatch_agent"]);
}
});

There’s no ambient “am I a lead” property Pi hands you - a spawned process only knows its own role because its parent told it, on the command line, the same way the orchestrator passes --tools and --model to every child it spawns: spawn("pi", [..., "--role", "lead"]). pi.registerFlag declares that flag so Pi’s own CLI parser accepts the invocation instead of rejecting it as unknown, and pi.getFlag reads back whatever the parent passed.

Two details from Pi’s extension docs make this particular lock safe. setActiveTools ignores names that aren’t registered yet - and extension factories finish running before session_start ever fires, so by the time the lock clamps down, the dispatch tool registered in a different extension file is already on the list. Reverse the two dependencies and the lock silently does nothing, which is exactly the kind of failure an unattended run never surfaces on its own.

dispatch_agent is the same child-process spawn you already know, just called from inside a lead instead of from your main thread - it hands a worker a scoped brief, waits, and returns the worker’s summary. The companion repo ships .pi/extensions/ empty, so create this file yourself:

.pi/extensions/dispatch.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { spawn } from "node:child_process";
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "dispatch_agent",
label: "Dispatch agent",
description: "Spawn a worker Pi process with a scoped brief and return its summary",
parameters: Type.Object({
task: Type.String({ description: "The scoped brief for the worker" }),
agent: Type.String({ description: "Which agent name to spawn" }),
}),
async execute(_toolCallId, { task, agent }, signal) {
const child = spawn("pi", [
"--mode", "json",
"-p", `You are the ${agent} agent. ${task}`,
"--no-session",
"--tools", "read,edit,bash",
"--thinking", "off",
], { stdio: ["ignore", "pipe", "pipe"] });
let summary = "";
let buffer = "";
child.stdout.on("data", (chunk) => {
buffer += chunk;
let newlineAt;
while ((newlineAt = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, newlineAt);
buffer = buffer.slice(newlineAt + 1);
if (line.trim()) handleEvent(JSON.parse(line));
}
});
function handleEvent(event) {
if (event.type === "agent_end") {
const last = [...(event.messages ?? [])].reverse().find((m) => m.role === "assistant");
summary = typeof last?.content === "string"
? last.content
: (last?.content ?? [])
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n");
}
}
signal.addEventListener("abort", () => child.kill(), { once: true });
await new Promise((resolve) => child.on("exit", resolve));
return {
content: [{ type: "text", text: summary || `${agent} completed with no summary` }],
details: { agent },
};
},
});
}

The splitting loop is the same LF-only buffer splitter from the spawn lesson, kept line-for-line identical, for the same reason it earned a section there: Node’s readline also splits on U+2028 and U+2029, both legal inside JSON strings, and a lead’s workers stream model text all day. One honest wart in this sketch: the worker toolset is hardcoded to read,edit,bash - right for this migration’s editing workers, wrong in general. A dispatch tool worth keeping takes the toolset from the brief, the same per-process scoping the spawn lesson argued for.

Skip the lock and you get a specific, predictable failure: a worker stalls or comes back empty, and the lead - reasonable-sounding, in its own output - decides “the task has to get done, let me just write the file myself.” That’s not a bug in the model, it’s the model behaving exactly like a manager who steps in when a report goes quiet.

The problem is what it costs you: an unenforced lead is a worker with a bigger title and none of the accountability, and you’ve lost the one property - one domain, one owner, one place to look when something’s wrong - that made the layer worth adding. Enforce the lock; don’t rely on the prompt to hold it. It’s also the same move that answered “who verifies the verifier” in the last chapter: don’t add a watcher, remove the option to misbehave.

A stalled worker isn’t the only failure this layer has to survive. The tool lock stops a lead from doing a worker’s job badly. It does nothing about a lead doing its own job badly - misreading where extract_meta is even called from, planning a worker brief that misses the actual failure, going quiet mid-migration with no worker spawned at all. Nothing here catches that automatically, and nothing should pretend to: a wrong lead is still your problem, the same way a wrong worker was your problem one chapter ago.

What changed is what it costs you to catch it. Reading three lead reports carefully is a real option; reading seventy raw worker messages carefully never was. The layer that shrinks what you read is exactly what buys you the attention to catch a lead that’s wrong, not just one that’s quiet.

Why decompose at all - and the answer that’s wrong

Section titled “Why decompose at all - and the answer that’s wrong”

Here’s the tempting, wrong justification: “we split this into three teams because one context window can’t hold the whole migration.” Don’t reach for that one. stash is 11 project files and 241 lines - count it yourself with wc -l - and the migration’s total reading material would fit in a single frontier context with room to spare. If context size were the real constraint, a bigger window would make the whole team unnecessary, and it doesn’t.

The real reason is narrower and holds regardless of window size: a focused agent is a performant agent. An agent whose only brief is “rebuild extract_meta, pass pages through it” makes fewer mistakes than one that’s also carrying the schema change in db.py and the endpoint’s response shape in the same context - not because it runs out of room, but because unrelated problems mixed into one context raise the error rate on all of them.

Guess before you toggle: three domains’ worth of reading material, run one after another in a single thread, before any migration work actually starts - does that land under half this window, or past it? The bar starts on the inline side; toggle to delegate and check the difference. The bars are illustrative, sized to show the shape, not a measurement from a real run. Then toggle to delegate and watch the same reading happen in three windows you never sit inside - your own window ends up holding three short reports instead.

Your window136k / 200k (68%)

Three domain sweeps, sequentially, in your window. Every file read and dead-end grep lands next to the migration work you actually came here to do - illustrative bars, but the shape holds: past halfway before any of it is done, and the sweeps ran one at a time.

The point isn’t that delegation saves tokens - total tokens spent are roughly the same either way, maybe more once you count each lead’s own overhead on top of its workers’. What it buys is a context that stays legible: your orchestrator thread holds three short lead reports, not three domains’ worth of file reads and dead-end greps tangled together.

That legibility is the whole argument for teams, not the size of the window.

If you’ve built or read about multi-agent systems before, this shape has a name. Orchestrator, leads, workers, nobody but the parent talking to their own children - that’s the supervisor pattern, the same shape a worker pool or an actor-model supervisor tree uses, just nested one level. This chapter deliberately does not use the other common shape, the blackboard pattern, where agents read and write one shared state store instead of passing messages to each other.

The nearest thing to a blackboard this repo has is stash.db itself - every lead can read and write it - and coordination still runs through messages here, because one lead silently reading another lead’s half-finished migration writes is precisely the ambient, nobody-decided context this whole course has been arguing against. That is exactly what the who-decided question exists to catch: name who put a piece of context in play, and “nobody, it was just sitting there” isn’t an answer it accepts.

One honest question before you build any of this for a migration this small: is three tiers overkill here? Partly, yes. A two-tier version - you, talking directly to three leads who also do their own domain’s work instead of delegating it further - would probably clear a migration this size just fine; nothing about seven workers demands a third rung on the ladder.

The third tier earns its cost specifically when a domain is both wide (more than a couple of workers) and something you don’t want to context-mix with the other domains - which extraction, storage, and interface all are here, barely. Don’t take “it worked” as proof the shape was necessary. Take it as proof the shape wasn’t wrong, which is a lower bar.

Three domains, three leads, seven workers between them, and one rule enforced by a tool lock rather than a hope: nobody above worker tier touches a file. Sketch the roster the way you’d sketch any config - a name per lead, a list of workers underneath it - and it stays exactly this legible however many workers a lead ends up needing, because your prompt to the orchestrator never gets longer.

Priced honestly, a lead costs one more agent’s own overhead per domain, on top of every worker beneath it, and buys a report count that stays flat as workers scale - three reports whether each lead runs two workers or eight. You decided that trade; nothing in Pi asked for it.

What none of this gives a lead yet is memory of its own lane. Spawn the extraction lead again next session and it starts from zero, even though it spent an entire migration learning exactly which pages yield nothing to readability-lxml.

Next: give each lead a place to keep that.