LangGraph: a multi-agent system by example
In the LangGraph intro I promised to show multi-agent handoff on a live example — here it is. We will build a small multi-agent system: a supervisor takes a request and decides who handles it — a researcher agent or a writer agent. All three share one state. The code comes in stages, in short chunks with prose between them — so you see not "framework magic" but a plain graph you could have written yourself.
If you have not read the basics yet — what nodes, edges, and state are — start with the LangGraph in production intro → Here I do not repeat the fundamentals; I go straight into building a system of several agents.
What the "supervisor" pattern is
The simplest multi-agent architecture is not "agents chatting with each other in a circle". It is one router node that looks at the state and decides which specialist runs next. The specialists do not know about each other — they only know about the shared state and that, once they are done, control returns to the supervisor.
Why this way and not "each agent decides who to hand off to"? Because decentralized handoff quickly turns into the same tangle of if statements we were escaping. The supervisor is one decision point. One node, one prompt, one place in the trace where you ask "why did it go here and not there". For a system of 2-5 agents this is almost always the right start.
Stage 1: the state that flows through all of them
The first and most important decision is the state. It is what separates a multi-agent system from a pile of separate LLM calls. Instead of passing strings between agents ("here is some text, do something with it"), we describe a shared structure that every agent reads from and adds to.
from typing import TypedDict, Annotated, Literal
from langgraph.graph import add_messages
from langchain_core.messages import BaseMessage
class AgentState(TypedDict):
# conversation history; add_messages appends instead of overwriting
messages: Annotated[list[BaseMessage], add_messages]
# where the supervisor decided to send the next step
next: str
# working artifacts from the specialists
research: str
draft: strNotice Annotated[list, add_messages]. This is not cosmetic. Without a reducer, every node that returns messages would overwrite the history. With add_messages, LangGraph knows to append new messages to the existing ones. This is exactly why state beats passing strings — it has merge rules, not just "last write wins".
The research and draft fields are working artifacts. The researcher writes into research; the writer reads it and writes into draft. Nobody hands anything to anybody by hand — they all just work on one shared board.
Stage 2: the specialist nodes
Now the agents themselves. Each is a plain function: it takes state, does its job, returns an update to the state (not the whole state, just what changed). Inside is the same LLM call you would make on the vanilla SDK.
def researcher(state: AgentState) -> dict:
topic = state["messages"][-1].content
notes = llm.invoke(
f"Collect 4-5 key facts about: {topic}. "
"Facts only, no preamble."
)
return {
"research": notes.content,
"messages": [notes],
}
def writer(state: AgentState) -> dict:
text = llm.invoke(
"Write a short paragraph based on these notes:\n"
+ state["research"]
)
return {
"draft": text.content,
"messages": [text],
}That is all the "specialization" there is. The researcher knows nothing about the writer. The writer does not know where research came from — it just reads a state field. Each node returns a dict with only the keys it updates, and LangGraph merges that into the rest of the state using the rules from AgentState.
Stage 3: the supervisor as a router
The heart of the system. The supervisor looks at the state and returns a decision — a string with the name of the next node. That decision can be deterministic (as here) or come from an LLM call with structured output when the logic is more involved.
def supervisor(state: AgentState) -> dict:
# simple rule: research first, then write, then stop
if not state.get("research"):
return {"next": "researcher"}
if not state.get("draft"):
return {"next": "writer"}
return {"next": "FINISH"}
def route(state: AgentState) -> Literal["researcher", "writer", "__end__"]:
nxt = state["next"]
if nxt == "FINISH":
return "__end__"
return nxtI deliberately split supervisor (makes the decision, writes it into state) from route (reads the decision and returns the edge label). This way the supervisor's decision stays in the state — you can see it in the trace and rewind. If the logic were richer, the body of supervisor would simply call the LLM: "here is the state, what next — researcher, writer, or FINISH?". The graph scaffold itself does not change.
Stage 4: assembling the graph
Now we wire it all up. The key detail: after every specialist, an edge leads back to the supervisor. That is what makes it a supervisor — it gets control again and again until it says FINISH.
from langgraph.graph import StateGraph, START, END
graph = StateGraph(AgentState)
graph.add_node("supervisor", supervisor)
graph.add_node("researcher", researcher)
graph.add_node("writer", writer)
graph.add_edge(START, "supervisor")
# supervisor -> one of the specialists, or the end
graph.add_conditional_edges("supervisor", route, {
"researcher": "researcher",
"writer": "writer",
"__end__": END,
})
# every specialist always returns to the supervisor
graph.add_edge("researcher", "supervisor")
graph.add_edge("writer", "supervisor")
app = graph.compile()Look at the shape of the graph: it is a star. The supervisor at the center, specialists on the spokes, control always returning to the center. Adding a third agent (say, an editor) is one add_node, one add_edge back to the supervisor, and one line in the routes dict. No rewriting of what already works.
Stage 5: running it
We run it like any StateGraph — by passing the initial state.
from langchain_core.messages import HumanMessage
result = app.invoke({
"messages": [HumanMessage(content="LangGraph for multi-agent systems")],
"next": "",
"research": "",
"draft": "",
})
print(result["draft"])One invoke, and a whole cycle ran inside: supervisor → researcher → supervisor → writer → supervisor → end. You wrote no while loop and no step counter. All the "who runs next" logic lives in the graph, not smeared through a loop body. That is the core difference from how this would look on the vanilla SDK.
Why state, not passing strings
The most common beginner mistake is to build a multi-agent system as a chain: agent A returns a string, you hand it to agent B by hand, B returns another string. It works in a demo and breaks on the third agent. Here is what shared state gives you instead:
- Any agent sees the full picture. The writer needs both the notes and the original request — the state has both. With strings you would carry context by hand and lose some of it.
- Merge rules instead of "last write wins". A reducer like
add_messagesaccumulates history. Artifact fields update independently and do not clobber each other. - State serializes. Since it is a plain structure, you can save it, restore it, inspect it in the trace. Strings flying between functions cannot be rewound like that.
Where checkpoints fit in
So far the graph lives in memory: invoke finishes and the state is gone. For a real multi-agent system that is not enough, especially if there is a human pause between agents or the work runs for a while. This is where a checkpointer comes in — and it is nearly free in terms of code.
from langgraph.checkpoint.postgres import PostgresSaver
checkpointer = PostgresSaver.from_conn_string(POSTGRES_URL)
app = graph.compile(checkpointer=checkpointer)
# each run is tied to a thread_id
config = {"configurable": {"thread_id": "user-42"}}
app.invoke({"messages": [HumanMessage(content="...")],
"next": "", "research": "", "draft": ""}, config)One argument at compile, and the graph writes state after every node. Now the system survives a server restart, you can pause between the researcher and the writer for a human approval and then resume from the same point, and the thread_id isolates each user's or session's state. Checkpoints are exactly what turn a "nice demo" into something you can keep in production.
When multi-agent is overkill
Honestly, as always: most tasks do not need several agents. Splitting into a researcher and a writer makes sense when the roles are genuinely different — different prompts, different tools, different models per role. If one prompt does it all, then "multi-agent" is three LLM calls where one would do, plus added latency and three times the tokens.
Stay on a single agent when:
- The roles are not really different — it is one and the same prompt cut up artificially.
- There are no role-specific tools or models. Specialization with no difference in tools is an illusion of specialization.
- Latency matters. Every hop back to the supervisor is one more round trip to the model.
I tell clients this straight: one agent with a few tools first. Split into several only once a single prompt starts carrying too many incompatible roles. More on when a system genuinely becomes multi-agent is in the multi-agent systems article →
Common mistakes in multi-agent graphs
- Specialists knowing about each other. The moment agent A decides that agent B runs next, you have lost the single decision point. Let only the supervisor decide.
- State without reducers. If two nodes write to the same field with no merge rule, one clobbers the other. For message histories, always
add_messages. - A bloated state. Do not dump everything into state "just in case". State should be minimal and explicit, or the graph reads as badly as a bloated prompt.
- No exit condition. If the supervisor never returns
FINISH, the star spins forever. There must always be a path toEND.
What this looks like in a real project
In production this same scaffold grows details: the supervisor calls an LLM with structured output to pick the route by meaning rather than empty fields; behind the researcher I put a model with search and tool-calling, behind the writer a stronger one at prose; a Postgres checkpointer holds state and enables human pauses. The star stays the same — only the filling of the nodes grows.
I build these multi-agent systems end to end — from graph design to deploy, checkpoints, and monitoring — as part of multi-agent system development →
Wrap-up
A multi-agent system in LangGraph is not magic — it is a star with a supervisor at the center and specialists on the spokes, all sharing one state. State with merge rules replaces manual string passing, a checkpointer adds persistence with one argument, and all the "who runs next" logic lives in the graph, not in a loop. But remember the main thing: reach for several agents when the roles are genuinely different — otherwise one agent with a set of tools does the same job for less.
Not sure whether your task is ready for several agents? Message me — 30 minutes on a call and I will tell you honestly where you have one agent and where you truly have a graph.