tl;dr. the supervisor pattern is a hub-and-spoke team:
specialist workers, and one routing agent whose only job is
deciding who works next. workers never talk to each other — everything
round-trips through the hub. Command lets the supervisor
return a routing decision and a state update in one value. it's the
boring team shape, which is why it's the one to reach for first.
a team is agents plus a routing decision
lesson three ended at the twenty-tool agent — one generalist drowning in its own menu. the fix is division of labour: three narrow agents, each with a toolset small enough to use well. but the moment there are three, someone has to decide whose turn it is. that decision is the actual design problem of multi-agent systems, and the supervisor pattern answers it the simplest way: make the decision a node.
// ┌────────────┐
// START ───► │ supervisor │ ◄─────────┐
// └─────┬──────┘ │
// reads state, picks the │
// next worker — or END │
// ┌─────────┼──────────┐ │
// ▼ ▼ ▼ │
// ┌─────────┐ ┌───────┐ ┌────────┐ │
// │ billing │ │ tech │ │ orders │ │
// └────┬────┘ └───┬───┘ └────┬───┘ │
// └──────────┴──────────┴───────────┘
// every worker reports back to the
// supervisor. nobody talks sideways. the supervisor node
import { StateGraph, MessagesAnnotation, Command, START, END } from "@langchain/langgraph";
import { z } from "zod";
const WORKERS = ["billing", "tech", "orders"] as const;
// the supervisor is a model call with ONE job: pick the next hop.
// structured output, not prose — the router should never parse.
const routeSchema = z.object({
next: z.enum([...WORKERS, "done"]),
reason: z.string().describe("one line, for the trace"),
});
async function supervisor(state: typeof MessagesAnnotation.State) {
const { next, reason } = await model
.withStructuredOutput(routeSchema)
.invoke([
{ role: "system", content: SUPERVISOR_PROMPT },
...state.messages,
]);
// Command = routing decision + state update in one return value
return new Command({
goto: next === "done" ? END : next,
update: { messages: [aiMsg(`→ ${next}: ${reason}`)] },
});
} - structured output, always.
the route is an enum, not prose.
withStructuredOutputmeans the router never regex-parses "i think billing should handle this" — the failure mode lesson two warned about, at the exact spot it does the most damage. - Command is goto + update.
a node returning
Commandcombines "where next" with "what changed" — routing logic lives inside the node instead of a separate conditional edge. declare the possible destinations withendsso the graph stays statically known. - the reason field is for you. one line of routing rationale in the trace costs a few tokens and answers "why did billing get this?" at 11pm. observability starts in the schema.
workers are lesson-three agents
// each worker is a lesson-three agent,
// mounted as a single node. narrow prompt, narrow toolset.
const billing = createAgent({ model: fast, tools: billingTools,
systemPrompt: "you handle invoices and payment plans. nothing else." });
const tech = createAgent({ model: strong, tools: techTools,
systemPrompt: "you debug product issues. nothing else." });
const orders = createAgent({ model: fast, tools: orderTools,
systemPrompt: "you handle order state. nothing else." });
const team = new StateGraph(MessagesAnnotation)
.addNode("supervisor", supervisor, { ends: [...WORKERS, END] })
.addNode("billing", billing)
.addNode("tech", tech)
.addNode("orders", orders)
.addEdge(START, "supervisor")
// workers always report back — the supervisor owns control flow
.addEdge("billing", "supervisor")
.addEdge("tech", "supervisor")
.addEdge("orders", "supervisor")
.compile(); two things earn attention here. the workers' return edges are fixed — a worker finishes and reports back, no negotiation. keeping workers dumb about control flow is what makes them reusable and testable in isolation; they're personas, and the graph is the thing that composes them. and the models are mixed on purpose: routing and judgment get the stronger model, mechanical workers get the faster one. that mix is what keeps a daily-run agent team affordable rather than a demo you can't afford to leave on.
@langchain/langgraph-supervisor packages this pattern —
one createSupervisor call with your agents and llm, done. use it
once you've built the shape by hand once; the library's defaults (what
workers see of history, how handoffs are worded) are exactly the knobs
you'll need to reason about when routing goes weird.
what the supervisor should not do
the pattern degrades the same way in every codebase: the supervisor
starts helping. it answers the easy questions itself, it
summarizes worker output, it patches a worker's half-done job. now
routing quality and answer quality are tangled in one prompt, and
improving either degrades the other. hold the line: the
supervisor routes. if it keeps answering trivial questions
itself, that's your signal the team is missing a general
worker — add the specialist, don't grow the router. one prompt, one
job — the same rule that governs every persona in the pipeline.
when it breaks
- the hub is a bottleneck by design. every worker round-trips through the supervisor: latency and tokens scale with hops. for long specialist-to-specialist exchanges, a swarm's direct handoffs cut the middleman.
- routing loops. billing sends it back, supervisor re-sends it to billing, forever. track hops-per-worker in state and force an escalation path — to a different worker or a human — after two bounces.
- workers that see too much. handing every worker the full conversation history bloats context and leaks concerns across specialists. decide per-worker what slice of state it needs — it's a handoff payload, and payload design is the contract.
next: swarms — delete the hub, let agents hand off directly, and find out what that buys and what it costs.