КОД</>БЕЗ МЕЖ
← All articles

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: str

Notice 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 nxt

I 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:

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:

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

  1. 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.
  2. 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.
  3. 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.
  4. No exit condition. If the supervisor never returns FINISH, the star spins forever. There must always be a path to END.

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.

Message @tribeofdanel →