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.
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.
The loop appends every message to a list in memory. For a script you run and watch, this is all you need.
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))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.
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.
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.
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
messages = [] # in this process onlyYou 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.
THE POINT TO KEEPThe loop is easy. Keeping its state, pausing it and steering it are what need structure.
A box does some work and hands back what changed
Before the agent, the smallest possible example: one record, one function.
The run starts with a record that has one field filled in.
The record
- name
"Darshan"- greeting
not set
Python
class State(TypedDict): name: str greeting: str app.invoke({"name": "Darshan"})The function reads the record and returns only the field it wants to change.
The record
- name
"Darshan"reads- greeting
not set
Python
def greet(state: State): return {"greeting": f"Hello {state['name']}"}The returned field is merged into the record. Everything else is left as it was.
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'}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
{"greeting": "Hello Darshan"}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.
THE POINT TO KEEPA node reads the shared state and returns only what it changed.
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.
By default a new value replaces the old one. failure_type holds whatever was written last.
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 | Noneevidence is declared with a function that joins lists, so the second node's item is added to the first node's.
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 | NoneWhat inspect_logs returned
return {"evidence": ["last_reason=OOMKilled"]} # one item, not the whole listThe 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
evidence: Annotated[list[str], operator.add]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.
THE POINT TO KEEPUpdates are merged per key. A list that should grow needs a reducer that adds.
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?"
The question is put into the state and the run enters the model node.
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()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.
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()The tools node runs each requested tool and appends the results. A fixed arrow leads back to the model node.
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()The model sees the pod list and asks for details. Down and back once more.
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()This reply has no tool call, so the arrow out of the question goes to END and the run returns the state.
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()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
tools_condition(state) → 'tools' or '__end__'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.
THE POINT TO KEEPA loop is a conditional edge plus an edge that points back.
The while loop was this graph all along
One picture, three ways to write it down.
The if is the question. The bottom of the loop body is the arrow back.
| In the loop | In the graph |
|---|---|
| messages, a local list | State with a messages key |
| if not reply.tool_calls | A conditional edge out of model |
| return reply | The edge to END |
| The end of the loop body | The 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))Each line adds one box or one arrow of the picture.
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()The prebuilt agent builds a graph of this shape and compiles it for you.
The prebuilt agent
from langchain.agents import create_agent agent = create_agent(model, tools=TOOLS)list(agent.get_graph().nodes) # ['__start__', 'model', 'tools', '__end__']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
app.get_graph().draw_mermaid()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.
THE POINT TO KEEPLangGraph turns the implicit loop into an explicit graph over state.
State can hold more than messages
Give the troubleshooting run named fields, and each node touches only the ones it needs.
Reads which pod to look at. Writes what kind of failure it found and the first evidence.
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 | NoneThe 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}"]}Reads the same pod. Adds what the previous run's termination says.
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 | NoneReads the failure type and the evidence. Writes the diagnosis. It never needs the raw tool output.
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 | NoneNamed 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
class K8sAgentState(MessagesState): …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.
THE POINT TO KEEPPut what later steps depend on into named state, and each node can stay small.
Your code picks the path
Choose what inspect_pod found, and see which box runs next.
The pod has not been scheduled or started. The next evidence is with the scheduler: events about nodes, taints and unbound volumes.
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)The container starts and dies. The next evidence is the previous run: its logs, exit code and termination reason.
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)The image cannot be pulled. The next evidence is the image reference and the pull events.
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)A reason this graph has no dedicated path for still gets handled: a general set of checks.
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)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
assert route_problem({"failure_type": "Pending"}) == "inspect_scheduler"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.
THE POINT TO KEEPWhen you already know the right next step, say so in an edge instead of hoping the model picks it.
A cycle needs a way out that you wrote
Let the graph go round for more evidence when the first pass is not enough.
The first diagnosis is unsure, so the run gathers more and tries again. The second one is confident, and the run ends.
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")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".
State
- attempts
3reads- diagnosis
None
Python
def enough(state) -> str: if state["diagnosis"] or state["attempts"] >= 3: return END return "gather_more"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.
Python
app.invoke(state, {"recursion_limit": 12}) # GraphRecursionError: Recursion limit of 12 reached # without hitting a stop condition.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=…)
app.invoke(state, {"recursion_limit": 12})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.
THE POINT TO KEEPPut the exit condition in the state. Treat the runtime's step limit as a fuse.
After every step, the state is saved
Compile the agent graph with somewhere to save, and give the run a name.
The run is given an id. Before the model node runs, the state is saved with a note of what comes next.
Checkpoints · thread_id "session-123"
- #0
next: model · 1 messagejust saved
Python
app = graph.compile(checkpointer=checkpointer)config = {"configurable": {"thread_id": "session-123"}}app.invoke({"messages": [question]}, config)The model node finishes. The state, now two messages long, is saved again.
Checkpoints · thread_id "session-123"
- #0
next: model · 1 message - #1
next: tools · 2 messagesjust saved
And again after the tools node. Each save is a complete snapshot, and the earlier ones are kept.
Checkpoints · thread_id "session-123"
- #0
next: model · 1 message - #1
next: tools · 2 messages - #2
next: model · 3 messagesjust saved
Three model calls and two tool runs have left six snapshots. The last one has nothing left to run.
Checkpoints · thread_id "session-123"
- #0
next: model · 1 message - #1
next: tools · 2 messages - #2
next: model · 3 messages - #3
next: tools · 4 messages - #4
next: model · 5 messages - #5
next: 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 inputCheckpoints, 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
app.get_state(config).values["messages"]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.
THE POINT TO KEEPA checkpointer moves the run's state out of your process and into a store, one snapshot per step.
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.
Two model calls and one tool run are done. The tools node has started on get_pod_details.
Checkpoints · thread_id "session-123"
- #0
next: model · 1 message - #1
next: tools · 2 messages - #2
next: model · 3 messages - #3
next: tools · 4 messagesjust saved
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.
Checkpoints · thread_id "session-123"
- #0
next: model · 1 message - #1
next: tools · 2 messages - #2
next: model · 3 messages - #3
next: tools · 4 messages
The newest checkpoint says: next is tools, with 4 messages.
Any process with the same graph and the same store can continue. It passes no new input, only the thread id.
Checkpoints · thread_id "session-123"
- #0
next: model · 1 message - #1
next: tools · 2 messages - #2
next: model · 3 messages - #3
next: tools · 4 messagesloaded
Python
config = {"configurable": {"thread_id": "session-123"}}app.invoke(None, config) # None: continue, don't restartThe tools node starts over from its first line. The two model calls before it are not repeated.
Checkpoints · thread_id "session-123"
- #0
next: model · 1 message - #1
next: tools · 2 messages - #2
next: model · 3 messages - #3
next: tools · 4 messages - #4
next: model · 5 messagesjust saved
The model sees the details and answers. The user gets one reply, as if nothing happened.
Checkpoints · thread_id "session-123"
- #0
next: model · 1 message - #1
next: tools · 2 messages - #2
next: model · 3 messages - #3
next: tools · 4 messages - #4
next: model · 5 messages - #5
next: nothing · 6 messagesjust saved
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
resumed: tools, model (earlier model calls: 0)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.
THE POINT TO KEEPDurable state means a crash costs you one step. It also means every step may run twice.
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.
The approval node asks its question and the run stops. The state is saved; the process is free to do anything else, or exit.
Checkpoints · thread_id "session-123"
- #7
next: 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 resumeWhat the caller sees
result = app.invoke(inputs, config)result["__interrupt__"][0].value # {'question': 'Apply this change?', # 'action': 'set_memory_limit(payments-api, 512Mi)'}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.
Checkpoints · thread_id "session-123"
- #7
next: approval · waiting on a personloaded - #8
next: apply_fix · approved=Truejust saved
Resuming
from langgraph.types import Command app.invoke(Command(resume=True), config) # same thread_idSomeone 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.
Checkpoints · thread_id "session-123"
- #7
next: approval · waiting on a personloaded - #8
next: 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)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
app.get_state(config).next → ('approval',)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.
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.
Triage labels the question as a workloads problem and is nearly certain. The score clears the bar, so the label picks the branch.
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"])A different question, a different label, and still a high score. Same edge, other branch.
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"])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.
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"])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
add_conditional_edges("triage", next_step, ["workloads", "network", "ask_person"])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.
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.
The supervisor is the triage step under a new name. It picks one agent, and only that agent runs.
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)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.
State
- messages
6 itemswrites- newest
assistant: payments-api-7d9f was OOMKilled…writes- route
"workloads"
A storage agent is one more row in a table. The loop that builds the graph does not change.
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)A node can return a Command that updates the state and names the next node together, so the supervisor needs no separate routing function.
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})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
supervisor → workloads: model, tools, model, tools, model → ENDA 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.
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.
The tool functions run inside the service and call Kubernetes with the service's own credential.
The same tools, served by a separate process over the Model Context Protocol. The graph is unchanged: a function call became a network call.
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)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
config = {"configurable": {"thread_id": session.id}}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.
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.
invoke returns once, when the run reaches END. Until then the caller has nothing to show.
| When | What arrives |
|---|---|
| At END | The final state, with all 6 messages |
Python
result = app.invoke(inputs, config)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.
| When | What 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}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.
| When | What 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="")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.
| Event | What 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)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)
stream_mode="messages" → (AIMessageChunk, {"langgraph_node": "model", …})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.
THE POINT TO KEEPUse invoke when only the result matters, and stream when someone is watching.
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.
The same two boxes and a question, now with state that is saved and a run that can stop and continue.
| Part | Its job | What it does not do |
|---|---|---|
| The model | Proposes the next step: a tool call or an answer | Run anything |
| LangChain | Standard messages, models and tools | Decide the control flow |
| LangGraph | Control flow, saved state, pauses | Decide what a tool may do |
| Your application | Which tools exist, checks, scope, approvals | Hand authority to the prompt |
| Kubernetes RBAC | What the credential may do | Know a model was involved |
Every idea on this page in one line, with the name to search for.
| Idea | One line | In the API |
|---|---|---|
| State | One shared record for the run, merged by LangGraph | TypedDict, MessagesState |
| Node | A function: state in, changed keys out | add_node |
| Edge | What runs next | add_edge |
| Reducer | Replace or combine when an update is merged | operator.add, add_messages |
| Routing | A function returns the next node's name | add_conditional_edges |
| Agent loop | model and tools, round until no tool calls | ToolNode, tools_condition, create_agent |
| Bounded loop | A counter in state, with the runtime limit as a fuse | recursion_limit |
| Checkpoints | A snapshot per step, grouped by thread | compile(checkpointer=…), thread_id |
| Approval | Stop, save, resume with the answer | interrupt, Command(resume=…) |
| Confidence | A label and a score in state; a low score goes to a person | Your classifier |
| Specialists | A small top level over compiled graphs | add_node(name, graph), Command(goto=…) |
| Streaming | Per-node updates or per-token output | stream_mode |
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
START → model → tools? → tools → model … → ENDSaved 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.