TAI BUI
Back to curriculum
BuildPython

LangGraph — State Machines for Agents

A ReAct loop written by hand is a `while True`. A ReAct loop written in LangGraph is a graph you can checkpoint, interrupt, branch, and time-travel through. The agent hasn't changed. The harness around it has.

TIME: ~75 minutesPREREQUISITES: Phase 11 · 09 (Function Calling), Phase 11 · 14 (Model Context Protocol)

Learning Objectives

  • Model an agent as a StateGraph with typed state, nodes, and conditional edges
  • Choose the right reducer so message lists append instead of overwrite
  • Add a checkpointer and an interrupt_before so a human approves tool calls
  • Fork a run from a prior checkpoint with time-travel to debug it

The Problem

You ship a function-calling agent. It works for three turns, then something goes wrong: the model tries a tool that returns 500, the user changes their mind mid-task, or the agent decides to refund an order without a human signing off. The while True: loop has no hooks. You can't pause it, you can't rewind it, and you can't branch off into "what if the model had picked the other tool." The moment you ship this past a demo, the agent becomes a black box that either worked or didn't.

The next step is obvious once you see it. The agent is already a state machine: system prompt, message history, pending tool calls, and the next action. Make the state machine explicit: nodes for "the model thinks," "a tool runs," "a human approves," and edges for the conditional transitions between them. Once the graph is explicit, the harness gets four things for free: checkpointing (save state between steps), interrupts (pause for a human), streaming (stream tokens and intermediate events), and time-travel (rewind to a prior state and try a different branch).

LangGraph is the library that ships this abstraction. It isn't an agent framework in the LangChain sense ("here is an AgentExecutor, good luck"). It's a graph runtime with first-class state, first-class persistence, and first-class interrupts. The agent loop is something you draw, not something you hand-write.

Warm-up0/3
1. In function calling, which part of the system actually runs the tool?Warm-up
Diagram with a box for the app and a box for the model. An arrow carries a question plus a tool description from the app to the model. Below sits a dashed box for the function get_weather(city), with a question mark and the words runs it.
Illustrative names: get_weather is a made-up example tool.
2. What problem does the Model Context Protocol solve for tool use?Warm-up
A client app box on the left with dashed arrows to three program boxes. Program A lists search_web, program B lists read_file, program C lists a question mark. A question mark sits on the arrows.
Typed schematic. Tool names come from the lesson's ToolNode example.
3. A web app keeps each user's session data in a database instead of in the server process's memory. What does that buy you?Warm-up
Two panels. Design 1 shows a server process that contains the session data in its memory. Design 2 shows a server process with an arrow to a separate database that holds the session data. A question mark asks about Design 2 compared with Design 1.
Typed schematic.

The Concept

LangGraph StateGraph: nodes, edges, and the checkpointer

A StateGraph has three things.

  1. State. A typed dict (TypedDict or Pydantic model) that flows through the graph. Every node receives the full state and returns a partial update, which LangGraph merges using a reducer per field. Use operator.add for lists that should accumulate; the default is overwrite.
  2. Nodes. Python functions state -> partial_state. Each is a discrete step: "call the model," "run tools," "summarize."
  3. Edges. Transitions between nodes. Static edges go one place. Conditional edges take a router function state -> next_node_name so the graph can branch on model output.

You compile the graph. Compile binds the topology, attaches a checkpointer (optional but essential for production), and returns a runnable. You invoke it with an initial state and a thread_id. Every step of execution persists a checkpoint keyed on (thread_id, checkpoint_id).

The four superpowers

Checkpointing. Every node transition writes the new state to a store (in-memory for tests, Postgres/Redis/SQLite for prod). Resume by calling the graph again with the same thread_id. The graph picks up where it paused.

Interrupts. Mark a node with interrupt_before=["human_review"] and execution stops before that node runs. The state persists. Your API responds to the user with "awaiting approval." A later request to the same thread_id with Command(resume=...) resumes execution.

Streaming. graph.stream(state, mode="updates") yields state deltas as they happen. mode="messages" streams the LLM tokens inside model nodes. mode="values" yields full snapshots. You pick what to surface in your UI.

Time-travel. graph.get_state_history(thread_id) returns the full checkpoint log. Pass any prior checkpoint_id to graph.invoke and you fork from that point. Useful for debugging ("what if the model had picked tool B instead?") and for regression tests that replay production traces.

Reducers are the point

Every state field has a reducer. Most defaults are fine, since a new value just overwrites the old. But message lists need an appending reducer, add_messages, so new messages append instead of replacing. Parallel edges merge their updates through the reducer. If two nodes both update messages and you forgot the Annotated[list, add_messages], the second wins silently and you lose half the turn. The reducer is the subtlest part of the library. Get it right and the rest composes.

The ReAct graph

A production ReAct agent is two nodes and two edges:

  1. agent calls the LLM with the current message history. Returns the assistant message (which may contain tool_calls).
  2. tools executes any tool_calls in the last assistant message, appends the tool results as tool messages.
  3. A conditional edge from agent that routes to tools if the last message has tool_calls, else to END.
  4. A static edge from tools back to agent.

That's all of it. You get the full ReAct loop (Thought → Action → Observation → Thought → …) with checkpointing, interrupts, and streaming, in roughly 40 lines of code.

StateGraph vs Send (fanout)

Send(node_name, state) lets a node dispatch parallel subgraphs. Example: the agent decides to query three retrievers at once. Each Send spawns a parallel execution of the target node; their outputs merge through the state reducer. This is how LangGraph expresses the orchestrator-workers pattern without threading primitives.

Subgraphs

A compiled graph can be a node in another graph. The outer graph sees a single node; the inner graph has its own state and its own checkpoints. This is how teams build supervisor-worker agents: the supervisor graph routes user intent to a per-domain worker subgraph.

Check: The Concept0/17
1. What is a StateGraph in LangGraph?Define
Four lines of Python from the lesson: a State class with a messages field, then the line graph = StateGraph(State), highlighted, with a question mark asking what the object is.
Lines copied from Step 1 of the lesson's code.
2. What is a reducer in a LangGraph state?Define
Two lines of Python: a State class with a messages field annotated with add_messages. The messages line is highlighted, with a note that it declares the messages field.
Lines copied from Step 1 of the lesson's code.
3. What is a thread in LangGraph?Define
Six lines of Python from the lesson with thread_id underlined in the config dict, passed to app.stream. A question mark asks what a thread is.
Step 2 code, abridged (input elided).
4. What is a checkpoint?Define
Table of five entries returned by get_state_history, newest first, with columns step, next and a shortened checkpoint_id. Steps run from 3 down to -1.
Captured from a real LangGraph run with a stand-in model.
5. What is an interrupt in LangGraph?Define
Four lines of Python: graph.compile with a checkpointer and the highlighted line interrupt_before=["tools"], with a question mark asking what the highlighted setting is.
Step 3 code, trailing comment omitted.
6. What is time-travel in LangGraph?Define
Five checkpoint boxes in a row, labelled step -1 through step 3. The box for step 0 is highlighted with a note pointing at its config. Below is the call app.invoke(None, config of the highlighted checkpoint), and a question mark asking what to call it.
Typed schematic; step labels from a real run.
7. What does Send(node_name, state) let a node do?Define
An agent node with an arrow labelled Send(node_name, state) to a node named retriever. A question mark and the text What does the agent node get from this.
Typed schematic; retriever follows the lesson's example.
8. What is a subgraph in LangGraph?Define
A supervisor box on the left with arrows to three boxes named researcher, writer and reviewer, each tagged worker. A question mark sits on the right.
Typed schematic.
9. Which `graph.stream()` mode streams the LLM's tokens from inside model nodes?Define
A model node lane showing six word chips between start and node finishes. Below it a client UI lane drawn as a dashed empty box with a question mark.
Typed schematic; the words are illustrative.
10. Why does the `messages` field in a LangGraph State need `Annotated[list, add_messages]`?Explain
Six lines of Python from the lesson: a State class whose messages field is highlighted, and an agent_node function that returns a dict with a messages list holding the model response.
Lines copied from Step 1 of the lesson's code.
11. In the ReAct graph, where does the conditional edge live?Explain
Four boxes, START, agent, tools and END, with no edges drawn. Below, a description of what the agent node and the tools node do.
Typed schematic; node descriptions follow the lesson's ReAct section.
12. Why does each node return a partial update instead of the whole state?Explain
A state box with messages (1 message), plan (search, answer) and budget 3 goes into an agent node. The node returns a dict with only budget 2. An arrow leads to a dashed box reading state after: question mark.
Typed schematic; the values match the real run shown in the explanation.
13. Why do you pass a `thread_id` when you invoke a compiled graph that has a checkpointer?Explain
Four lines of Python: compiling with MemorySaver as the checkpointer, a config dict whose thread_id is underlined, and app.invoke(input, config). A question mark asks why the thread_id is passed.
Typed from Steps 1 and 2 of the lesson's code.
14. State has messages = [HumanMessage, AIMessage with a tool call], and the tools node returns {"messages": [tool_result]}. If `messages` has no reducer, what is the list afterwards?Apply
State field messages is declared as list[AnyMessage] with no reducer. It holds HumanMessage and AIMessage(tool_call). The tools node returns a dict with messages holding tool_result. Below: messages afterwards equals a question mark.
Typed schematic of the stem.
15. Three retrievers run in parallel through Send, and each returns {"docs": [...]}. The final state holds docs from only one retriever. What is missing?Analyze
Three retriever boxes, each returning a dict with a docs list of two items, with arrows into a single docs field shown as a question mark. A note says the final state holds docs from only one retriever.
Typed schematic; doc ids are illustrative.
16. You are building a supervisor that routes user requests to researcher, writer, and reviewer agents, each with its own private state. A human must approve the research brief before writing. Which design fits?Design
A supervisor box with arrows to three worker boxes named researcher, writer and reviewer, each tagged private state. A dashed box says human approves research brief. A note says the approval must happen before the writer starts.
Typed schematic of the stem.
17. You want a regression test that replays a bad production run and also checks what happens if the model had picked a different tool at step three. Which approach uses the lesson's ideas?Design
Four step boxes in a row from a production run, with step 3 highlighted as the tool choice. Below, two goals for the regression test: replay the run and also try a different tool choice at step three. A question mark asks how.
Typed schematic; the number of steps is illustrative.

Build It

Step 1: state and nodes

python
from typing import Annotated, TypedDict
from langchain_core.messages import AnyMessage, HumanMessage, AIMessage
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from langgraph.checkpoint.memory import MemorySaver
 
class State(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]
 
def agent_node(state: State) -> dict:
    response = llm.invoke(state["messages"])
    return {"messages": [response]}
 
def should_continue(state: State) -> str:
    last = state["messages"][-1]
    return "tools" if getattr(last, "tool_calls", None) else END
 
tool_node = ToolNode(tools=[search_web, read_file])
 
graph = StateGraph(State)
graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)
graph.set_entry_point("agent")
graph.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
graph.add_edge("tools", "agent")
 
app = graph.compile(checkpointer=MemorySaver())

add_messages is the reducer that makes the message list accumulate instead of overwrite. Forgetting it is the most common LangGraph bug.

Step 2: run with a thread

python
config = {"configurable": {"thread_id": "user-42"}}
for event in app.stream(
    {"messages": [HumanMessage("find the Anthropic headquarters address")]},
    config,
    stream_mode="updates",
):
    print(event)

Every update is a dict {node_name: state_delta}. Your frontend can stream these to the UI so users see "agent is thinking… calling search_web… got result… answering."

Step 3: add a human-in-the-loop interrupt

Mark a node so execution pauses before it runs.

python
app = graph.compile(
    checkpointer=MemorySaver(),
    interrupt_before=["tools"],  # pause before every tool call
)
 
state = app.invoke({"messages": [HumanMessage("delete the production database")]}, config)
# The graph is paused: app.get_state(config).next == ("tools",). Inspect proposed tool calls.
# If approved:
from langgraph.types import Command
app.invoke(Command(resume=True), config)
# If denied: write a rejection message and resume
app.update_state(config, {"messages": [AIMessage("Blocked by human reviewer.")]})

The state, the checkpoint, and the thread all persist across the interrupt. With a persistent checkpointer, the process can restart in between.

Step 4: time-travel for debugging

python
history = list(app.get_state_history(config))
for snapshot in history:
    print(snapshot.values["messages"][-1].content[:80], snapshot.config)
 
# Fork from a prior checkpoint
target = history[3].config  # three steps back
for event in app.stream(None, target, stream_mode="values"):
    pass  # replay from that point forward

Passing None as the input replays from the given checkpoint; passing a value appends it as an update to that checkpoint's state before resuming. This is how you reproduce a bad agent run without re-running the whole conversation.

Step 5: swap the checkpointer for production

python
from langgraph.checkpoint.postgres import PostgresSaver
 
with PostgresSaver.from_conn_string("postgresql://...") as checkpointer:
    checkpointer.setup()
    app = graph.compile(checkpointer=checkpointer)

SQLite, Redis, and Postgres are shipped. MemorySaver is for tests. Anything that persists across restarts wants a real store.

Check: Build It0/8
1. What is the difference between `interrupt_before=['tools']` and `interrupt_after=['tools']`?Explain
A row of three boxes, agent, tools and agent for the next loop, joined by arrows. A question mark sits on the arrow before tools and another on the arrow after tools. Below, two settings are listed: interrupt_before=['tools'] and interrupt_after=['tools'].
Typed schematic.
2. How do you time-travel to a prior state and explore a different branch?Explain
Table of five saved checkpoints with columns index, step, next and a short id. The row history[3] is highlighted. Below it the goal: explore a different branch from the state at history[3].
Captured from a real LangGraph run with a stand-in model.
3. In Step 1, the last message in state is an AIMessage whose `tool_calls` is empty. What does `should_continue` return, and what happens next?Apply
A message box showing an AIMessage with an empty tool_calls list, an arrow into the agent node, and two arrows out to boxes named tools and END. A question mark asks what should_continue returns.
Typed schematic of the stem's situation.
4. In Step 3, after the first `app.invoke(...)` returns with `interrupt_before=["tools"]`, what does `app.get_state(config).next` hold?Apply
Seven lines of Python from the lesson: compile with a checkpointer and interrupt_before for tools, an invoke with the prompt delete the production database, then app.get_state(config).next highlighted, equal to a question mark.
Step 3 code with the answer replaced by a question mark.
5. In Step 4, you keep `target = history[3].config` but call `app.stream({"messages": [HumanMessage("what about Paris?")]}, target, stream_mode="values")` instead of passing None. What happens?Apply
A table of five saved checkpoints with history[3] highlighted, then the lesson's code that sets target to history[3].config and calls app.stream with a new HumanMessage asking what about Paris. A note gives the state at history[3] and a question mark asks what happens.
History captured from a real run with a stand-in model; code from Step 4 with the input changed.
6. An agent compiles and runs, but after every tool step the model seems to forget the user's original question. State declares `messages: list[AnyMessage]` with no annotation. What is the most likely cause?Analyze
State is declared as messages: list[AnyMessage] with no annotation. An agent box and a tools box are joined by arrows in both directions. Below, an illustrative transcript: the user asks for the headquarters address, the agent calls a tool, the tool returns, and the agent then asks what the user wants.
Typed illustration of the stem's symptom, not a real model transcript.
7. A graph pauses at an interrupt, the process restarts overnight, and the next morning the resume call finds no state to continue. What is the most likely cause?Analyze
A timeline of three boxes: evening, run pauses at interrupt; overnight, process restarts; next morning, resume call, which finds no state. A dashed box reads where the state was kept, question mark.
Typed schematic of the stem.
8. Your support agent runs on two servers behind a load balancer, deploys nightly, and pauses for human approval that can take hours. Which checkpointer fits?Design
A load balancer sends traffic to Server 1 and Server 2. Both have dashed arrows to a dashed box reading checkpointer store: question mark. Notes: servers are redeployed nightly, and a run pauses for human approval which can take hours.
Typed schematic of the stem.

The Skill

You build agents as graphs, not as while True loops.

Before you reach for LangGraph, do a 60-second design:

  1. Name the nodes. Every discrete decision or side-effecting action is a node. "Agent thinks," "tool runs," "reviewer approves," "response streams." If you can't list them, the task is not agent-shaped yet.
  2. Declare the state. Minimal TypedDict with a reducer for every list field. Do not stuff everything into messages; hoist task-specific fields (a working plan, a budget counter, a retrieved_docs list) to the top level.
  3. Draw the edges. Static unless the next step depends on model output. Every conditional edge needs a router function with named branches.
  4. Choose a checkpointer up front. MemorySaver for tests, Postgres/Redis/SQLite for anything else. Don't ship without one, because no checkpointer means no resume, no interrupt, and no time-travel.
  5. Decide interrupts before tools run, not after. Approvals go on the edge into a side-effecting node so you can cancel before harm; validation goes on the edge out of the model so you can reject bad calls cheaply.
  6. Stream by default. mode="updates" for the UI, mode="messages" for token-level streaming inside model nodes, mode="values" for full snapshots during eval.

Refuse to ship a LangGraph agent that has no checkpointer. Refuse to ship one that interrupts after the side effect. Refuse to ship a messages field without add_messages as its reducer.

Check: The Skill0/3
1. Why should an approval interrupt go on the edge into a side-effecting node rather than after it?Explain
A path of three boxes in time order: agent proposes the tool call, tools with a side effect, agent reads the result. Below a dashed box labelled human approval pause with a question mark asking where on the path it goes.
Typed schematic. The prompt is the one used in Step 3 of the lesson.
2. A reviewer approves a refund, but the agent's log shows the refund API was already called before the approval request appeared. Which configuration mistake explains this?Analyze
Three log entries in time order: agent proposes the refund tool call, tools: refund API called, reviewer: approval request appears. The refund API entry is outlined in red.
Typed from the stem.
3. Two designs for a `plan` and a `budget` counter: keep them inside the `messages` list as special messages, or add them as top-level state fields. Which design does the lesson recommend and why?Analyze
Two panels. The first keeps a message list with Human, AI, Tool and a plan special message. The second has separate fields messages, plan and budget.
Typed schematic. Plan and budget values are illustrative.
Author's Note

I'd run main.py before reading anything else. It builds the ReAct graph (agent and tools), pauses at interrupt_before=["tools"], prints the pending tool call, resumes with Command(resume=True), then prints the checkpoint history with each snapshot's next field. Seeing the pause and the resume as separate streams does more than the prose will. It needs ANTHROPIC_API_KEY, and the two tools are toys: calculator filters its input to digits and arithmetic characters before calling eval, and web_lookup is a dict with two canned answers.

My bet is that the reducer is the part worth slowing down on. messages: Annotated[list[AnyMessage], add_messages] is one line in State, and it is what makes the agent's reply and the tool results append instead of overwrite. Delete the annotation and the loop still compiles. You just get a different, wrong conversation.

The time-travel section in main.py differs from Step 4 above. It forks from the earliest snapshot in the history by passing a new HumanMessage as input, not None, and then resumes past the interrupt for the math tool. I'd use the None replay when reproducing a bad run and the new-input fork when asking a different question from the same starting point. The toy also uses MemorySaver, so nothing survives the process. For anything real I'd swap in the Postgres saver from Step 5.

Exercises

  1. Easy. Implement the ReAct graph above with a calculator tool and a web-search tool. Verify that list(app.get_state_history(config)) returns at least four checkpoints for a two-turn conversation.
  2. Medium. Add a planner node that runs before agent and writes a structured plan: list[str] into state. Have agent mark plan steps as done. Fail the test if plan is lost across a checkpoint resume (wrong reducer).
  3. Hard. Build a supervisor graph that routes between three subgraphs (researcher, writer, reviewer) using Send. Each subgraph has its own state and checkpointer. Add an interrupt_before=["writer"] on the outer graph so a human can approve the research brief. Confirm that time-travel from a prior checkpoint re-runs only the forked branch.

Key Terms

TermWhat people sayWhat it actually means
StateGraph"The LangGraph graph"The builder object you add nodes and edges to before compile.
Reducer"How the field merges"A function (old, new) -> merged applied when a node returns an update for that field; default is overwrite, add_messages appends.
Thread"A conversation ID"A thread_id string that scopes all checkpoints for one session.
Checkpoint"A paused state"A persisted snapshot of the full graph state after a node transition, keyed on (thread_id, checkpoint_id).
Interrupt"Pause for a human"interrupt_before / interrupt_after stop execution at a node boundary; resume with Command(resume=...).
Time-travel"Fork from a prior step"graph.invoke(None, config_with_old_checkpoint_id) replays from that checkpoint forward.
Send"Parallel subgraph dispatch"A constructor a node can return to spawn N parallel executions of a target node.
Subgraph"A compiled graph as a node"A compiled StateGraph used as a node in another graph; preserves its own state scope.

Further Reading

Code

Ships With