- Book: AI That Plans
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
The supervisor pattern is the first multi-agent shape most teams try,
and it is a good one: a coordinator decides who works next, workers
do narrow jobs, control returns to the coordinator.
The part that goes wrong is not the routing. It is state ownership —
and specifically what happens when two workers write the same channel
in the same superstep. There is no error. One update wins, quietly.
The shape
import { Annotation, StateGraph, START, END } from "@langchain/langgraph";
const Team = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: messagesStateReducer,
default: () => [],
}),
task: Annotation<string>(),
next: Annotation<Worker | "FINISH">(),
research: Annotation<string[]>({
reducer: (a, b) => a.concat(b),
default: () => [],
}),
draft: Annotation<string | null>({ default: () => null }),
});
type TeamState = typeof Team.State;
type Worker = "researcher" | "writer" | "reviewer";
Each channel has a declared merge rule. research accumulates,
draft replaces, messages appends by id. Writing that down per
channel is the design work — and it is where the bug below is either
prevented or created.
The supervisor
const Route = z.object({
next: z.enum(["researcher", "writer", "reviewer", "FINISH"]),
reason: z.string().max(200),
});
async function supervisor(state: TeamState) {
const res = await model.invoke([
new SystemMessage(SUPERVISOR_PROMPT),
new HumanMessage(summarise(state)),
], { response_format: toJsonSchema(Route) });
const { next, reason } = Route.parse(JSON.parse(textOf(res)));
return {
next,
messages: [new AIMessage(`Routing to ${next}: ${reason}`)],
};
}
summarise(state) rather than the raw state is deliberate. A
supervisor that receives every worker's full output pays for all of
it on every routing decision, and the decision only needs to know
what exists and what is missing.
function summarise(s: TeamState) {
return [
`Task: ${s.task}`,
`Research items: ${s.research.length}`,
`Draft: ${s.draft ? "present" : "none"}`,
`Reviewed: ${s.reviewed ? "yes" : "no"}`,
].join("\n");
}
Four lines of state instead of four thousand tokens of content.
Wiring the return path
const graph = new StateGraph(Team)
.addNode("supervisor", supervisor)
.addNode("researcher", researcher)
.addNode("writer", writer)
.addNode("reviewer", reviewer)
.addEdge(START, "supervisor")
.addConditionalEdges("supervisor", (s) =>
s.next === "FINISH" ? END : s.next,
)
.addEdge("researcher", "supervisor")
.addEdge("writer", "supervisor")
.addEdge("reviewer", "supervisor")
.compile({ checkpointer });
Every worker edge goes back to the supervisor. That is what makes it
a supervisor rather than a pipeline — workers never choose the next
worker, and adding one does not change any existing edge.
Annotate the router return type as Worker | typeof END so a typo
fails at compile time rather than at run time.
The failure: two writers, one channel
Now the part that costs a day.
Suppose you run two researchers in parallel — a common optimisation,
since research is independent.
.addConditionalEdges("supervisor", () => ["researcher_a", "researcher_b"])
Both nodes execute in the same superstep. Both return { draft: ... }
because someone had them each produce a summary paragraph.
// researcher_a returns
{ research: ["finding A"], draft: "Draft from A" }
// researcher_b returns
{ research: ["finding B"], draft: "Draft from B" }
research is fine — its reducer concatenates, so you get both
findings. draft has no reducer, which means replace. Two
replacements arrive for the same channel in the same step, and one
wins.
Nothing throws. The state is valid. You have silently lost one
worker's output, and the run continues as if it never existed.
The symptom is a run that is subtly incomplete in a way that varies
between executions. Debugging it by reading the transcript is
hopeless, because the discarded update was never a message.
Encode ownership in the type
The fix is not "be careful". It is making an unowned write
impossible to express.
type Owner = "researcher" | "writer" | "reviewer";
type Writes<O extends Owner> =
O extends "researcher" ? { research: string[] } :
O extends "writer" ? { draft: string } :
O extends "reviewer" ? { reviewed: boolean; notes: string[] } :
never;
function node<O extends Owner>(
owner: O,
fn: (s: TeamState) => Promise<Writes<O> & { messages?: BaseMessage[] }>,
) {
return fn;
}
Now a worker can only return the channels it owns:
const researcher = node("researcher", async (s) => {
const found = await search(s.task);
return { research: found }; // ok
// return { draft: "..." }; // does not compile
});
The compiler enforces what the reducer cannot. Any channel written by
more than one owner has to either get a reducer that merges, or be
split into per-owner channels.
Give parallel channels merge semantics
Where two workers genuinely both produce a value, the channel needs a
reducer that keeps both:
drafts: Annotation<Record<Owner, string>>({
reducer: (a, b) => ({ ...a, ...b }),
default: () => ({} as Record<Owner, string>),
}),
Keyed by owner, merged by spread. Both survive, and the supervisor
can compare them. That is a real design decision — "two drafts and
pick one" versus "one draft" — made explicit instead of resolved by
whichever node happened to finish last.
Test routing without a model
The supervisor's decision function is the highest-risk logic and the
easiest to test, once you separate the decision from the model call.
export function decide(s: TeamState): Worker | "FINISH" {
if (s.research.length === 0) return "researcher";
if (!s.draft) return "writer";
if (!s.reviewed) return "reviewer";
return "FINISH";
}
A deterministic policy, unit-testable in milliseconds:
it("finishes only when all stages are complete", () => {
expect(decide({ ...base, research: ["x"], draft: "d", reviewed: true }))
.toBe("FINISH");
});
Then let the model handle the cases the rules do not cover, with the
deterministic path as a guard. Many supervisors do not need a model
at all — the routing is a state machine, and using an LLM for it adds
latency, cost, and nondeterminism to a decision that has a correct
answer.
Worth checking before building an LLM supervisor: write decide as a
function first, and see whether anything is left over.
Two more failure modes
Ping-pong. Supervisor sends to writer, writer produces nothing
useful, supervisor sends to writer again. Cap it:
visits: Annotation<Record<string, number>>({
reducer: (a, b) => {
const out = { ...a };
for (const [k, v] of Object.entries(b)) out[k] = (out[k] ?? 0) + v;
return out;
},
default: () => ({}),
}),
Refuse to route to a worker visited more than three times without
state having changed.
Context accumulation. Every worker appends to messages, and the
supervisor sees all of it. Keep worker output in owned channels and
messages for coordination only, or the supervisor's context grows
with every hop.
If this was useful
AI That Plans covers
multi-agent design — supervisor and peer topologies, state ownership,
merge semantics for parallel work, and the routing tests that keep a
team of agents debuggable.
The full series is at
xgabriel.com/ai-in-typescript.



Top comments (0)