scalable/ai/langgraph agent teams/lesson 02 cph / /
lesson 02 / 08 · 13 min · updated ·

state, nodes, edges

the whole api surface: an annotated state, functions as nodes, edges as control flow. reducers are where the sharp edges hide.

tl;dr. the whole api is three moves: declare a typed state with Annotation.Root, add plain async functions as nodes, wire them with fixed and conditional edges. nodes return partial updates; a per-field reducer decides how each update merges into the state. almost every confusing early bug is a reducer you didn't write.

state is a contract, not a variable

everything in langgraph hangs off one decision: what's in the state. it's the interface every node programs against, so it deserves the same care as a public api — the types are the documentation here, literally.

three fields, three merge strategies
import { Annotation } from "@langchain/langgraph";
import { BaseMessage } from "@langchain/core/messages";
import { messagesStateReducer } from "@langchain/langgraph";

// the state is the contract every node signs.
const State = Annotation.Root({
  // append-only conversation — the reducer concatenates updates
  messages: Annotation<BaseMessage[]>({
    reducer: messagesStateReducer,
    default: () => [],
  }),
  // last-write-wins — no reducer means "replace"
  draft: Annotation<string>,
  // accumulating review notes from multiple gates
  notes: Annotation<string[]>({
    reducer: (curr, next) => curr.concat(next),
    default: () => [],
  }),
});

the non-obvious part is the reducer. a node doesn't mutate state and doesn't return the whole state — it returns a patch, and the reducer for each field decides what the patch means. no reducer means replace. messagesStateReducer means append (with de-dup by message id). your own (curr, next) => ... means whatever the field needs. this is the same discipline as handoff payload design — the shape of shared data is the real contract between agents.

(since 1.x you can also define state with zod schemas — or any standard-schema library — instead of Annotation. same semantics, including the reducer rules below; this course sticks with Annotation because it's what you'll meet in most existing codebases and it forces the reducer decision zod lets you forget.)

nodes and edges

a write → review loop with an exit condition
import { StateGraph, START, END } from "@langchain/langgraph";

// nodes are plain async functions: state in, PARTIAL state out.
async function writeDraft(state: typeof State.State) {
  const draft = await callModel(state.messages);
  return { draft };                       // only what changed
}

async function review(state: typeof State.State) {
  const verdict = await critique(state.draft);
  return { notes: [verdict.summary] };    // reducer appends this
}

// routing is a function from state to a node name.
function routeAfterReview(state: typeof State.State) {
  return state.notes.at(-1)?.startsWith("ok") ? END : "writeDraft";
}

const graph = new StateGraph(State)
  .addNode("writeDraft", writeDraft)
  .addNode("review", review)
  .addEdge(START, "writeDraft")
  .addEdge("writeDraft", "review")
  .addConditionalEdges("review", routeAfterReview, ["writeDraft", END])
  .compile();

const result = await graph.invoke({
  messages: [new HumanMessage("write the release note for v2.3")],
});

the reducer bug you will write

lists without reducers lose data
// the classic first-week bug: no reducer on a list.
const State = Annotation.Root({
  findings: Annotation<string[]>,   // last-write-wins!
});

// two review nodes run in parallel...
// node A returns { findings: ["missing null check"] }
// node B returns { findings: ["sql injection in filter"] }
//
// without a reducer, one silently overwrites the other —
// which one depends on completion order. with parallel nodes
// langgraph throws InvalidUpdateError instead. the fix is the
// same either way:
const Fixed = Annotation.Root({
  findings: Annotation<string[]>({
    reducer: (a, b) => a.concat(b),
    default: () => [],
  }),
});

i'd summarize the rule as: scalar fields replace, collection fields reduce. if a field can be written by more than one node — and in a team graph, most can — decide the merge semantics the day you add the field, not the day the overwrite bites. sequential graphs hide this bug; it surfaces the week you make two reviewers run in parallel, which is exactly the week you have the least patience for it.

loops need a leash. the write → review cycle above can ping-pong forever if the reviewer is never satisfied. langgraph has a recursion limit (default 25 steps) that turns infinite loops into an explicit GraphRecursionError — but treat that as an airbag, not a strategy. put the real stopping rule in state: a revision counter the router checks, the same budget-and-no-progress discipline a bare react loop needs.

when it breaks

next: tools and the react agent — one competent agent, built the short way, before we start hiring more of them.

scalable labs·cvr 30091604·github·linkedin·hello@scalable.dk