The Supervisor / Router Pattern, Implemented via the Reactive Reducer
A little background
Two articles in, and the patterns so far have been about shape: a line of stages, then a fan-out with a join. This one is about authority - the supervisor / router pattern, where one agent holds the wheel and decides, at each step, who acts next.
If you’ve built this in a framework with tool-calling agents, you know the usual shape: the supervisor is an LLM with a set of call_<agent> tools, and routing is whatever the model decides to invoke. That works, until the model decides to do the work itself, or calls the wrong agent, or routes to an agent that doesn’t exist. The routing policy lives in the model’s head, which is to say it’s emergent, and you find out what it is by reading the trace after the fact.
The reactive reducer does something different: the supervisor’s decision is a context write against a typed field, and the routing table is the guards. The supervisor picks; the machine dispatches. The policy is data you can read before the run, not behavior you discover after it.
The pattern
Supervisor / Router. A central supervisor retains control of the workflow and decides which specialist agent should act next based on the current task and state. Specialists typically return their results to the supervisor rather than directly transferring control to one another. The supervisor may route multiple times, request revisions, call several agents, and ultimately synthesize the final answer.
Good for:
- Tool-rich assistants
- Specialist agent teams
- Customer support systems
- Coding agents
- Systems where centralized control is desirable
Common failure points:
- Supervisor becomes a bottleneck and single point of failure
- Bad routing poisons the whole task
- Supervisor can consume excessive context
- Specialists may become overly dependent on the supervisor’s interpretation
The machine: a hub state with a typed wheel
Here’s a customer-support supervisor that can route to a billing specialist, a technical specialist, or a security specialist - multiple times, in any order - and synthesize when it’s ready. The whole routing policy is one enum and a handful of guards.
fsm:
name: support-router
version: 1
initialState: triage
context:
request: { type: string }
route: { type: enum, values: [billing, technical, security, synthesize] }
finding: { type: string } # the specialist's report - CONSUMED each hop
notes: { type: list, of: string } # the running record - never cleared
answer: { type: string }
agents:
supervisor: { canTransition: [triage, dispatch, synthesize] }
billing_agent: { canTransition: [billing] }
tech_agent: { canTransition: [technical] }
security_agent: { canTransition: [security] }
states:
triage:
description: >-
Read the customer request and decide the first specialist: billing, technical, or security.
Then stage: updateContext(field=request, value=<gist of the request>) and
updateContext(field=route, value=<the specialist>).
requires:
- { name: request, from: agent }
- { name: route, from: agent }
transitions:
- { toState: billing, guards: [ { backend: cel, expression: "context.route == 'billing'" } ] }
- { toState: technical, guards: [ { backend: cel, expression: "context.route == 'technical'" } ] }
- { toState: security, guards: [ { backend: cel, expression: "context.route == 'security'" } ] }
- { toState: synthesize, guards: [ { backend: cel, expression: "context.route == 'synthesize'" } ] }
billing:
description: >-
Resolve the billing question from "{{ context.request }}", then stage:
updateContext(field=finding, value=<what you found>).
requires:
- { name: finding, from: { agent: billing_agent } }
exit:
# Keep the work, consume the trigger. `exit` runs before the edge, so `finding` is
# still set when it is folded into the record.
- { append: { notes: "${ 'billing: ' + context.finding }" } }
transitions:
- toState: dispatch
# Both are consumed: `route` so the edge can't re-fire, `finding` so a SECOND visit
# to this specialist actually asks it again instead of being satisfied by its last report.
onTransition: [ { assign: { route: "", finding: "" } } ]
technical:
description: >-
Investigate the technical issue from "{{ context.request }}", then stage:
updateContext(field=finding, value=<what you found>).
requires:
- { name: finding, from: { agent: tech_agent } }
exit:
- { append: { notes: "${ 'technical: ' + context.finding }" } }
transitions:
- toState: dispatch
onTransition: [ { assign: { route: "", finding: "" } } ]
security:
description: >-
Assess the security angle from "{{ context.request }}", then stage:
updateContext(field=finding, value=<what you found>).
requires:
- { name: finding, from: { agent: security_agent } }
exit:
- { append: { notes: "${ 'security: ' + context.finding }" } }
transitions:
- toState: dispatch
onTransition: [ { assign: { route: "", finding: "" } } ]
dispatch:
description: >-
A specialist has reported back. Everything gathered so far: {{ context.notes }}.
Decide the next move: route to another specialist, or synthesize the final answer.
Stage: updateContext(field=route, value=<choice>).
requires:
- { name: route, from: agent }
transitions:
- { toState: billing, guards: [ { backend: cel, expression: "context.route == 'billing'" } ] }
- { toState: technical, guards: [ { backend: cel, expression: "context.route == 'technical'" } ] }
- { toState: security, guards: [ { backend: cel, expression: "context.route == 'security'" } ] }
- { toState: synthesize, guards: [ { backend: cel, expression: "context.route == 'synthesize'" } ] }
synthesize:
description: >-
Compose the final answer from the record: {{ context.notes }}.
Stage: updateContext(field=answer, value=<answer>).
requires:
- { name: answer, from: agent }
transitions:
- { toState: done }
done:
description: "Answer delivered."
terminal: true
policy:
maxIterations: 12 # bounds the re-route loop if running headless
maxExchangesPerStage: 30
maxExchangesPerAgentPerStage: 15
checkpointEvery: 5
Supervisor / Router work flow:
Two things to hold onto:
- The supervisor never calls an agent. It writes
route, and the machine summons the specialist. Thebillingstate’s requirement isfrom: { agent: billing_agent }, so entering it parks the machine and the runtime raises the A2A summon. The supervisor’s authority is exactly one typed field. dispatchis the hub, and it’s reentrant. Every specialist edge returns todispatch, which parks waiting on a freshroute(the specialist’sonTransitionconsumed the old one withassign: { route: "" }). The supervisor can route to a second specialist, a third, or synthesize - the “may route multiple times” requirement from the pattern description is just the hub re-parking.- Reentrancy has a second half, and it’s the one that’s easy to miss.
findingis consumed on the same edge asroute. It has to be: a specialist’s requirement is satisfied by presence, so if the field still held its last report, routing back to the same specialist would satisfy the state instantly and bounce straight todispatchwithout anyone being asked. You’d see a hop in the trace and no work in it. The record survives because it isn’t the trigger -exitappends the finding tonotes, alistthat only ever grows, before the edge blanks the scalar. Accumulate what you want to keep; consume what re-arms the loop.
Walking a two-hop route
A customer reports a charge they believe is fraudulent. The supervisor triages and writes route = technical. The machine parks at technical and summons tech_agent, whose objective is templated on the request. The agent investigates and writes finding. On the way out, exit folds it into notes as "technical: ..."; the edge then blanks both route and finding, and the machine re-parks at dispatch.
Now the supervisor sees the running record in the CONTEXT block and writes route = security. The machine summons security_agent, which writes its own finding into the same field the technical agent used - and it can, because that field was emptied on the way out. Its report is appended too. Back at dispatch, the supervisor writes route = synthesize, and the synthesize state composes the answer from notes, which by now holds every hop in order. done.
Notice the supervisor’s context position across those hops. It never carries the specialists’ transcripts. It carries the store: the request gist, the running record, its own last decision. And notice that three specialists share one finding field rather than owning one each - the field belongs to the hop, not to the agent, which is why adding a fourth specialist adds a state and an edge but no new context. That’s not an accident of this FSM; it’s the structural consequence of “actors only ever contribute to context.”
What the reducer does to the failure points
Supervisor becomes a bottleneck and single point of failure. The bottleneck part is real and now visible: every routing decision is a park at triage or dispatch, and the parked record is journaled, so a crash mid-run resumes at the hub with the full store intact - no re-derivation of “what were we doing.” The single-point-of-failure part gets sharper, not softer: the supervisor’s only lever is a typed enum, so it can’t do the work itself, call an undeclared agent, or route out of order. Its failure mode is now “picks the wrong value,” which is a routing-quality problem you can log, evaluate, and fix by changing the triage objective - not a control-flow corruption.
Bad routing poisons the whole task. A bad route is still a bad route, but it’s bounded and legible. route is an enum: billing, technical, security, synthesize. A malformed or out-of-vocabulary write fails coercion at the context layer - it can’t silently misroute the way a hallucinated tool call can. And because the hub re-parks after every specialist, a bad first route is recoverable: the supervisor sees the specialist’s note and can re-route. The pattern’s “request revisions” behavior is just another hub decision.
Supervisor can consume excessive context. This is where the design pays off most. In a tool-calling supervisor, every specialist transcript lands in the supervisor’s prompt, and context grows with the number of hops. Here the supervisor’s per-turn injection is the CONTEXT block (set fields, capped at 300 chars per value) plus its own objective. The full specialist notes live in the store and are templated in full only into the state that needs them. Set guidance: summary on the specialists and they get cheaper steering too. The supervisor’s context cost is roughly constant in the number of specialists, not the sum of their outputs.
Specialists may become overly dependent on the supervisor’s interpretation. Each specialist’s objective is templated from the staged fields - the request gist, the prior notes - not from the supervisor’s free-form paraphrase. The handoff to a specialist is a data value the machine interpolates, so the specialist works from the artifact, and the supervisor can’t “interpret” a specialist’s output into something else before it reaches the store. What the specialist writes is what the next state reads.
One more property worth naming: the routing table is auditable before the run. Every edge out of triage and dispatch is declared, guarded, and readable in the YAML. You can enumerate the complete routing policy by reading one file. In the tool-calling design, the routing policy is the model’s behavior - you enumerate it by reading traces.
The whole thing, one idea
| Moment | Who | What happened in context |
|---|---|---|
| triage | supervisor | request + route set |
| technical | tech_agent | finding set → appended to notes → route + finding consumed |
| dispatch | (park) | nothing - waiting on a fresh route |
| security | security_agent | finding set → appended to notes → route + finding consumed |
| dispatch | supervisor | route = synthesize |
| synthesize | supervisor | answer set |
| done | - | terminal |
No call_agent tools. No routing logic in the supervisor’s prompt. The supervisor contributed values; the guards steered; the hub re-parked after every specialist so the supervisor could route again; and the whole policy - every legal route, every guard - is declared in the YAML before anything runs. Centralized control, but the control is structural and the choice is a typed write.
Up next: 4. Hierarchical Manager / Worker
Series:
- Intro
- 1. Sequential Pipeline
- 2. Parallel Fan-Out / Fan-In
- 3. Supervisor / Router
- 4. Hierarchical Manager / Worker
- 5. Peer Handoff / Swarm
- 6. Debate / Consensus
- 7. Blackboard / Shared-State
- 8. Planner / Executor
- 9. Generator / Critic / Refiner
- 10. Auction / Contract-Net
- 11. Event-Driven / Publish-Subscribe
- 12. Quorum / Voting
- 13. Dynamic Team Formation