The hand-rolled loops you built in Agent Architectures: Loops,
Planning & Memory taught you what an agent is.
They also, by the end of that course, started sprouting the warts
of anything grown by accretion: a step counter here, a per-tool
guard there, an if-branch for the human-approval case bolted onto
the side, state threaded through a list you kept hoping stayed in
the right order. Every one of those is a real production
requirement, and stacking them into a for loop with a
growing pile of conditionals is how agent code becomes
unmaintainable. At some point the control flow is the
program, and it deserves to be written down as one.
LangGraph is that written-down control flow — the standard-bearer of Lesson 1's explicit-graph philosophy. It models an agent as a graph: nodes are steps (call the model, run a tool, ask a human), edges are transitions, and a typed state object flows through — no more threading a list by hand. This notebook rebuilds your ReAct agent as a graph, meets the prebuilt agent constructor you will see in every 2026 codebase, and then previews the two things hand-rolling makes painful and LangGraph makes structural: persistence and human-in-the-loop approval. Lessons 4 through 6 go deep on each; today is the working skeleton.
pip install -U langgraph "langchain[anthropic]"
Three concepts carry everything. State is a typed
dict that every node reads and updates — the agent's memory made
explicit instead of smuggled through a list.
Nodes are plain functions: state in, state
update out. Edges connect nodes; a
conditional edge chooses the next node by running a
function on the state — which is where "the model decided to call
a tool, so go run it" stops being an if buried in
your loop and becomes a labelled route on a diagram you can
actually read:
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
# add_messages is a reducer: node updates APPEND to history
# instead of overwriting it — the trajectory accumulates safely.
messages: Annotated[list, add_messages]
That reducer annotation is the quiet fix for the bug you have been
avoiding by hand: when two nodes both return a messages
update, LangGraph knows to merge by appending rather than
clobbering. The state's shape is the agent's contract — Lesson 4
is entirely about designing it well.
Two nodes and one decision reproduce your from-scratch ReAct agent
— but now the loop is a visible cycle in a graph, not a
for statement. (This block calls a hosted model, so
run it locally with an API key set, not in the browser.)
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langgraph.prebuilt import ToolNode
from langgraph.graph import StateGraph, START, END
@tool
def get_weather(city: str) -> str:
"Current weather for a city."
return {"Kyiv": "24C clear", "Lviv": "19C rain"}.get(city, "unknown")
@tool
def calculate(expression: str) -> float:
"Evaluate an arithmetic expression."
# Toy-only: eval with builtins stripped. In production use a real
# sandbox — see Tool Use, Code Execution & Sandboxing.
return eval(expression, {"__builtins__": {}}, {})
TOOLS = [get_weather, calculate]
llm = init_chat_model("anthropic:claude-sonnet-4-6").bind_tools(TOOLS)
def call_model(state: AgentState):
return {"messages": [llm.invoke(state["messages"])]}
def should_continue(state: AgentState) -> str:
last = state["messages"][-1]
return "tools" if last.tool_calls else END # the routing decision
graph = StateGraph(AgentState)
graph.add_node("model", call_model)
graph.add_node("tools", ToolNode(TOOLS))
graph.add_edge(START, "model")
graph.add_conditional_edges("model", should_continue,
{"tools": "tools", END: END})
graph.add_edge("tools", "model") # the cycle: tools -> back to model
agent = graph.compile()
result = agent.invoke(
{"messages": [("user", "Is Kyiv or Lviv warmer, and by how much?")]})
print(result["messages"][-1].content)
Kyiv is warmer than Lviv by 5 degrees (24C vs 19C).
Read the graph, not the code, to see the payoff:
START → model; from model, either
loop through tools and back, or finish. The
observe-think-act cycle you built by hand is now a literal cycle
in a data structure — and that structure is what makes everything
below a modification rather than a rewrite. Note what
ToolNode replaced: your entire hand-written dispatch
block — argument parsing, execution, and errors returned as tool
messages so the model can recover.
The graph above is so common that you should not have to write it. The blessed constructor in 2026 lives in LangChain 1.0:
from langchain.agents import create_agent
agent = create_agent(
model="anthropic:claude-sonnet-4-6",
tools=TOOLS,
system_prompt="You are a concise weather and math assistant.",
)
result = agent.invoke(
{"messages": [("user", "What is 24 - 19?")]})
Under the hood create_agent builds essentially the
Section 2 graph — same state, same cycle, same conditional edge —
and returns a compiled graph you can checkpoint, stream, and
extend like any other. Two lessons ride along with it. First, the
churn lesson: this constructor spent years as
langgraph.prebuilt.create_react_agent, and every
tutorial from that era now teaches a deprecated import — Lesson
1's "assume tutorials older than six months are wrong in the
details", made concrete. Second, the judgment call: use the
prebuilt while your agent is "model + tools + system prompt";
drop to an explicit StateGraph the moment you need
custom state keys, extra nodes, or routing the prebuilt does not
expose. Because the prebuilt is a graph, that migration
is mechanical, not a rewrite.
Hand-rolled agents forget everything the moment the function returns. LangGraph adds a checkpointer that saves state after every node, keyed by a thread id — so a conversation resumes across calls, survives a crash mid-run, and can be inspected step by step. This is the mechanism behind both multi-turn agent memory and the human-in-the-loop pause:
from langgraph.checkpoint.memory import InMemorySaver # SQLite/Postgres in prod
agent = graph.compile(checkpointer=InMemorySaver())
cfg = {"configurable": {"thread_id": "user-42"}}
agent.invoke({"messages": [("user", "How warm is Kyiv?")]}, cfg)
# Later, same thread — the agent still has the earlier turn:
r = agent.invoke({"messages": [("user", "And is that above freezing?")]}, cfg)
print(r["messages"][-1].content) # resolves "that" from saved state
Notice what you did not write: no session store, no serialization code, no load-on-start logic. The checkpointer falls out of the typed state model — because all state flows through declared channels, LangGraph knows exactly what to save and when. This is the single biggest purchase from Lesson 2's list, and Lesson 5 spends fifty minutes on it, including killing a process mid-run and resuming.
Agent Architectures insisted that irreversible actions —
sends, payments, deletions — be gated behind human confirmation.
Hand-rolling that meant contorting the loop; with a checkpointer,
LangGraph pauses the graph inside a node via
interrupt(), hands control back to your application,
and resumes exactly where it stopped when you approve:
from langgraph.types import interrupt, Command
@tool
def send_email(to: str, subject: str, body: str) -> str:
"Send an email. Irreversible - gated behind approval."
return f"sent to {to}"
def guarded_tools(state: AgentState):
last = state["messages"][-1]
for call in last.tool_calls:
if call["name"] == "send_email":
# PAUSE: surface the pending action to a human, wait.
decision = interrupt({
"action": "send_email",
"args": call["args"],
"prompt": "Approve sending this email?",
})
if decision != "approve":
return {"messages": [("tool", "Cancelled by user.")]}
return ToolNode(TOOLS + [send_email]).invoke(state)
# The graph pauses at interrupt(); your UI shows the draft; resuming
# with agent.invoke(Command(resume="approve"), cfg) runs the send, and
# the checkpointer means the agent picks up mid-trajectory, state intact.
This is the pattern behind every trustworthy action-taking agent: reads flow freely, writes stop for a human. Because the pause is built on the checkpointer, the agent's entire reasoning context survives the wait — hours or days — and the human approves a fully-formed action rather than a guess. Lesson 5 covers the sharp edges (what re-executes on resume, and why that matters for side effects).
Graph agents fail in graph-shaped ways, and the debugging tools
match. agent.get_graph().draw_mermaid() prints the
actual topology — the first check when routing misbehaves is
whether the edges are what you think. Streaming the run with
agent.stream(..., stream_mode="values") shows state
after every node, making a stuck cycle or a wrong route visible
as it happens. And because state is checkpointed, you can replay
a failed run from any step. The discipline is "inspect before you
fix", lifted to trajectories: when an agent does the wrong thing,
read the state transitions before touching the prompt.
To make the execution model concrete without an API key, run the
cell below. It is a ten-line graph engine — nodes, a conditional
edge, a reducer — driving a scripted "model" that requests two
tool calls and then answers. Watch the node trace: it is exactly
what stream_mode="updates" will show you on the real
thing.
Six lines of trace tell you the whole story: model
routes to tools twice, then to END. When
a real agent misbehaves, this trace — node, route taken, state
size — is where diagnosis starts, and the table below maps the
common symptoms:
| Symptom | Likely cause | Where to look |
|---|---|---|
| Agent never stops | Conditional edge never routes to END | should_continue logic + recursion limit |
| Loses earlier context | State overwritten, not appended | Missing add_messages reducer |
| Approval never fires | interrupt node unreachable | Draw the graph; check the edge into it |
| Resumes wrong / forgets | Thread id mismatch | The configurable.thread_id |
| Duplicate messages | Node returns full history, reducer appends | Return only new messages (Lesson 4) |
Extend the Section 2 graph with a search tool and a
conditional edge that routes questions needing external
information through search before answering, and simple questions
straight to the model. Add the send_email approval
gate so the agent can offer to email its findings — pausing for
your yes. Then verify, in order: (1)
draw_mermaid() shows the topology you intended; (2) a
simple question's trace never touches search; (3) the
email path pauses and resumes with Command(resume=...).
You have now built the skeleton that this course's closing
project ports across three frameworks — and that
Multi-Agent Systems Engineering later scales sideways,
from one agent with many nodes to many agents collaborating. The
next lesson opens the hood on the part you mostly took on faith
today: state, and the reducers that merge it.