Skip to content

Course · Pi · Teams

Orchestrator, lead, worker

Go back to how the last chapter’s fan-out actually worked: your main thread spawned N children with spawn("pi", ["--mode","json","-p", ...]), waited on all of them with Promise.allSettled, and read N summaries back. Flat, one hop, done. That shape was exactly right for re-extracting a pile of saved URLs, because every child was doing the same narrow job on a different input - there was nothing to coordinate, only work to divide.

The extraction-engine migration doesn’t divide that cleanly. It splits into genuinely different domains - the extractor module itself, the search index that has to be rebuilt against whatever the new extractor produces, the web UI and CLI that render whatever shape comes out the other end - 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 just 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 - that’s this chapter’s villain, and the fix is the layer you’re about to add.

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 - swap the library, handle the sites that broke the old extractor, backfill the test fixtures. The index lead needs two - reindex every already-saved row, confirm the accented-character cases that used to get dropped now round-trip. The interface lead needs two - one per surface, the web UI and the CLI. 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 read enough of its domain to plan, then spawn and manage the workers underneath it. For the migration, that’s three leads:

  • Extraction lead - owns the extractor module and the fetch worker’s call site. Delegates workers for “swap the library,” “handle the sites that broke the old extractor,” and “backfill the test fixtures.”
  • Index lead - owns the full-text search rebuild. Delegates workers for “reindex every already-saved row” and “confirm the accented-character cases that used to get dropped now round-trip.”
  • Interface lead - owns the tiny web UI and the CLI’s rendering of saved entries. Delegates a worker per surface.

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 it instead of rejecting the invocation as unknown, and pi.getFlag reads back whatever the parent passed.

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 (or polls), and returns the worker’s summary. A lead that only has this one tool available cannot quietly fix something itself, because the tool to do that isn’t on its list.

.pi/extensions/dispatch.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { spawn } from "node:child_process";
import readline from "node:readline";
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", task,
"--no-session",
"--tools", "read,bash",
"--thinking", "off",
], { stdio: ["ignore", "pipe", "pipe"] });
const lines = readline.createInterface({ input: child.stdout! });
let summary = "";
signal.addEventListener("abort", () => child.kill(), { once: true });
for await (const raw of lines) {
const event = JSON.parse(raw);
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");
}
}
return {
content: [{ type: "text", text: summary || `${agent} completed with no summary` }],
details: { agent },
};
},
});
}

Skip this 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 in the first place. Enforce the lock; don’t rely on the prompt to hold it. This is the same move that answered “who verifies the verifier” 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 - reading the extractor module wrong, 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 actually 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. Context windows are large and getting larger, and the extraction migration’s total reading material - one extractor module, one index rebuild, two small UI surfaces - 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 “swap the extractor, pass rows through it” makes categorically fewer mistakes than one that’s also carrying the index rebuild’s tokenizer quirks and the UI’s rendering assumptions 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. Watch what happens to a single thread’s window just from reading material for three unrelated sweeps, before any of the actual migration work starts.

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? Toggle “Run it inline” and check. 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.

Notice 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. File that away - it’s the first line you’ll add to the running ledger at the end of this chapter.

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 to one shared state store instead of passing messages to each other. Shared mutable state would let the index lead peek at the extraction lead’s in-progress notes without either side making a decision about it - which is precisely the kind of ambient, nobody-decided context this whole course has been arguing against. The ledger’s fourth column exists to catch exactly that: every row names who decided, and “nobody, it was just sitting there” isn’t an answer the column 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, index, 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 own prompt to the orchestrator never gets longer.

A lead per domain: report count stays flat as workers scale, at the cost of one more agent’s own overhead sitting on top of every worker beneath it. You decided where that line goes. That’s this chapter’s row in the running ledger:

LayerWhat it costsWhat it buysWho decided
Lead (tool-surface locked to dispatch_agent only)one more agent’s own overhead, per domain, on top of every worker beneath ita report count that stays flat as workers scale - 3 reports whether each lead runs 2 workers or 8you

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 sites break the new extractor. Next: give each lead a place to keep that.