← Guide

Lesson 4 · State passing: shared state without stomping

A multi-agent graph has one piece of shared state that every node reads and writes. When specialists run in parallel, naive state handling lets them overwrite each other's work. This lesson covers the two mechanisms that prevent that: reducers on the shared state, and separate state schemas for the subgraphs.

The shared state

LangGraph state is declared as an annotation, a set of named channels, each with a type and an optional reducer. The coach's top-level state:

// src/state.ts
export const CoachAnnotation = Annotation.Root({
  sessionId: Annotation<string>(),
  userQuery: Annotation<string>(),
  routing: Annotation<RoutingDecision | undefined>(),
  findings: Annotation<FindingsMap>({
    reducer: (prev, next) => ({ ...prev, ...next }),
    default: () => ({}),
  }),
  finalAnswer: Annotation<FinalAnswer | undefined>(),
});

Each node returns a partial update. LangGraph applies it to the channel. For a channel with no reducer, the default behaviour is last-write-wins: the update replaces the channel's value. That is fine for routing (one node writes it), and a bug waiting to happen for findings.

Why findings needs a reducer

The supervisor fans out: when a question is cross-domain, the Nutrition and Workout specialists run in the same superstep, in parallel. Each finishes and returns an update:

// nutritionNode returns:
return { findings: { nutrition: nutritionFinding } };
// workoutNode returns:
return { findings: { workout: workoutFinding } };

With last-write-wins, LangGraph applies one update, then the other, and the second { findings: { workout } } replaces the channel, discarding the nutrition finding. The cross-domain answer silently loses half its input.

The fix is the reducer on the findings channel:

reducer: (prev, next) => ({ ...prev, ...next }),

Now each update is merged into the channel instead of replacing it. The two parallel updates compose to { nutrition, workout } regardless of order, because each specialist writes only its own key. The rule that makes this safe: a specialist writes only its own slot, nutritionNode never returns a workout key.

Picking a reducer is the core state-design decision. Ask, per channel: is it written once (last-write-wins is fine) or by several nodes (it needs a merge or append reducer)? Get that right and parallelism is free; get it wrong and you get nondeterministic data loss that is miserable to debug.

Subgraph isolation: specialists can't read each other

The headline rule of this architecture is "specialists never read each other's findings." You could enforce that with discipline. This repo enforces it with types: each specialist subgraph has its own state schema, and that schema has no findings channel at all.

// src/agents/nutrition/subgraph.ts
const NutritionAnnotation = Annotation.Root({
  subQuestion: Annotation<string>(),
  citations: Annotation<Citation[]>({ reducer: (p, n) => [...p, ...n], default: () => [] }),
  toolCalls: Annotation<ToolCallRecord[]>({ reducer: (p, n) => [...p, ...n], default: () => [] }),
  needsCalorieTool: Annotation<boolean>(),
  calorieArgs: Annotation<CalorieInput | null>(),
  draftText: Annotation<string>(),
});

There is no findings here. The nutrition subgraph's nodes cannot read another specialist's output because that data is not in their state, the isolation is structural, not a convention someone has to remember. (Note citations and toolCalls use append reducers, the retrieve and tool nodes each contribute, and their contributions accumulate.)

The adapter node: bridging two state shapes

A subgraph has its own state; the top-level graph has CoachState. Something must translate. That is the adapter node, a thin function that runs the subgraph and maps its result into a top-level update:

// src/agents/nutrition/subgraph.ts
export async function nutritionNode(state: CoachState): Promise<CoachUpdate> {
  const subQuestion = state.routing?.subQuestions.nutrition ?? state.userQuery;
  const startedAt = Date.now();

  const result = await nutritionSubgraph.invoke({ subQuestion });

  const finding: SpecialistFinding = {
    agent: "nutrition",
    text: result.draftText ?? "",
    citations: result.citations,
    toolCalls: result.toolCalls,
    durationMs: Date.now() - startedAt,
  };
  return { findings: { nutrition: finding } };
}

The adapter reads from CoachState (only what it needs, its sub-question), runs the subgraph in the subgraph's own state shape, and writes back exactly one key of findings. The two state worlds touch only here, in a function small enough to read at a glance.

Fan-in: the synthesizer

After the specialists, a synthesize node runs. It is the one node allowed to read across specialists, state.findings.nutrition and state.findings.workout together, because synthesis is its entire job. The merge reducer guarantees both findings are present by the time it runs. Fan-out, isolated work, fan-in: the shape of every supervisor system.

Build this yourself

Continue the support desk (Billing, Technical, Account).

Exercise. Design the desk's SupportAnnotation: a findings channel with a merge reducer, plus routing and finalAnswer. Then design one specialist subgraph's state schema and confirm it has no findings channel. Write the adapter node for that specialist. Finally, trace by hand: if Billing and Technical run in parallel, what is in findings after each, with the reducer, and without it?


Next: Lesson 5 · Evals, building a LangSmith eval dataset that catches the failures you actually care about.