The Supervisor / Router Pattern, Implemented via the Reactive Reducer


Back to: Intro

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:

route == billing

route == technical

route == security

route == synthesize

finding appended to notes, trigger consumed

finding appended to notes, trigger consumed

finding appended to notes, trigger consumed

route == billing

route == technical

route == security

route == synthesize

answer staged

triage

billing

technical

security

synthesize

dispatch

done

Two things to hold onto:

  • The supervisor never calls an agent. It writes route, and the machine summons the specialist. The billing state’s requirement is from: { 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.
  • dispatch is the hub, and it’s reentrant. Every specialist edge returns to dispatch, which parks waiting on a fresh route (the specialist’s onTransition consumed the old one with assign: { 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. finding is consumed on the same edge as route. 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 to dispatch without 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 - exit appends the finding to notes, a list that 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.

security_agenttech_agentsupervisorMachine (reducer)security_agenttech_agentsupervisorMachine (reducer)exit appends tonotes, route +finding consumedexit appends tonotes, consumedagainpark at triage (route + requestneeded)request + route=technicalA2A summon (objective templated on request)findingpark at dispatch (fresh route needed)route=securityA2A summonfinding (same field, re-armed)park at dispatchroute=synthesizeanswerdone (terminal)

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

MomentWhoWhat happened in context
triagesupervisorrequest + route set
technicaltech_agentfinding set → appended to notes → route + finding consumed
dispatch(park)nothing - waiting on a fresh route
securitysecurity_agentfinding set → appended to notes → route + finding consumed
dispatchsupervisorroute = synthesize
synthesizesupervisoranswer 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