State is the contract
The state schema and its reducers, not the endpoint, are the real API between an agent and its UI — a channel with an append reducer is a transcript, a channel with last-write-wins is a field, and confusing the two produces the wrong component.
You have shipped enough products to know where the real contract between two teams lives. It is never the endpoint list. It is the response shape: what fields exist, which are nullable, which accumulate across calls and which get replaced. Get that wrong and no amount of good component work saves the screen.
For an agent graph, that contract is the state schema, and it is unusually literal about it. Every node reads state and returns a partial update to it. Everything the run has accumulated lives there. Every checkpoint is a copy of it. The stream is a stream of changes to it. There is no other channel. If you can read the schema, you can predict what your UI will have to render before anyone has designed a screen — and, more usefully, you can tell when the schema makes a screen somebody has already designed impossible.
Reducers, and the default nobody mentions
Each key in state — a channel — has its own independent reducer, which decides how a node’s update combines with what is already there. The Graph API documentation states the default plainly:
“Each key in the State has its own independent reducer function. If no reducer function is explicitly specified then it is assumed that all updates to that key should override it.” (LangChain, Graph API)And where one is specified, “a custom reducer combines the left and right arguments instead of replacing the state value, which is useful for accumulating values, such as appending updates to a list.”
That single distinction is the most load-bearing thing in the schema for your purposes:
- Overwrite channel → a field. Only the latest value ever exists. Render it as a value that changes. There is no history to show because the runtime did not keep one.
- Accumulating channel → a transcript. The channel is a growing list, which means the UI has scroll position, ordering, virtualization at length, and a “what is new since I last looked” problem. Completely different component, completely different amount of work.
You cannot tell these apart from a field name. notes could be either. You can only tell from the reducer, which is why the schema is the first file to open in an unfamiliar agent repo.
What the current schema API looks like
This is where a course written from memory goes wrong, so: as of LangGraph v1.1.0 the documented way to declare state in TypeScript is the StateSchema class. The docs’ own comparison table marks StateSchema as recommended and labels Annotation.Root, Zod v3 with .langgraph, and the Zod v4 registry approach all as Legacy, with the raw Channels API kept for advanced cases. The legacy forms still work — they were not removed — but nearly every tutorial and blog post you will find uses one of them, and mixing two styles in one file is a reliable way to produce a snippet that does not compile.
The documented shape, which is worth reading as a taxonomy of channel kinds rather than as syntax to memorize:
import {
StateSchema,
ReducedValue,
MessagesValue,
UntrackedValue
} from "@langchain/langgraph";
import { z } from "zod/v4";
const AgentState = new StateSchema({
messages: MessagesValue,
currentStep: z.string(),
retryCount: z.number().default(0),
allSteps: new ReducedValue(
z.array(z.string()).default(() => []),
{
inputSchema: z.string(),
reducer: (current, newStep) => [...current, newStep],
}
),
tempCache: new UntrackedValue(z.record(z.string(), z.unknown())),
});
type State = typeof AgentState.State;
type Update = typeof AgentState.Update;Four kinds of channel, four different things for a UI:
currentStep,retryCount— a plain schema field. Last write wins. A value on screen.messagesviaMessagesValue— the prebuilt accumulating channel for chat messages. A transcript.allStepsviaReducedValue— a custom accumulator. Note it carries a separateinputSchema: nodes append a singlestring, the channel holdsstring[]. Append-only history, and a real audit trail if you need one.tempCacheviaUntrackedValue— live during execution and never checkpointed. This is a design lever, not an optimization detail, and it comes back below.
The update type is not the state type
Look again at the last two lines of that sample. State and Update are two different exported types, and the difference is exactly the reducer. For allSteps, State says string[] and Update says string. A node returns an Update. The checkpoint stores a State.
This is the sharpest practical trap in the whole module, because the stream will hand you both and they look similar enough to conflate. If your client subscribes to per-step updates and naively assigns them into local state, an accumulating channel silently collapses to its last element and your transcript renders one message. It is not a crash. It is a screen that is quietly wrong, which is the worst kind.
Not all state is yours to show
Two facts, both documented, that together make this a design decision rather than a serialization detail.
First: a checkpointer writes the graph state at every superstep, so for a graph with a messages channel the entire message history — content included — is persisted to the backend on each step, keyed by thread_id. Second: UntrackedValue exists precisely so that a channel can be live during execution and never written to a checkpoint.
Put those together against your own product. HouseWarm processes documents that belong to somebody’s house purchase. If the raw OCR text of a mortgage offer lives in a checkpointed channel, then a durable, addressable copy of that document’s contents now exists in the checkpoint store for as long as the thread does, and a thread_id is a handle to all of it. That may well be correct — you probably want the run to be resumable and auditable. But it is a decision, and the schema is where it was made, whether or not anyone noticed making it.
So the state table you build for an unfamiliar graph needs a fourth column beyond name, type and reducer: does this reach the browser. Chain-of-thought scratchpads, retrieved document chunks, raw tool payloads and internal confidence scores are all routinely in state and routinely not things to render. Deciding that per-channel is a UI architect’s job. Nobody else on the team is looking at the schema through that lens.
Retrieval check
You inherit a graph whose state has `extractedFields` (no reducer), `reviewLog` (append reducer), and `pageImages` (UntrackedValue). Without seeing a single screen, what have you learned?
Check your answer
extractedFields overwrites, so only the latest extraction exists — there is no built-in before/after, and if the product wants to show what changed after a correction, that history has to come from somewhere else. That is a schema change, not a UI change, and it is much cheaper to say so now than in design review.
reviewLog accumulates, so it is a transcript: ordering, scroll, growth over time, and something worth showing a reviewer who arrives late. It is also the audit trail, which usually means somebody outside the product team cares about it.
pageImages is untracked, so it exists during the run and is not in any checkpoint. That is the one with the hard consequence: a resumed run does not have it. Any screen that renders a page crop after a resume must fetch it from wherever the images actually live, because the checkpoint will not bring it back. Design a review UI that assumes the crop is in state and it will work perfectly until the first person closes their laptop.
Hands on
Write the state contract for the parser
Done when: ARTIFACT.md’s state table has a row per channel with reducer behaviour, a transcript/field/internal call, and an explicit reaches-the-browser decision — and at least one row where the honest answer changed a screen you had already imagined.
- List every channel the parser graph needs. Keep it to what nodes actually read and write — a channel nothing reads is a field on a response object, not graph state.
- For each, decide overwrite or accumulate, and write which. Force the question on the ones you would rather leave vague: does
extractedFieldsreplace wholesale on every extraction pass, or accumulate per field? Both are defensible. Only one of them lets you show a broker what changed. - Classify each row as transcript, field, or internal-only, and name the component you would reach for. If two rows with different reducers got the same component, one of them is wrong.
- Fill the reaches the browser column for every row, and mark any channel holding raw document content explicitly. For at least one, write the sentence justifying why it is checkpointed or why it is untracked. This is the row to bring to a security conversation.
- Write down the
Updateshape for the one channel where it differs most from theStateshape. That is the channel your client-side stream handling will get wrong first. - Bring the table into the chat. I will look for a channel classified as a field that is really a transcript, and for a raw-document channel nobody made a decision about.
What this does not cover
This lesson reads the schema statically — the contract as declared. It has not shown you a single byte actually arriving. The runtime offers several different views of a run in flight, and they are not interchangeable: one gives you full state per step, one gives you per-node updates, one gives you model tokens, one gives you whatever a node chose to emit. Picking among them is the stream-modes lesson, and it is where the update-versus-state trap above stops being theoretical.
It has also deferred the durability question. That checkpointed state can be read back long after the run, walked backwards, edited and forked is the whole of the state-and-time-travel module; here the schema mattered only for what a channel means. And UntrackedValue gets its real treatment in the showable-state lesson, where “what survives a resume” is the question rather than a footnote.
Read this next — primary source
Use the Graph APILangChain — docs.langchain.com, JavaScript docs, fetched 2026-09-03. Vendor documenting its own product
This lesson uses the state-definition half and the reducer semantics. The page goes considerably further — per-node input schemas that hand a node a subset of state, private channels passed between two nodes without entering the public schema, and the full "Alternative state definitions" comparison that tells you what every older tutorial you will find on the internet is using instead. That last table is the one to actually read, because most of the LangGraph code you inherit will be written in one of the legacy styles.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.