← All sessions, cheat sheets & labs
Week 1 · Deep dive · Orchestration

LangGraph

The control-first way to build agents and multi-step workflows: an explicit state graph you own, with durable memory, human-in-the-loop, and streaming built in. This is the long version, from first install to production.

orchestrationopen sourcePython + JSby LangChainv1.x
On this page
Mental model Install Hello world State & graph Conditional routing Tools Memory Human-in-the-loop Streaming Multi-agent Full example Production Gotchas Course fit

The mental model

Your app is nodes and edges. You own the wiring.

LangGraph models an LLM application as a graph. Nodes are plain functions (call a model, run a tool, check a rule). Edges are the control flow you write. A shared state object flows through the graph, and each node returns a partial update to it. On top of that it adds the production plumbing a raw while loop lacks: durable state via checkpointing, streaming, branching, retries, and human-in-the-loop pauses.

LangChain (same team) is the higher-level library of model wrappers, tools, and integrations. LangGraph is the lower-level runtime you reach for when you need real control over the flow, and it is what powers most serious agent stacks in 2026.

flowchart LR
  START(["START"]) --> CL["classify
node"] CL -->|"simple"| ANS["answer
node"] CL -->|"needs tools"| AG["agent
node"] AG --> TN["tool
node"] TN --> AG AG --> ANS ANS --> E(["END"]) classDef code fill:#D6F5E3,stroke:#1B7F46,color:#09244B; classDef model fill:#EEE6FF,stroke:#6D28D9,color:#09244B; class CL,TN code; class AG,ANS model;
In plain English

A flowchart you can actually run. Every box is code you can read, test, and set a breakpoint in. The model only acts inside the boxes you allow it to, and the arrows between boxes are yours.

Install & setup

One package, one API key.

LangGraph is Python-first (a full TypeScript port exists as @langchain/langgraph). We use Python throughout. You also install a model provider adapter, here Anthropic.

# core runtime + a model provider
pip install langgraph langchain "langchain[anthropic]"

# set your key (or OPENAI_API_KEY, etc.)
export ANTHROPIC_API_KEY="sk-ant-..."

Hello world

A working agent in eight lines.

Before the low-level graph API, meet the shortcut. create_react_agent builds a complete tool-using (ReAct) agent: it wires the model-call → tool-call → loop for you. Start here, then drop to StateGraph when you outgrow it.

from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""   # the docstring IS the tool description
    return f"It's 22C and sunny in {city}."

agent = create_react_agent(
    model="anthropic:claude-sonnet-4-5",
    tools=[get_weather],
    prompt="You are a concise weather assistant.",
)

result = agent.invoke({"messages": [{"role": "user", "content": "weather in Oslo?"}]})
print(result["messages"][-1].content)

this one call is a full StateGraph under the hood, you just don't see it yet

The core · state and graph

Build the graph by hand when you need control.

The real power is the low-level API. Three pieces: a state schema (a TypedDict), nodes (functions that take state and return a partial update), and edges (how control moves). A reducer like add_messages tells LangGraph how to merge each update, appending to the message list instead of overwriting it.

from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain.chat_models import init_chat_model

model = init_chat_model("anthropic:claude-sonnet-4-5")

class State(TypedDict):
    messages: Annotated[list, add_messages]   # reducer: append, don't replace

def chatbot(state: State) -> dict:
    return {"messages": [model.invoke(state["messages"])]}

builder = StateGraph(State)
builder.add_node("chatbot", chatbot)
builder.add_edge(START, "chatbot")
builder.add_edge("chatbot", END)
graph = builder.compile()

graph.invoke({"messages": [{"role": "user", "content": "hi"}]})

A node returns a dict with only the keys it wants to change. Everything else in state is left alone. That partial-update model is what makes graphs composable.

Conditional routing

Branch on a decision with add_conditional_edges.

The whole point of a graph over a chain is branching. A routing function reads the state and returns the name of the next node. This is Session 2's code/model boundary made literal: a cheap classifier decides the path, and code owns the branch.

def route(state: State) -> str:
    last = state["messages"][-1].content.lower()
    if "refund" in last:
        return "billing"       # -> node named "billing"
    return "general"

builder.add_conditional_edges(
    "classify",                 # from this node
    route,                        # run this to pick the next
    {"billing": "billing", "general": "general"},  # map return -> node
)

Prefer returning a Command when a node wants to update state and jump in one move: return Command(update={"messages": [...]}, goto="billing"). It keeps routing logic next to the state change that motivates it.

Tools

Give the model actions, gate them in code.

A tool is a Python function with a docstring and typed args. Bind tools to the model so it can request a call; a ToolNode executes the requested calls and feeds results back. The prebuilt tools_condition loops back to the model until it stops asking for tools.

from langgraph.prebuilt import ToolNode, tools_condition
from langchain_core.tools import tool

@tool
def lookup_order(order_id: str) -> dict:
    """Look up an order by its ID."""
    return {"id": order_id, "status": "shipped"}

tools = [lookup_order]
model_with_tools = model.bind_tools(tools)

builder.add_node("agent", lambda s: {"messages": [model_with_tools.invoke(s["messages"])]})
builder.add_node("tools", ToolNode(tools))
builder.add_conditional_edges("agent", tools_condition)  # tool call? -> "tools", else END
builder.add_edge("tools", "agent")                    # feed results back, loop
The boundary still applies

A bound tool means the model proposes the call, LangGraph runs it. For anything with a side effect (refunds, deletes, emails) validate the arguments and check authorization inside the tool function, or gate it behind a human-in-the-loop interrupt. Binding a tool is not the same as authorizing every call.

Memory · checkpointing

Persistence is one argument: a checkpointer.

Compile the graph with a checkpointer and every step is saved. Pass a thread_id and the graph remembers that conversation across calls, survives a crash, and can be replayed. Swap InMemorySaver for the Postgres saver in production, nothing else changes.

from langgraph.checkpoint.memory import InMemorySaver

graph = builder.compile(checkpointer=InMemorySaver())

config = {"configurable": {"thread_id": "user-42"}}
graph.invoke({"messages": [{"role": "user", "content": "my name is Sam"}]}, config)
graph.invoke({"messages": [{"role": "user", "content": "what's my name?"}]}, config)
# -> "Your name is Sam." State was reloaded from the checkpoint by thread_id.

# production: from langgraph.checkpoint.postgres import PostgresSaver

Checkpointing is short-term memory (one thread). For long-term memory shared across threads, LangGraph adds a Store (a key-value store with optional vector search) you read and write inside nodes.

Human-in-the-loop

Pause for approval, resume with a value.

Because state is checkpointed, the graph can stop mid-run, wait for a human, and pick up exactly where it left off, even days later. Call interrupt() inside a node to pause; resume by invoking with Command(resume=...). This is how you gate risky actions.

from langgraph.types import interrupt, Command

def approve_refund(state: State) -> dict:
    decision = interrupt({"ask": "Approve this refund?", "amount": state["amount"]})
    if decision != "yes":
        return {"messages": [{"role": "assistant", "content": "Refund declined."}]}
    return {"messages": [{"role": "assistant", "content": "Refund issued."}]}

# first call pauses at the interrupt and returns control to you
graph.invoke(inputs, config)
# a human decides, then you resume the SAME thread
graph.invoke(Command(resume="yes"), config)

Streaming

Stream state, steps, or tokens.

Use .stream() (or .astream()) with a stream_mode to get progressive output instead of waiting for the whole run. This is what makes an agent feel responsive.

# "updates": what each node changed, as it finishes
for chunk in graph.stream(inputs, config, stream_mode="updates"):
    print(chunk)

# "messages": token-by-token LLM output, for a typing effect
for token, meta in graph.stream(inputs, config, stream_mode="messages"):
    print(token.content, end="")

# "values": the full state snapshot after every step
stream_modeYou getUse it for
valuesfull state after each stepdebugging, audit trails
updatesonly what a node changedprogress UI (“searching…”)
messagesLLM tokens as they generatethe typing effect
customwhatever you emit from a nodetool progress, custom events

Multi-agent

Compose graphs, or supervise sub-agents.

A compiled graph is itself a node. That is how you build multi-agent systems: each agent is a subgraph, and a supervisor node routes between them. The two common shapes:

flowchart TB
  U(["user"]) --> S{"supervisor
routes"} S -->|"research"| R["researcher
subgraph"] S -->|"write"| W["writer
subgraph"] S -->|"done"| E(["END"]) R --> S W --> S classDef model fill:#EEE6FF,stroke:#6D28D9,color:#09244B; classDef code fill:#D6F5E3,stroke:#1B7F46,color:#09244B; class S code; class R,W model;
from langgraph.graph import StateGraph, MessagesState, START

researcher = create_react_agent(model, tools=[web_search], prompt="Research facts.")
writer     = create_react_agent(model, tools=[], prompt="Write the final answer.")

def supervisor(state: MessagesState) -> Command:
    # a routing LLM (or rules) picks the next worker, or ends
    nxt = pick_next(state)                 # "researcher" | "writer" | END
    return Command(goto=nxt)

team = StateGraph(MessagesState)
team.add_node("supervisor", supervisor)
team.add_node("researcher", researcher)   # a subgraph IS a node
team.add_node("writer", writer)
team.add_edge(START, "supervisor")
app = team.compile()

For the common supervisor pattern there is a prebuilt: pip install langgraph-supervisor gives you create_supervisor(...) so you don't hand-write the routing.

Putting it together

A support-triage agent, end to end.

PII stripped in code, the model classifies and can look up an order, a code rule pages on-call for high urgency, everything checkpointed per user. One fuzzy step, code on both sides.

from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.tools import tool
from langchain.chat_models import init_chat_model

model = init_chat_model("anthropic:claude-sonnet-4-5")

class State(TypedDict):
    messages: Annotated[list, add_messages]
    urgency: str

@tool
def lookup_order(order_id: str) -> dict:
    """Look up an order status by ID."""
    return {"id": order_id, "status": "delayed"}

tools = [lookup_order]
agent_model = model.bind_tools(tools)

def strip_pii(state: State) -> dict:
    # deterministic redaction BEFORE the model sees anything
    return {"messages": [redact(m) for m in state["messages"]]}

def agent(state: State) -> dict:
    return {"messages": [agent_model.invoke(state["messages"])]}

def route_urgency(state: State) -> str:
    return "page" if state.get("urgency") == "high" else "done"

def page_oncall(state: State) -> dict:
    notify_oncall()                       # code owns the side effect
    return {}

b = StateGraph(State)
b.add_node("strip_pii", strip_pii)
b.add_node("agent", agent)
b.add_node("tools", ToolNode(tools))
b.add_node("page", page_oncall)
b.add_edge(START, "strip_pii")
b.add_edge("strip_pii", "agent")
b.add_conditional_edges("agent", tools_condition, {"tools": "tools", END: "__route__"})
b.add_edge("tools", "agent")
b.add_conditional_edges("__route__", route_urgency, {"page": "page", "done": END})
b.add_edge("page", END)

app = b.compile(checkpointer=InMemorySaver())

Production

From notebook to service.

Gotchas

What trips people up.

Forgetting the reducer

If messages is a plain list without Annotated[list, add_messages], each node overwrites the history instead of appending. Symptom: the agent forgets everything mid-run. The reducer is not optional for message state.

No checkpointer, no memory

Memory across calls needs both a checkpointer at compile time and a thread_id in the config on every invoke. Miss either and each call starts blank.

Unbounded loops

An agent that keeps calling tools hits the default recursion limit and raises. That's a feature, but tune the limit and add an explicit stop condition rather than relying on the crash.

Reach for it when

Skip it when

The principle it teaches

Course principle

The code / model boundary should be explicit. LangGraph makes the boundary a diagram: the graph is deterministic code you own, the nodes are where the model is allowed to act, and the checkpointer plus interrupts are where humans stay in control. That is Sessions 2 and 3, in a library.

Where it fits your labs

A Week 1 tool. The router you build in Lab 1 is a LangGraph graph in miniature: classify, then branch. When your orchestrator-workers or evaluator-optimizer pattern outgrows plain functions, this is what you graduate to.

Use in
Lab 1 (Workflow Router) and the orchestrator-workers pattern from S1.
week 1
Pairs with
LangSmith for tracing, and Pydantic AI / Instructor when a node needs strictly-typed output.