DEV Community

Cover image for Multi-Agent Teams in TypeScript: Supervisor, Workers, Who Owns State
Gabriel Anhaia
Gabriel Anhaia

Posted on

Multi-Agent Teams in TypeScript: Supervisor, Workers, Who Owns State


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";
Enter fullscreen mode Exit fullscreen mode

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}`)],
  };
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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.

Supervisor routing to workers, each returning control rather than choosing a successor.

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"])
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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>),
}),
Enter fullscreen mode Exit fullscreen mode

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.

Two parallel workers writing one replace-channel, with one update silently discarded.

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";
}
Enter fullscreen mode Exit fullscreen mode

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");
});
Enter fullscreen mode Exit fullscreen mode

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: () => ({}),
}),
Enter fullscreen mode Exit fullscreen mode

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.

AI That Plans — Stateful AI Agents with LangGraph.js

The full series is at
xgabriel.com/ai-in-typescript.

Top comments (0)