Infra, Illustrated.
AI · GRAPH RUNTIME CONSOLE

LangGraph: The Agent Loop as a Graph

Take the loop that calls a model and runs its tools, and redraw it as boxes and arrows over one shared record. Then see what that buys: your own routing, saved state, resume after a crash, a pause for approval, specialist agents and a run you can watch.

QUESTION · Why is payments-api failing?INTERMEDIATE · DEEP DIVEPublished Oct 11, 2026 · Reviewed Oct 11, 2026
ACTIVE VIEWThe limit
namespace payments · pod payments-api-7d9f · thread session-123
your code model (remote) Kubernetes happening now no such path stopped
01 / WHY A GRAPH

A loop in a script works until you need more than a script

An agent is a loop that calls a model and runs the tools it asks for. Here it is as plain Python, in one process.

OPTION 1 OF 4 · As a script

The loop appends every message to a list in memory. For a script you run and watch, this is all you need.

An agent loop inside one processShown: the while loop, messages. the while loop to messages: appends.

The loop

while True:    reply = model.invoke(messages)    messages.append(reply)    if not reply.tool_calls:        return reply    messages.extend(run_tools(reply.tool_calls))
OPTION 2 OF 4 · The process restarts

A deploy, a crash, or the next request landing on another replica. The list lived in this process, so the conversation is gone.

NOTHING TO RESUMEThe model calls and tool calls already paid for have to be made again from the first message.

An agent loop inside one processShown: the while loop, messages. messages: gone.
OPTION 3 OF 4 · A person must approve

The model proposes a change to the cluster. Someone has to say yes, and they might answer in an hour.

NOWHERE TO WAITA while loop can only block. Holding a process open for an hour, and hoping it survives, is not a pause.

An agent loop inside one processShown: the while loop, messages, a person. the while loop to a person: stop here until they answer (stopped).
OPTION 4 OF 4 · You want fixed steps

For a crash-looping pod you always want the previous logs checked. In this loop the model picks every next step, so "always" is a hope.

NO PLACE FOR YOUR RULESYou can ask for an order in the prompt. You cannot enforce one without restructuring the loop.

An agent loop inside one processShown: the while loop, messages. the while loop: model picks the order.
INSPECTOR

Four things a plain loop cannot hold

The loop itself is fine. What it lacks is everything around it: a place to keep state outside the process, a way to stop and continue later, and a way for your code to own part of the order of work.

State
A variable in one process
Pausing
Only by blocking
Order of work
Decided by the model each time
Where the conversation livesmessages = [] # in this process only
WORTH KNOWING

You can build each missing piece yourself: a database table for messages, a job queue for approvals, an if-ladder for routing. LangGraph is those pieces built once and made to fit together.

LangGraph: persistence ↗LangGraph: Graph API ↗

THE POINT TO KEEPThe loop is easy. Keeping its state, pausing it and steering it are what need structure.

02 / WHY A GRAPH

A box does some work and hands back what changed

Before the agent, the smallest possible example: one record, one function.

STEP 1 OF 3 · A record goes in

The run starts with a record that has one field filled in.

The smallest graphShown: START, greet, END. START to greet.

The record

name
"Darshan"
greeting
not set

Python

class State(TypedDict):    name: str    greeting: str app.invoke({"name": "Darshan"})
STEP 2 OF 3 · The function runs

The function reads the record and returns only the field it wants to change.

The smallest graphShown: START, greet, END.

The record

name
"Darshan"reads
greeting
not set

Python

def greet(state: State):    return {"greeting": f"Hello {state['name']}"}
STEP 3 OF 3 · The change is merged

The returned field is merged into the record. Everything else is left as it was.

The smallest graphShown: START, greet, END. greet to END.

The record

name
"Darshan"
greeting
"Hello Darshan"writes

Python

graph = StateGraph(State)graph.add_node("greet", greet)graph.add_edge(START, "greet")graph.add_edge("greet", END)app = graph.compile() app.invoke({"name": "Darshan"}) # {'name': 'Darshan', 'greeting': 'Hello Darshan'}
INSPECTOR

State, nodes and edges

LangGraph has names for these three things. The shared record is the state. A function that reads it and returns a change is a node. An arrow saying what runs next is an edge. START and END mark where a run enters and leaves.

State
What the run knows so far
Node
A function: state in, update out
Edge
What runs next
A node returns an update{"greeting": "Hello Darshan"}
WORTH KNOWING

A node does not return the whole record. It returns the keys it changed, and the runtime merges them. That is what lets several nodes work on one state without overwriting each other.

LangGraph: Graph API ↗

THE POINT TO KEEPA node reads the shared state and returns only what it changed.

03 / WHY A GRAPH

Each field says how a change is merged: replace or add

Two nodes write to the same record, one after the other. What the second write does depends on the field.

OPTION 1 OF 2 · Replace

By default a new value replaces the old one. failure_type holds whatever was written last.

Two nodes writing to one stateShown: START, inspect_pod, inspect_logs, END. inspect_pod to inspect_logs: update.

After inspect_pod

failure_type
"CrashLoopBackOff"writes
evidence
["restarts=8"]writes

Python

class K8sAgentState(TypedDict):    namespace: str    pod_name: str    failure_type: str | None    evidence: Annotated[list[str], operator.add]    diagnosis: str | None
OPTION 2 OF 2 · Add

evidence is declared with a function that joins lists, so the second node's item is added to the first node's.

Two nodes writing to one stateShown: START, inspect_pod, inspect_logs, END. inspect_logs to END: update.

After inspect_logs

failure_type
"CrashLoopBackOff"
evidence
["restarts=8", "last_reason=OOMKilled"]writes

Python

class K8sAgentState(TypedDict):    namespace: str    pod_name: str    failure_type: str | None    evidence: Annotated[list[str], operator.add]    diagnosis: str | None

What inspect_logs returned

return {"evidence": ["last_reason=OOMKilled"]} # one item, not the whole list
INSPECTOR

The merge rule is called a reducer

A reducer takes the current value and the update and returns the new value. With none declared, the update wins. For a message list you want appending, which is what the built-in MessagesState does: its messages key uses a reducer named add_messages.

No reducer
New value replaces old
operator.add
Lists are joined
add_messages
Appends messages, or replaces one with the same id
Declaring oneevidence: Annotated[list[str], operator.add]
WORTH KNOWING

With an adding reducer, returning an empty list clears nothing. Forgetting the reducer on a list is the opposite bug: each node silently wipes what the last one found.

LangGraph: Graph API ↗

THE POINT TO KEEPUpdates are merged per key. A list that should grow needs a reducer that adds.

04 / THE AGENT AS A GRAPH

The agent loop, drawn as two boxes and a question

Now the real thing. The state is a message list, and the question is "Why is payments-api failing?"

STEP 1 OF 5 · Start

The question is put into the state and the run enters the model node.

The agent loop as a graphShown: START, model, tool calls?, END, tools. START to model.

State

messages
1 itemwrites
newest
user: Why is payments-api failing?writes

Building the graph

graph = StateGraph(MessagesState)graph.add_node("model", call_model)graph.add_node("tools", ToolNode(TOOLS))graph.add_edge(START, "model")graph.add_conditional_edges("model", tools_condition)graph.add_edge("tools", "model")app = graph.compile()
STEP 2 OF 5 · The model asks for a tool

The model node calls the LLM and returns its reply, which is appended. The reply contains a tool call, so the arrow out of the question goes down.

The agent loop as a graphShown: START, model, tool calls?, END, tools. model to tool calls?. tool calls? to tools: yes.

State

messages
2 itemswrites
newest
assistant: list_pods(namespace="payments")writes

Building the graph

graph = StateGraph(MessagesState)graph.add_node("model", call_model)graph.add_node("tools", ToolNode(TOOLS))graph.add_edge(START, "model")graph.add_conditional_edges("model", tools_condition)graph.add_edge("tools", "model")app = graph.compile()
STEP 3 OF 5 · The tools node runs it

The tools node runs each requested tool and appends the results. A fixed arrow leads back to the model node.

The agent loop as a graphShown: START, model, tool calls?, END, tools. tools to model: results.

State

messages
3 itemswrites
newest
tool: payments-api-7d9f ready=0/1 restarts=8writes

Building the graph

graph = StateGraph(MessagesState)graph.add_node("model", call_model)graph.add_node("tools", ToolNode(TOOLS))graph.add_edge(START, "model")graph.add_conditional_edges("model", tools_condition)graph.add_edge("tools", "model")app = graph.compile()
STEP 4 OF 5 · Round again

The model sees the pod list and asks for details. Down and back once more.

The agent loop as a graphShown: START, model, tool calls?, END, tools. model to tool calls?. tool calls? to tools: yes. tools to model: results.

State

messages
5 itemswrites
newest
tool: CrashLoopBackOff, last_reason=OOMKilledwrites

Building the graph

graph = StateGraph(MessagesState)graph.add_node("model", call_model)graph.add_node("tools", ToolNode(TOOLS))graph.add_edge(START, "model")graph.add_conditional_edges("model", tools_condition)graph.add_edge("tools", "model")app = graph.compile()
STEP 5 OF 5 · The model answers

This reply has no tool call, so the arrow out of the question goes to END and the run returns the state.

The agent loop as a graphShown: START, model, tool calls?, END, tools. model to tool calls?. tool calls? to END: no.

State

messages
6 itemswrites
newest
assistant: payments-api-7d9f was OOMKilled…writes

Building the graph

graph = StateGraph(MessagesState)graph.add_node("model", call_model)graph.add_node("tools", ToolNode(TOOLS))graph.add_edge(START, "model")graph.add_conditional_edges("model", tools_condition)graph.add_edge("tools", "model")app = graph.compile()
INSPECTOR

A branch that depends on the state

The arrow from model is a conditional edge: a function looks at the state and names the next node. tools_condition is a ready-made one. It returns "tools" when the newest message has tool calls, and END when it does not. The arrow from tools back to model is the cycle.

call_model
A node you write: calls the LLM, returns {"messages": [reply]}
ToolNode
A ready-made node that runs every tool call in the newest message
MessagesState
A ready-made state with one key, messages
Checked by running ittools_condition(state) → 'tools' or '__end__'
WORTH KNOWING

tools_condition and ToolNode both expect the state to have a messages key, and tools_condition routes to a node named "tools". Name things differently and you write the three-line condition yourself.

LangGraph: Graph API ↗LangChain: agents ↗

THE POINT TO KEEPA loop is a conditional edge plus an edge that points back.

05 / THE AGENT AS A GRAPH

The while loop was this graph all along

One picture, three ways to write it down.

OPTION 1 OF 3 · while loop

The if is the question. The bottom of the loop body is the arrow back.

The agent loop as a graphShown: START, model, tool calls?, END, tools.
The same four parts
In the loopIn the graph
messages, a local listState with a messages key
if not reply.tool_callsA conditional edge out of model
return replyThe edge to END
The end of the loop bodyThe edge from tools back to model

A while loop

while True:    reply = model.invoke(messages)    messages.append(reply)    if not reply.tool_calls:        return reply    messages.extend(run_tools(reply.tool_calls))
OPTION 2 OF 3 · StateGraph

Each line adds one box or one arrow of the picture.

The agent loop as a graphShown: START, model, tool calls?, END, tools. tool calls? to END: no. tool calls? to tools: yes. tools to model: results.

A graph you build

from langgraph.graph import StateGraph, MessagesState, STARTfrom langgraph.prebuilt import ToolNode, tools_condition def call_model(state: MessagesState):    reply = model_with_tools.invoke(state["messages"])    return {"messages": [reply]} graph = StateGraph(MessagesState)graph.add_node("model", call_model)graph.add_node("tools", ToolNode(TOOLS))graph.add_edge(START, "model")graph.add_conditional_edges("model", tools_condition)graph.add_edge("tools", "model")app = graph.compile()
OPTION 3 OF 3 · create_agent

The prebuilt agent builds a graph of this shape and compiles it for you.

The agent loop as a graphShown: START, model, tool calls?, END, tools.

The prebuilt agent

from langchain.agents import create_agent agent = create_agent(model, tools=TOOLS)list(agent.get_graph().nodes) # ['__start__', 'model', 'tools', '__end__']
INSPECTOR

Nothing was added, the structure was named

Writing the loop as a graph does not make it do anything new. It makes the control flow into data the runtime can see. Once the runtime knows where a run is, it can save that position, stop there, and show it to you.

Loop
Control flow hidden inside Python
Graph
Control flow the runtime can save, pause and draw
create_agent
The standard graph, prebuilt
Draw any compiled graphapp.get_graph().draw_mermaid()
WORTH KNOWING

A compiled graph has the same invoke, stream and batch methods as a model or a chain, so it drops into code that already uses those. Older code calls create_react_agent from langgraph.prebuilt. It builds the same shape with the model node named agent, and LangGraph 1.0 deprecated it in favour of create_agent.

LangGraph: Graph API ↗LangChain reference: create_agent ↗

THE POINT TO KEEPLangGraph turns the implicit loop into an explicit graph over state.

06 / YOUR OWN FLOW

State can hold more than messages

Give the troubleshooting run named fields, and each node touches only the ones it needs.

OPTION 1 OF 3 · inspect_pod

Reads which pod to look at. Writes what kind of failure it found and the first evidence.

A troubleshooting graph that branches on the failureShown: START, inspect_pod, inspect_scheduler, inspect_logs, inspect_image, general_checks, diagnose, END.

K8sAgentState

namespace
"payments"reads
pod_name
"payments-api-7d9f"reads
failure_type
"CrashLoopBackOff"writes
evidence
["restarts=8"]writes
diagnosis
None

The state

class K8sAgentState(TypedDict):    namespace: str    pod_name: str    failure_type: str | None    evidence: Annotated[list[str], operator.add]    diagnosis: str | None

The node

def inspect_pod(state: K8sAgentState):    status = get_pod_status(state["namespace"],                            state["pod_name"])    return {"failure_type": status.waiting_reason                            or status.phase,            "evidence": [f"restarts={status.restarts}"]}
OPTION 2 OF 3 · inspect_logs

Reads the same pod. Adds what the previous run's termination says.

A troubleshooting graph that branches on the failureShown: START, inspect_pod, inspect_scheduler, inspect_logs, inspect_image, general_checks, diagnose, END.

K8sAgentState

namespace
"payments"reads
pod_name
"payments-api-7d9f"reads
failure_type
"CrashLoopBackOff"
evidence
[…, "exit_code=137", "last_reason=OOMKilled"]writes
diagnosis
None

The state

class K8sAgentState(TypedDict):    namespace: str    pod_name: str    failure_type: str | None    evidence: Annotated[list[str], operator.add]    diagnosis: str | None
OPTION 3 OF 3 · diagnose

Reads the failure type and the evidence. Writes the diagnosis. It never needs the raw tool output.

A troubleshooting graph that branches on the failureShown: START, inspect_pod, inspect_scheduler, inspect_logs, inspect_image, general_checks, diagnose, END.

K8sAgentState

namespace
"payments"
pod_name
"payments-api-7d9f"
failure_type
"CrashLoopBackOff"reads
evidence
3 itemsreads
diagnosis
"Killed for exceeding its memory limit…"writes

The state

class K8sAgentState(TypedDict):    namespace: str    pod_name: str    failure_type: str | None    evidence: Annotated[list[str], operator.add]    diagnosis: str | None
INSPECTOR

Named fields instead of one long transcript

With only a message list, every fact a later step needs has to be found again in the text. Named fields let a node read exactly what it needs and say exactly what it produced. They also let plain code, with no model involved, make decisions from the state.

A node reads
Any key in the state
A node writes
Only the keys it returns
Worth a field
Anything a later decision depends on
Keeping messages as wellclass K8sAgentState(MessagesState): …
WORTH KNOWING

Pending is a pod phase. CrashLoopBackOff and ImagePullBackOff are reasons a container is waiting. OOMKilled is why a container's previous run ended. inspect_pod folds the first two into one failure_type field; OOMKilled turns up later, as evidence.

LangGraph: Graph API ↗Kubernetes: Pod lifecycle ↗

THE POINT TO KEEPPut what later steps depend on into named state, and each node can stay small.

07 / YOUR OWN FLOW

Your code picks the path

Choose what inspect_pod found, and see which box runs next.

OPTION 1 OF 4 · Pending

The pod has not been scheduled or started. The next evidence is with the scheduler: events about nodes, taints and unbound volumes.

A troubleshooting graph that branches on the failureShown: START, inspect_pod, inspect_scheduler, inspect_logs, inspect_image, general_checks, diagnose, END. inspect_pod to inspect_scheduler: Pending. inspect_scheduler to diagnose.

State

failure_type
"Pending"reads

The routing function

def route_problem(state) -> str:    kind = state["failure_type"]    if kind == "Pending":        return "inspect_scheduler"    if kind == "CrashLoopBackOff":        return "inspect_logs"    if kind == "ImagePullBackOff":        return "inspect_image"    return "general_checks" graph.add_conditional_edges("inspect_pod", route_problem)
OPTION 2 OF 4 · CrashLoopBackOff

The container starts and dies. The next evidence is the previous run: its logs, exit code and termination reason.

A troubleshooting graph that branches on the failureShown: START, inspect_pod, inspect_scheduler, inspect_logs, inspect_image, general_checks, diagnose, END. inspect_pod to inspect_logs: CrashLoopBackOff. inspect_logs to diagnose.

State

failure_type
"CrashLoopBackOff"reads

The routing function

def route_problem(state) -> str:    kind = state["failure_type"]    if kind == "Pending":        return "inspect_scheduler"    if kind == "CrashLoopBackOff":        return "inspect_logs"    if kind == "ImagePullBackOff":        return "inspect_image"    return "general_checks" graph.add_conditional_edges("inspect_pod", route_problem)
OPTION 3 OF 4 · ImagePullBackOff

The image cannot be pulled. The next evidence is the image reference and the pull events.

A troubleshooting graph that branches on the failureShown: START, inspect_pod, inspect_scheduler, inspect_logs, inspect_image, general_checks, diagnose, END. inspect_pod to inspect_image: ImagePullBackOff. inspect_image to diagnose.

State

failure_type
"ImagePullBackOff"reads

The routing function

def route_problem(state) -> str:    kind = state["failure_type"]    if kind == "Pending":        return "inspect_scheduler"    if kind == "CrashLoopBackOff":        return "inspect_logs"    if kind == "ImagePullBackOff":        return "inspect_image"    return "general_checks" graph.add_conditional_edges("inspect_pod", route_problem)
OPTION 4 OF 4 · Anything else

A reason this graph has no dedicated path for still gets handled: a general set of checks.

A troubleshooting graph that branches on the failureShown: START, inspect_pod, inspect_scheduler, inspect_logs, inspect_image, general_checks, diagnose, END. inspect_pod to general_checks: anything else. general_checks to diagnose.

State

failure_type
"CreateContainerConfigError"reads

The routing function

def route_problem(state) -> str:    kind = state["failure_type"]    if kind == "Pending":        return "inspect_scheduler"    if kind == "CrashLoopBackOff":        return "inspect_logs"    if kind == "ImagePullBackOff":        return "inspect_image"    return "general_checks" graph.add_conditional_edges("inspect_pod", route_problem)
INSPECTOR

A conditional edge is an ordinary function

route_problem reads one field and returns the name of the next node. No model is called to make this decision, so it costs nothing, behaves the same way every time, and can be tested with a dict. The model still does the reading and reasoning inside the nodes.

The application owns
Which path a failure takes
The model owns
Reading the evidence and wording the diagnosis
Fallback
Always give unmatched values somewhere to go
Testing it without a modelassert route_problem({"failure_type": "Pending"}) == "inspect_scheduler"
WORTH KNOWING

You are trading flexibility for predictability. A free-running agent can investigate failures you did not anticipate; a routed graph does the same thing every time for the ones you did. Many systems use fixed routing with one small agent loop inside a node.

LangGraph: Graph API ↗Kubernetes: Pod lifecycle ↗

THE POINT TO KEEPWhen you already know the right next step, say so in an edge instead of hoping the model picks it.

08 / YOUR OWN FLOW

A cycle needs a way out that you wrote

Let the graph go round for more evidence when the first pass is not enough.

OPTION 1 OF 3 · Enough on the second pass

The first diagnosis is unsure, so the run gathers more and tries again. The second one is confident, and the run ends.

A cycle with an exit conditionShown: diagnose, enough evidence?, END, gather_more. enough evidence? to END: yes. enough evidence? to gather_more: no. gather_more to diagnose: more evidence. diagnose: attempt 2.

State

attempts
2writes
diagnosis
"Killed for exceeding its memory limit…"writes

Python

def enough(state) -> str:    if state["diagnosis"] or state["attempts"] >= 3:        return END    return "gather_more" graph.add_conditional_edges("diagnose", enough)graph.add_edge("gather_more", "diagnose")
OPTION 2 OF 3 · Never enough, with a counter

The evidence never settles it. After three attempts the counter in the state sends the run to END with what it has.

ENDS CLEANLYThe caller gets the evidence collected so far and can say "no confident diagnosis after three attempts".

A cycle with an exit conditionShown: diagnose, enough evidence?, END, gather_more. enough evidence? to END: yes. diagnose: attempt 3.

State

attempts
3reads
diagnosis
None

Python

def enough(state) -> str:    if state["diagnosis"] or state["attempts"] >= 3:        return END    return "gather_more"
OPTION 3 OF 3 · Never enough, no counter

Without the counter the cycle has no exit. The runtime stops it at its step limit and raises an error.

STOPPED BY THE RUNTIMEAn exception, not an answer. Every model call up to the limit has been paid for.

A cycle with an exit conditionShown: diagnose, enough evidence?, END, gather_more. enough evidence? to END: yes (stopped). enough evidence? to gather_more: no. gather_more to diagnose: more evidence. diagnose: step 12 of 12.

Python

app.invoke(state, {"recursion_limit": 12}) # GraphRecursionError: Recursion limit of 12 reached # without hitting a stop condition.
INSPECTOR

Two limits, and only one of them is yours

The runtime counts steps and raises GraphRecursionError at recursion_limit. That is a safety net for bugs. The limit you design belongs in the state: a counter the routing function checks, so the run ends with a usable result.

Your limit
A counter in state, checked by the conditional edge
The runtime's limit
recursion_limit, passed in the run config
For create_agent
ModelCallLimitMiddleware(run_limit=…)
Set it where you invokeapp.invoke(state, {"recursion_limit": 12})
WORTH KNOWING

Do not lean on the default. It was 25 in early releases; an endless graph run against LangGraph 1.2.14 for this page stopped at 10,007 steps, and the Graph API guide gives 1,000. Set the number you mean.

LangGraph: Graph API ↗

THE POINT TO KEEPPut the exit condition in the state. Treat the runtime's step limit as a fuse.

09 / SAVED STATE

After every step, the state is saved

Compile the agent graph with somewhere to save, and give the run a name.

STEP 1 OF 4 · Start a named run

The run is given an id. Before the model node runs, the state is saved with a note of what comes next.

The agent loop as a graphShown: START, model, tool calls?, END, tools. START to model.

Checkpoints · thread_id "session-123"

  1. #0next: model · 1 messagejust saved

Python

app = graph.compile(checkpointer=checkpointer)config = {"configurable": {"thread_id": "session-123"}}app.invoke({"messages": [question]}, config)
STEP 2 OF 4 · After the model node

The model node finishes. The state, now two messages long, is saved again.

The agent loop as a graphShown: START, model, tool calls?, END, tools. model to tool calls?. model: saved.

Checkpoints · thread_id "session-123"

  1. #0next: model · 1 message
  2. #1next: tools · 2 messagesjust saved
STEP 3 OF 4 · After the tools node

And again after the tools node. Each save is a complete snapshot, and the earlier ones are kept.

The agent loop as a graphShown: START, model, tool calls?, END, tools. tools to model: results. model: saved. tools: saved.

Checkpoints · thread_id "session-123"

  1. #0next: model · 1 message
  2. #1next: tools · 2 messages
  3. #2next: model · 3 messagesjust saved
STEP 4 OF 4 · End of the run

Three model calls and two tool runs have left six snapshots. The last one has nothing left to run.

The agent loop as a graphShown: START, model, tool calls?, END, tools. model: saved. tools: saved.

Checkpoints · thread_id "session-123"

  1. #0next: model · 1 message
  2. #1next: tools · 2 messages
  3. #2next: model · 3 messages
  4. #3next: tools · 4 messages
  5. #4next: model · 5 messages
  6. #5next: nothing · 6 messagesjust saved

Ask a follow-up with the same thread_id and the run starts from #5, history included. A thread_id that has never been used starts empty.

Python

app.invoke({"messages": [follow_up]}, config)  # same threadapp.get_state(config).next                     # ()len(list(app.get_state_history(config))) # 7: these six, and an empty one from before the input
INSPECTOR

Checkpoints, threads and checkpointers

Each saved snapshot is a checkpoint. The id that groups a run's checkpoints is its thread_id, and the object that writes them is a checkpointer. With one in place, conversation memory stops being a variable in your process: any replica can load the thread and carry on.

Saved
After each step, with the nodes due next
Keyed by
thread_id in the run config
Where
InMemorySaver to learn; SqliteSaver or PostgresSaver to keep
Read the latest oneapp.get_state(config).values["messages"]
WORTH KNOWING

InMemorySaver keeps checkpoints in the process, so it survives nothing. Saved threads are also data you now hold: scope them per user, protect them, and expire them, because they contain every tool result.

LangGraph: checkpointers ↗LangGraph: persistence ↗

THE POINT TO KEEPA checkpointer moves the run's state out of your process and into a store, one snapshot per step.

10 / SAVED STATE

Kill the process mid-run and pick up where it stopped

Same run, with checkpoints going to a database. The process dies during the second tool call.

STEP 1 OF 5 · Running

Two model calls and one tool run are done. The tools node has started on get_pod_details.

The agent loop as a graphShown: START, model, tool calls?, END, tools. tools: running.

Checkpoints · thread_id "session-123"

  1. #0next: model · 1 message
  2. #1next: tools · 2 messages
  3. #2next: model · 3 messages
  4. #3next: tools · 4 messagesjust saved
STEP 2 OF 5 · The process dies

Out of memory, a node drain, a deploy. Whatever the tools node was doing is lost. The checkpoints are in the database and are not.

PROCESS GONEWith a list in memory this would be the end of the conversation.

The agent loop as a graphShown: START, model, tool calls?, END, tools. tools: lost.

Checkpoints · thread_id "session-123"

  1. #0next: model · 1 message
  2. #1next: tools · 2 messages
  3. #2next: model · 3 messages
  4. #3next: tools · 4 messages

The newest checkpoint says: next is tools, with 4 messages.

STEP 3 OF 5 · A new process resumes

Any process with the same graph and the same store can continue. It passes no new input, only the thread id.

The agent loop as a graphShown: START, model, tool calls?, END, tools.

Checkpoints · thread_id "session-123"

  1. #0next: model · 1 message
  2. #1next: tools · 2 messages
  3. #2next: model · 3 messages
  4. #3next: tools · 4 messagesloaded

Python

config = {"configurable": {"thread_id": "session-123"}}app.invoke(None, config)     # None: continue, don't restart
STEP 4 OF 5 · The interrupted node runs again

The tools node starts over from its first line. The two model calls before it are not repeated.

The agent loop as a graphShown: START, model, tool calls?, END, tools. tools to model: results. tools: runs again.

Checkpoints · thread_id "session-123"

  1. #0next: model · 1 message
  2. #1next: tools · 2 messages
  3. #2next: model · 3 messages
  4. #3next: tools · 4 messages
  5. #4next: model · 5 messagesjust saved
STEP 5 OF 5 · The run finishes

The model sees the details and answers. The user gets one reply, as if nothing happened.

The agent loop as a graphShown: START, model, tool calls?, END, tools. model to tool calls?. tool calls? to END: no.

Checkpoints · thread_id "session-123"

  1. #0next: model · 1 message
  2. #1next: tools · 2 messages
  3. #2next: model · 3 messages
  4. #3next: tools · 4 messages
  5. #4next: model · 5 messages
  6. #5next: nothing · 6 messagesjust saved
INSPECTOR

Resuming repeats one node, not the whole run

A run continues from its last checkpoint, which sits between nodes. The node that was in flight when the process died has no partial record, so it runs again from the top. Everything before it is read back from the store.

Repeated
The node that was running
Not repeated
Every node that had finished
So
A node with side effects must be safe to run twice
Checked by running itresumed: tools, model (earlier model calls: 0)
WORTH KNOWING

Re-running a read is harmless. Re-running "create ticket" or "scale deployment" is not, unless the call is idempotent. Put the write in its own node, after the decision, and make it safe to retry. Checkpoint writes can also be made synchronous with durability="sync" when losing the last step is not acceptable.

LangGraph: checkpointers ↗LangGraph: persistence ↗

THE POINT TO KEEPDurable state means a crash costs you one step. It also means every step may run twice.

11 / SAVED STATE

Stop for a person, with no process waiting

The diagnosis suggests raising the memory limit. That is a write to the cluster, so someone has to agree first.

OPTION 1 OF 3 · Waiting

The approval node asks its question and the run stops. The state is saved; the process is free to do anything else, or exit.

A graph that waits for a person before a writeShown: diagnose, propose_fix, approval, apply_fix, END. approval: paused.

Checkpoints · thread_id "session-123"

  1. #7next: approval · waiting on a personjust saved

The caller gets the question back and can show it to someone.

The node

def approval(state):    answer = interrupt({             # the run stops here        "question": "Apply this change?",        "action": state["proposed_action"],    })    return {"approved": answer}      # runs after resume

What the caller sees

result = app.invoke(inputs, config)result["__interrupt__"][0].value # {'question': 'Apply this change?', #  'action': 'set_memory_limit(payments-api, 512Mi)'}
OPTION 2 OF 3 · Approve

An hour later, someone approves. The run is loaded from the store, the approval node runs again and this time gets an answer, and the write goes ahead.

A graph that waits for a person before a writeShown: diagnose, propose_fix, approval, apply_fix, END. approval to apply_fix. apply_fix to END. approval: approved.

Checkpoints · thread_id "session-123"

  1. #7next: approval · waiting on a personloaded
  2. #8next: apply_fix · approved=Truejust saved

Resuming

from langgraph.types import Command app.invoke(Command(resume=True), config)   # same thread_id
OPTION 3 OF 3 · Reject

Someone says no. The run resumes the same way, the answer is False, and the edge out of approval goes to END. The write tool never runs.

A graph that waits for a person before a writeShown: diagnose, propose_fix, approval, apply_fix, END. approval to apply_fix (stopped). approval to END: rejected. approval: rejected.

Checkpoints · thread_id "session-123"

  1. #7next: approval · waiting on a personloaded
  2. #8next: nothing · approved=Falsejust saved

Resuming

app.invoke(Command(resume=False), config)

The edge out of approval

def after_approval(state) -> str:    return "apply_fix" if state["approved"] else END graph.add_conditional_edges("approval", after_approval)
INSPECTOR

A pause is a saved state and an unanswered question

interrupt() stops the run inside a node and hands its argument to the caller. Nothing is sleeping. Resuming loads the thread and runs that node again from its first line; this time interrupt() returns the value you passed to Command(resume=…).

Needs
A checkpointer and a thread_id
On resume
The node restarts from the top
Rule of thumb
Read tools run freely; write tools wait for a person
The run is parked hereapp.get_state(config).next → ('approval',)
WORTH KNOWING

Because the node restarts, anything before interrupt() runs twice. Keep the lines above it free of side effects. With create_agent you get the same pause from HumanInTheLoopMiddleware, which lets a reviewer approve, edit or reject each tool call.

LangGraph: interrupts ↗LangChain: human-in-the-loop middleware ↗

THE POINT TO KEEPHuman approval is a checkpoint plus a resume. The wait costs nothing because no process is holding it.

12 / BIGGER GRAPHS

When the input is a sentence, route on a label and on how sure it is

So far the branch came from an exact field. A question typed by a person has to be labelled first, and a label can be wrong.

OPTION 1 OF 3 · A clear question

Triage labels the question as a workloads problem and is nearly certain. The score clears the bar, so the label picks the branch.

A triage step that routes on a label and a confidenceShown: START, triage, sure enough?, workloads, network, ask_person. triage to sure enough?. sure enough? to workloads: 0.94.

State

question
"Why is payments-api failing?"reads
route
"workloads"writes
confidence
0.94writes

Python

def triage(state):    label, score = classify(state["question"])    return {"route": label, "confidence": score} def next_step(state) -> str:    if state["confidence"] < 0.6:        return "ask_person"    return state["route"] graph.add_conditional_edges("triage", next_step,    ["workloads", "network", "ask_person"])
OPTION 2 OF 3 · Another clear one

A different question, a different label, and still a high score. Same edge, other branch.

A triage step that routes on a label and a confidenceShown: START, triage, sure enough?, workloads, network, ask_person. triage to sure enough?. sure enough? to network: 0.88.

State

question
"payments-api cannot reach its database"reads
route
"network"writes
confidence
0.88writes

Python

def triage(state):    label, score = classify(state["question"])    return {"route": label, "confidence": score} def next_step(state) -> str:    if state["confidence"] < 0.6:        return "ask_person"    return state["route"] graph.add_conditional_edges("triage", next_step,    ["workloads", "network", "ask_person"])
OPTION 3 OF 3 · An unclear one

Triage still produces a label, because it always does. The score is low, so the label is not followed and the question goes to a person.

A PERSON, NOT A GUESSask_person can pause the run with interrupt(), the way the approval node did, and resume with the label someone chose.

A triage step that routes on a label and a confidenceShown: START, triage, sure enough?, workloads, network, ask_person. triage to sure enough?. sure enough? to network (stopped). sure enough? to ask_person: 0.41.

State

question
"payments has been slow since Tuesday"reads
route
"network"writes
confidence
0.41writes

Python

def triage(state):    label, score = classify(state["question"])    return {"route": label, "confidence": score} def next_step(state) -> str:    if state["confidence"] < 0.6:        return "ask_person"    return state["route"] graph.add_conditional_edges("triage", next_step,    ["workloads", "network", "ask_person"])
INSPECTOR

Keep the decision and its confidence as two fields

Whatever labels the question should return one of a fixed set of labels and a score. Put both in their own state keys, not in messages, and the edge that follows is plain code again: check the score, then follow the label.

Label
One of a fixed set, never free text to parse
Score
Decides whether the label is followed
Below the bar
A person, or a question back to the user
Say where the edge can goadd_conditional_edges("triage", next_step, ["workloads", "network", "ask_person"])
WORTH KNOWING

What does the labelling is your choice. A chat model with structured output works, but a confidence it writes is a number it generated, not a measured probability. Classifiers and decision models such as TypeSafe AI's Jev return typed answers with probabilities and write no prose, so text generation is spent only where a reply is written. The 0.6 here is an example: pick the bar from questions you have labelled yourself.

LangGraph: Graph API ↗LangChain blog: Jev and LangGraph ↗LangGraph: interrupts ↗

THE POINT TO KEEPStore the decision and how sure it was, and send a low score to a person instead of guessing.

13 / BIGGER GRAPHS

Each destination can be a whole agent

The boxes that triage chose between do not have to be single functions. Each can be the two-box agent graph, with its own tools.

OPTION 1 OF 4 · Pick a specialist

The supervisor is the triage step under a new name. It picks one agent, and only that agent runs.

A supervisor routing to specialist agentsShown: supervisor, model, tools, model, tools. supervisor to model.

State

messages
1 itemreads
route
"workloads"writes

Python

workloads = create_agent(model, tools=WORKLOAD_TOOLS)network = create_agent(model, tools=NETWORK_TOOLS) graph = StateGraph(State)     # messages and routegraph.add_node("supervisor", supervisor)graph.add_node("workloads", workloads)   # a graph as a nodegraph.add_node("network", network)
OPTION 2 OF 4 · Inside it

The workloads agent runs its own loop with only the pod and deployment tools. When it finishes, the messages it added are in the outer state.

A supervisor routing to specialist agentsShown: supervisor, model, tools, model, tools. model to tools. tools to model.

State

messages
6 itemswrites
newest
assistant: payments-api-7d9f was OOMKilled…writes
route
"workloads"
OPTION 3 OF 4 · Add a specialist

A storage agent is one more row in a table. The loop that builds the graph does not change.

A supervisor routing to specialist agentsShown: supervisor, model, tools, model, tools, model, tools. supervisor to model.

Python

AGENTS = {    "workloads": create_agent(model, tools=WORKLOAD_TOOLS),    "network": create_agent(model, tools=NETWORK_TOOLS),    "storage": create_agent(model, tools=STORAGE_TOOLS),}for name, agent in AGENTS.items():    graph.add_node(name, agent)    graph.add_edge(name, END)
OPTION 4 OF 4 · Route from the node

A node can return a Command that updates the state and names the next node together, so the supervisor needs no separate routing function.

A supervisor routing to specialist agentsShown: supervisor, model, tools, model, tools, model, tools. supervisor to model.

State

route
"workloads"writes

Python

from langgraph.types import Command Next = Literal["workloads", "network", "storage"] def supervisor(state) -> Command[Next]:    label = pick(state)    return Command(goto=label, update={"route": label})
INSPECTOR

A graph can be a node in another graph

One router with thirty branches is hard to read and harder to change. The usual shape is a small top level that picks among a handful of specialists, each a compiled graph with its own loop. A specialist is given only the tools for its job, so its model chooses among five tools instead of fifty.

Supervisor
Picks one specialist: code, a classifier or a model call
Specialist
A compiled graph passed to add_node
Shared state
Keys with the same name in both graphs, such as messages
Checked by running itsupervisor → workloads: model, tools, model, tools, model → END
WORTH KNOWING

A graph added as a node shares the outer state by key name. If its state has a different shape, call it from inside a node function and translate in and out. The Command return type is what tells LangGraph where the supervisor can go: without it the run still works, but the drawn graph has no arrows out of supervisor. LangChain's multi-agent guide calls this shape a router, and describes the other common wiring, where the main agent calls each specialist as a tool.

LangGraph: subgraphs ↗LangChain: multi-agent ↗LangGraph: Graph API ↗

THE POINT TO KEEPKeep the top level small. Route to a few specialists and let the detail live inside each one.

14 / IN A SERVICE

Where the graph sits when it is a service

Around the graph are the same things any backend has: an API in front, a database beside it, and the systems it calls.

OPTION 1 OF 2 · Tools in your process

The tool functions run inside the service and call Kubernetes with the service's own credential.

Where the graph sits in a serviceShown: user, your API, graph runtime, model API, checkpoint store, tools, Kubernetes API. graph runtime to tools: tool calls. tools to Kubernetes API.
OPTION 2 OF 2 · Tools behind MCP

The same tools, served by a separate process over the Model Context Protocol. The graph is unchanged: a function call became a network call.

Where the graph sits in a serviceShown: user, your API, graph runtime, model API, checkpoint store, tools, Kubernetes API, MCP server. graph runtime to tools: tool calls. tools to MCP server: MCP. MCP server to Kubernetes API.

Python

from langchain.mcp import MCPAdapter async with MCPAdapter("https://k8s-mcp.internal.example/mcp") as adapter:    tools = await adapter.list_tools()   # ordinary tools    agent = create_agent(model, tools)
INSPECTOR

An ordinary service with a graph in the middle

The API authenticates the user and picks the thread. The runtime loads that thread's checkpoint, runs nodes, and saves after each one. Model calls go out to a provider. Tool calls go to systems that authorise them with their own rules.

Watch
Model calls and tokens per run, tool errors, steps per run, runs waiting on approval
Test
Routing functions with plain dicts; a resume after a killed process
Decide early
Who can read a thread, and when threads are deleted
One thread per conversationconfig = {"configurable": {"thread_id": session.id}}
WORTH KNOWING

MCP changes where a tool runs and who operates it. It does not change who is responsible: your service still chooses which servers and tools to expose, and the credential the MCP server uses still decides what can be done. The MCPAdapter shown is from langchain 1.4; earlier code uses the langchain-mcp-adapters package.

LangChain: Model Context Protocol ↗LangGraph: persistence ↗Kubernetes: RBAC authorization ↗

THE POINT TO KEEPThe graph is one component. Identity, storage and the permissions of what it calls are still your service's job.

15 / IN A SERVICE

Watch a run while it happens

Three model calls and two tool runs can take a while. The caller can wait for the end, or be told as it goes.

OPTION 1 OF 4 · invoke

invoke returns once, when the run reaches END. Until then the caller has nothing to show.

The agent loop as a graphShown: START, model, tool calls?, END, tools.
What the caller receives
WhenWhat arrives
At ENDThe final state, with all 6 messages

Python

result = app.invoke(inputs, config)
OPTION 2 OF 4 · One event per node

Each time a node finishes, the caller gets that node's name and what it returned. Enough to show "checking pods" while the run continues.

The agent loop as a graphShown: START, model, tool calls?, END, tools. tool calls? to tools: yes. tools to model: results.
What the caller receives
WhenWhat arrives
model finishes{'model': {'messages': [tool call: list_pods]}}
tools finishes{'tools': {'messages': [the pod list]}}
model finishes{'model': {'messages': [tool call: get_pod_details]}}
tools finishes{'tools': {'messages': [the pod's details]}}
model finishes{'model': {'messages': [the answer]}}

Python

for update in app.stream(inputs, config, stream_mode="updates"):    print(update)     # {node name: what it returned}
OPTION 3 OF 4 · One event per token

While the model node is still running, the model's output arrives a piece at a time, with a note of which node produced it.

The agent loop as a graphShown: START, model, tool calls?, END, tools. model: still running.
What the caller receives during the last model call
WhenWhat arrives
Token 1('payments-api-7d9f', {'langgraph_node': 'model', …})
Token 2(' was', {'langgraph_node': 'model', …})
Token 3(' OOMKilled', {'langgraph_node': 'model', …})

Python

for chunk, meta in app.stream(inputs, config, stream_mode="messages"):    if meta["langgraph_node"] == "model":        print(chunk.content, end="")
OPTION 4 OF 4 · Both

Ask for a list of modes and each event is tagged with the mode it belongs to. One loop can drive a progress line and a growing reply.

The agent loop as a graphShown: START, model, tool calls?, END, tools. tool calls? to tools: yes. tools to model: results.
What the caller receives
EventWhat to do with it
("updates", {node: …})Move the progress line on
("messages", (chunk, meta))Append the text to the reply

Python

modes = ["updates", "messages"]for mode, data in app.stream(inputs, config, stream_mode=modes):    if mode == "updates":        show_progress(data)    else:        chunk, meta = data        append_text(chunk.content)
INSPECTOR

The same run, reported as it goes

stream runs the graph exactly as invoke does: the same nodes, the same checkpoints. The difference is what the caller is handed, and stream_mode picks it. "updates" is what each node returned. "messages" is model output a token at a time. "values" is the whole state after every step.

updates
One event per finished node: a progress line
messages
One event per model token: a reply that types itself
A list of modes
Each event arrives as (mode, data)
Checked by running itstream_mode="messages" → (AIMessageChunk, {"langgraph_node": "model", …})
WORTH KNOWING

Tokens are a preview. The node still returns one whole message, and that message is what gets saved. A run that pauses for approval ends its updates stream with an event named __interrupt__. Pass subgraphs=True to see events from inside a specialist. From LangGraph 1.1, version="v2" delivers every event as a dict with type, ns and data instead of these tuples.

LangGraph: streaming ↗

THE POINT TO KEEPUse invoke when only the result matters, and stream when someone is watching.

16 / IN A SERVICE

What each layer is responsible for

From one HTTP call to a graph with saved state, each layer took over one piece of plumbing. None of them took over authority.

OPTION 1 OF 2 · Everything together

The same two boxes and a question, now with state that is saved and a run that can stop and continue.

The agent loop as a graphShown: START, model, tool calls?, END, tools. model: saved. tools: saved.
Who does what
PartIts jobWhat it does not do
The modelProposes the next step: a tool call or an answerRun anything
LangChainStandard messages, models and toolsDecide the control flow
LangGraphControl flow, saved state, pausesDecide what a tool may do
Your applicationWhich tools exist, checks, scope, approvalsHand authority to the prompt
Kubernetes RBACWhat the credential may doKnow a model was involved
OPTION 2 OF 2 · One line each

Every idea on this page in one line, with the name to search for.

The agent loop as a graphShown: START, model, tool calls?, END, tools.
Cheat sheet
IdeaOne lineIn the API
StateOne shared record for the run, merged by LangGraphTypedDict, MessagesState
NodeA function: state in, changed keys outadd_node
EdgeWhat runs nextadd_edge
ReducerReplace or combine when an update is mergedoperator.add, add_messages
RoutingA function returns the next node's nameadd_conditional_edges
Agent loopmodel and tools, round until no tool callsToolNode, tools_condition, create_agent
Bounded loopA counter in state, with the runtime limit as a fuserecursion_limit
CheckpointsA snapshot per step, grouped by threadcompile(checkpointer=…), thread_id
ApprovalStop, save, resume with the answerinterrupt, Command(resume=…)
ConfidenceA label and a score in state; a low score goes to a personYour classifier
SpecialistsA small top level over compiled graphsadd_node(name, graph), Command(goto=…)
StreamingPer-node updates or per-token outputstream_mode
INSPECTOR

A loop you already understood, made explicit

A model call takes messages and returns one. A tool call is a request your code may run. An agent is that in a loop. LangGraph writes the loop down as nodes and edges over a state it can save, which is what makes restarts, approvals and your own routing possible.

Start with
The smallest graph that works: model, tools, one condition
Add state fields
When a decision needs them
Add checkpoints
When a run must outlive a process or wait for a person
The whole agentSTART → model → tools? → tools → model … → END
WORTH KNOWING

Saved state brings obligations a script never had: migrations when the state schema changes, retention for stored threads, and nodes that tolerate running twice. Skip persistence until you need what it gives.

LangGraph: Graph API ↗LangChain: agents ↗Kubernetes: RBAC authorization ↗

THE POINT TO KEEPLangChain standardises the pieces, LangGraph standardises the control flow, and your application still owns authority.