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.
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
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")],
}); - nodes are boring functions. no base class, no decorator. state in, partial update out. this is what makes them unit-testable without a graph — call the function, assert on the patch.
- fixed edges are the default.
addEdge("writeDraft", "review")— after a draft, always review. most of a production graph should be fixed edges; determinism is a feature, not a limitation. - conditional edges are the behaviour. a routing function from state to node name. keep it a pure, synchronous look-at-the-state decision. the moment routing calls a model, you've built an agent inside your control flow — sometimes right, never accidental.
- list every target.
the third argument to
addConditionalEdgesdeclares the possible destinations. it feels redundant; it's what lets langgraph type-check the graph and draw it. do not skip it.
the reducer bug you will write
// 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.
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
- state as a junk drawer. every intermediate value crammed into state "in case a later node needs it." state is the api between agents; scratch data belongs inside the node that computed it. a fat state is the graph version of a god object.
- routing on prose.
a router doing
message.content.includes("approve")is a regex holding your control flow together. make the upstream node emit a typed verdict field — structured output exists for exactly this — and route on that. - everything in messages. if the only state field is the message list, every node must parse prior turns to find what it needs. promote load-bearing values — the draft, the verdict, the ticket id — to their own typed fields. messages are a log, not a database.
next: tools and the react agent — one competent agent, built the short way, before we start hiring more of them.