Multi-agent in LangGraph: supervisors, handoffs and routers

19 min read
On this page

This post is about multi-agent systems.

So far every post built one agent. That works until the agent has too many jobs. Twenty tools and a prompt that tries to cover everything, and it starts picking the wrong tool and forgetting instructions.

The fix is the same one you’d use with people. Split the work. Give each specialist a small job and a few tools. Put a supervisor in front to hand the work out.

   ONE AGENT                          A TEAM
   twenty tools, one long prompt      small jobs, small prompts

   ┌──────────────────┐               ┌─ supervisor ─┐
   │ research, write, │               │  hands out   │
   │ count, cite,     │               └──┬────────┬──┘
   │ translate, ...   │                  ▼        ▼
   └──────────────────┘             researcher   writer
     picks wrong tool                 1 tool      1 tool

I’ll go through nine small lessons. We start with two specialists, build the simplest supervisor, then build it as a graph you can see. After that: subgraphs, handoffs, and routers that ask several specialists at once.

This uses LangChain 1.x and LangGraph 1.x, current in October 2026. The newer docs also show a zod-based StateSchema for state. It’s the same idea as Annotation, which the earlier posts used, so I’ll stick with Annotation here.

The words you’ll see

  • Specialist (or subagent) — a normal agent with one job and few tools.
  • Supervisor — an agent or node that decides which specialist acts next.
  • Agent as a tool — wrapping a whole agent in tool() so another agent can call it.
  • Subgraph — a compiled graph used as a single node in a bigger graph.
  • Handoff — one agent passing the conversation to another, no boss.
  • Command — the LangGraph object that says “go to this node, and update this state.”
  • Router — a step that splits a question and sends parts to specialists.
  • Send — the LangGraph way to start several branches at once.

Setup

mkdir team-demo && cd team-demo
npm init -y
npm pkg set type=module
npm install langchain @langchain/langgraph @langchain/openrouter zod

You need Node.js 20.6 or newer and an OpenRouter API key from openrouter.ai.

These lines are shared by the lessons below. Two fake tools stand in for a real search API and a real check.

import { createAgent, tool } from "langchain";
import { ChatOpenRouter } from "@langchain/openrouter";
import * as z from "zod";

// Paste your key here. Fine for a script on your own machine.
process.env.OPENROUTER_API_KEY = "sk-or-v1-paste-your-key-here";

const MODEL = new ChatOpenRouter({
  model: "openai/gpt-4o-mini",
  temperature: 0,
});

const ask = (agent, text) =>
  agent.invoke({ messages: [{ role: "user", content: text }] });
const lastText = (result) => result.messages.at(-1).content;

// A fake search. A real one would call an API.
const searchWeb = tool(
  async ({ topic }) =>
    `Facts about ${topic}: it is open source, it builds agents as graphs, ` +
    `and it is maintained by the LangChain team.`,
  {
    name: "search_web",
    description: "Search the web for facts about a topic",
    schema: z.object({ topic: z.string() }),
  },
);

const countWords = tool(
  async ({ text }) => `${text.trim().split(/\s+/).length} words`,
  {
    name: "count_words",
    description: "Count the words in a piece of text",
    schema: z.object({ text: z.string() }),
  },
);

Now the lessons.

Lesson 1: one agent, too many hats

Here’s one agent asked to do everything.

async function lesson1() {
  const agent = createAgent({
    model: MODEL,
    tools: [searchWeb, countWords],
    systemPrompt:
      "You research topics, write paragraphs, count words, check tone, " +
      "format citations and translate. Do whatever the user asks.",
  });

  const result = await ask(agent, "Research LangGraph and write a 2-sentence summary.");
  console.log(lastText(result));
}

With two tools this still works. That’s honest. The trouble starts as the list grows:

  • The prompt gets long, and the model starts skipping parts of it.
  • With many similar tools, it picks the wrong one more often.
  • You can’t test “the writing part” on its own. It’s all one blob.

Specialists fix all three. Each one has a short prompt, a couple of tools, and can be run and tested alone.

Lesson 2: two specialists

Two normal agents. Nothing new here, just small jobs.

   RESEARCHER                        WRITER
   tool:   search_web                tool:   count_words
   prompt: find facts, list them     prompt: turn facts into a paragraph
const researcher = createAgent({
  model: MODEL,
  tools: [searchWeb],
  systemPrompt:
    "You are a researcher. Use search_web, then list the facts you found " +
    "as short bullet points. Nothing else.",
});

const writer = createAgent({
  model: MODEL,
  tools: [countWords],
  systemPrompt:
    "You are a writer. Turn the facts you are given into one short " +
    "paragraph. Use count_words to check it is under 60 words.",
});

async function lesson2() {
  console.log(lastText(await ask(researcher, "Find facts about LangGraph.")));
  console.log(lastText(await ask(writer, "Facts: LangGraph is open source. It builds agents as graphs.")));
}

Each one is easy to run alone, and easy to fix alone. That’s the whole reason to split.

Lesson 3: the simplest supervisor

Now put a supervisor in front. The trick is small: wrap each specialist in a tool. The tool’s function just calls the agent and returns its last message.

Then the supervisor is an ordinary agent. Its tools happen to be other agents.

   you: "Write a short paragraph about LangGraph."
        │
        ▼
   SUPERVISOR (an agent)
     tools: research, write        ← each tool is another agent
        │
        ├─► research("LangGraph") ──► RESEARCHER ──► facts
        │
        ├─► write(facts)          ──► WRITER ──────► paragraph
        │
        ▼
   the paragraph
const callResearcher = tool(
  async ({ query }) => {
    const result = await researcher.invoke({
      messages: [{ role: "user", content: query }],
    });
    return result.messages.at(-1)?.content;
  },
  {
    name: "research",
    description: "Research a topic and return the facts found",
    schema: z.object({ query: z.string() }),
  },
);

const callWriter = tool(
  async ({ facts }) => {
    const result = await writer.invoke({
      messages: [{ role: "user", content: facts }],
    });
    return result.messages.at(-1)?.content;
  },
  {
    name: "write",
    description: "Turn a list of facts into a short paragraph",
    schema: z.object({ facts: z.string() }),
  },
);

const supervisor = createAgent({
  model: MODEL,
  tools: [callResearcher, callWriter],
  systemPrompt:
    "You coordinate two specialists. For a writing request: call research " +
    "first, then pass the facts to write, then return the paragraph.",
});

async function lesson3() {
  const result = await ask(supervisor, "Write a short paragraph about LangGraph.");
  console.log(lastText(result));
}

This is the pattern the LangChain docs call subagents. It’s the one to reach for first, because there’s nothing new to learn. It’s the tool calling from the tool-calling post, where the tools are agents.

Lesson 4: what actually happens

Let’s slow it down. Print what the supervisor asked for.

async function lesson4() {
  const result = await ask(supervisor, "Write a short paragraph about LangGraph.");

  for (const message of result.messages) {
    if (message.tool_calls?.length) {
      console.log("supervisor called:", message.tool_calls.map((call) => call.name));
    }
  }
  console.log(lastText(result));
}
   supervisor called: [ 'research' ]
   supervisor called: [ 'write' ]
   LangGraph is an open-source library for building agents as graphs ...

Each specialist runs its own full loop inside the tool call. The supervisor never sees that loop. It only sees the final text.

   supervisor loop
   ┌─────────────────────────────────────────────────┐
   │  model ─► research ──► ┌─ researcher loop ──┐    │
   │                        │  model ─► search   │    │
   │                        │  model ─► facts    │    │
   │                        └────────────────────┘    │
   │  model ─► write ─────► ┌─ writer loop ──────┐    │
   │                        │  model ─► count    │    │
   │                        │  model ─► text     │    │
   │                        └────────────────────┘    │
   │  model ─► answer                                 │
   └─────────────────────────────────────────────────┘

That’s the real benefit. The researcher’s tool chatter stays inside the researcher. The supervisor’s context stays short.

Lesson 5: the supervisor as a graph you can see

The tool trick hides the routing inside the model. Sometimes you want to see it, and control it. So let’s build the same team as a LangGraph graph.

If graphs are new to you, the LangGraph post explains state, nodes and edges. Here’s the shape:

   START
     │
     ▼
   supervisor ◄──────────────┐    picks "next"
     │                       │
     ├─ researcher ──────────┤
     │                       │
     ├─ writer ──────────────┘
     │
     └─ FINISH ──► END

Three new things:

  • The state gets a next field: who works next.
  • The supervisor node asks the model for a structured answer, so next is always one of three exact words.
  • A conditional edge reads next and routes. Workers always go back to the supervisor.
import { AIMessage } from "langchain";
import {
  StateGraph,
  START,
  END,
  Annotation,
  MessagesAnnotation,
} from "@langchain/langgraph";

const TeamState = Annotation.Root({
  ...MessagesAnnotation.spec,
  next: Annotation(), // who works next
});

// Structured output: the model must answer with exactly one of these.
const Decision = z.object({
  next: z.enum(["researcher", "writer", "FINISH"]).describe("Who acts next"),
});
const pickNext = MODEL.withStructuredOutput(Decision);

async function supervisorNode(state) {
  const { next } = await pickNext.invoke([
    {
      role: "system",
      content:
        "You manage a researcher and a writer. Research first, then write. " +
        "Reply FINISH once a paragraph has been written.",
    },
    ...state.messages,
  ]);
  console.log(`supervisor → ${next}`);
  return { next };
}

// A worker node runs its agent and adds only the final text to the team's history.
const runSpecialist = (name, agent) => async (state) => {
  const result = await agent.invoke({ messages: state.messages });
  return { messages: [new AIMessage({ content: lastText(result), name })] };
};

const team = new StateGraph(TeamState)
  .addNode("supervisor", supervisorNode)
  .addNode("researcher", runSpecialist("researcher", researcher))
  .addNode("writer", runSpecialist("writer", writer))
  .addEdge(START, "supervisor")
  .addConditionalEdges(
    "supervisor",
    (state) => (state.next === "FINISH" ? END : state.next),
    ["researcher", "writer", END],
  )
  .addEdge("researcher", "supervisor")
  .addEdge("writer", "supervisor")
  .compile();

async function lesson5() {
  const result = await team.invoke({
    messages: [{ role: "user", content: "Write a short paragraph about LangGraph." }],
  });
  console.log(lastText(result));
}
   supervisor → researcher
   supervisor → writer
   supervisor → FINISH

withStructuredOutput is the piece that makes this safe. Without it the model might reply “the writer, I think” and your router would have nothing to match. With it, next is one of three words, every time.

One safety net worth knowing: LangGraph stops a run after 25 steps by default. So a confused supervisor that keeps saying “researcher” can’t loop forever. It throws, and you fix the prompt.

Lesson 6: a specialist as a subgraph

A compiled graph can be a node in another graph. That’s a subgraph.

It matters here because every agent from createAgent is already a compiled graph. And you can write a specialist as a small graph of your own, then drop it into the team as one box.

   parent graph
   ┌─────────────────────────────────────────┐
   │  START ─► researcher ─► END             │
   │           ┌───────────────────────┐     │
   │           │  search ─► summarise  │     │   ← a whole graph, as one node
   │           └───────────────────────┘     │
   └─────────────────────────────────────────┘
// A researcher as its own graph: two fixed steps, no model deciding.
const researchGraph = new StateGraph(MessagesAnnotation)
  .addNode("search", async (state) => {
    const topic = state.messages.at(-1).content;
    const facts = await searchWeb.invoke({ topic });
    return { messages: [new AIMessage({ content: facts, name: "search" })] };
  })
  .addNode("summarise", async (state) => {
    const reply = await MODEL.invoke([
      { role: "system", content: "Turn these facts into three bullet points." },
      ...state.messages,
    ]);
    return { messages: [reply] };
  })
  .addEdge(START, "search")
  .addEdge("search", "summarise")
  .addEdge("summarise", END)
  .compile();

// The parent uses it as a single node.
const parent = new StateGraph(MessagesAnnotation)
  .addNode("researcher", researchGraph) // a compiled graph, passed straight in
  .addEdge(START, "researcher")
  .addEdge("researcher", END)
  .compile();

async function lesson6() {
  const result = await parent.invoke({
    messages: [{ role: "user", content: "LangGraph" }],
  });
  console.log(lastText(result));
}

This works directly because both graphs share the messages key. When the parent’s state is different, you wrap the subgraph in a small function that maps state in and out. That’s exactly what runSpecialist did in lesson 5.

Lesson 7: handoffs, no boss

A supervisor is one way. Another is to have no boss at all. Each agent decides for itself when to pass the conversation to someone else.

That pass is called a handoff, and it’s just a tool. When the researcher calls transfer_to_writer, the conversation moves to the writer.

   no boss. the agents pass the work along.

   START ─► researcher ──transfer_to_writer──► writer ─► END
                ▲                                 │
                └───── transfer_to_researcher ────┘
                       (if it needs more facts)

The handoff tool returns a Command. A Command says two things at once: update this state, and go to that node.

import { ToolMessage } from "langchain";
import { Command } from "@langchain/langgraph";

const HandoffState = Annotation.Root({
  ...MessagesAnnotation.spec,
  activeAgent: Annotation(), // who has the conversation right now
});

// A handoff is a tool. Calling it moves the conversation to another agent.
const makeHandoff = (target) =>
  tool(
    async (_input, runtime) =>
      new Command({
        goto: target,
        update: {
          activeAgent: target,
          messages: [
            runtime.state.messages.at(-1), // the AI message that asked to hand off
            new ToolMessage({
              content: `Handing off to ${target}.`,
              tool_call_id: runtime.toolCallId,
            }),
          ].filter(Boolean),
        },
        graph: Command.PARENT, // act on the outer graph, not inside this agent
      }),
    {
      name: `transfer_to_${target}`,
      description: `Hand the conversation to the ${target}`,
      schema: z.object({}),
    },
  );

const researcherPeer = createAgent({
  model: MODEL,
  tools: [searchWeb, makeHandoff("writer")],
  systemPrompt:
    "You are a researcher. Find facts with search_web, list them, then " +
    "call transfer_to_writer.",
});

const writerPeer = createAgent({
  model: MODEL,
  tools: [countWords, makeHandoff("researcher")],
  systemPrompt:
    "You are a writer. Write one short paragraph from the facts in the " +
    "conversation. If there are no facts yet, call transfer_to_researcher.",
});

// After an agent speaks: done if it answered plainly, else go to whoever is active.
function afterAgent(state) {
  const last = state.messages.at(-1);
  const answeredPlainly = last.getType() === "ai" && !last.tool_calls?.length;
  return answeredPlainly ? END : state.activeAgent;
}

const swarm = new StateGraph(HandoffState)
  .addNode("researcher", async (state) => researcherPeer.invoke(state))
  .addNode("writer", async (state) => writerPeer.invoke(state))
  .addConditionalEdges(START, (state) => state.activeAgent ?? "researcher", ["researcher", "writer"])
  .addConditionalEdges("researcher", afterAgent, ["researcher", "writer", END])
  .addConditionalEdges("writer", afterAgent, ["researcher", "writer", END])
  .compile();

async function lesson7() {
  const result = await swarm.invoke({
    messages: [{ role: "user", content: "Write a short paragraph about LangGraph." }],
  });
  console.log(lastText(result));
}

Three details that matter:

  • graph: Command.PARENT. The tool runs inside the researcher’s own graph. This tells it to act on the outer graph instead, where the writer lives.
  • The update carries the AI message that made the tool call, plus a ToolMessage answering it. Every tool call needs its answer in the history, or the next model call errors.
  • activeAgent is the baton. The edges just read it.

Handoffs suit conversations, like a support bot that passes you from sales to billing. For a fixed pipeline, a supervisor is simpler to debug.

The docs also show a lighter version: one agent whose middleware swaps the prompt and tools based on a state key. That’s the middleware post idea, and for many cases it’s enough.

Lesson 8: routers, ask several at once

The supervisor works one specialist at a time. A router splits the question and asks several specialists at the same time, then combines the answers.

Two new pieces:

  • A classify step uses structured output to decide which specialists to ask, each with its own sub-question.
  • Send starts one branch per task, all in parallel. It’s the dynamic version of the fan-out from the LangGraph post.
   question
      │
      ▼
   classify ── decides: weather + stocks (skip news)
      │
   ┌──┴────────┐           Send() starts one branch per task,
   ▼           ▼           all at the same time
 weather     stocks
   │           │
   └─────┬─────┘
         ▼
     synthesize ──► one answer
import { Send } from "@langchain/langgraph";

const RouterState = Annotation.Root({
  question: Annotation(),
  plan: Annotation(), // which specialists to ask
  results: Annotation({ reducer: (a, b) => a.concat(b), default: () => [] }),
  answer: Annotation(),
});

const Plan = z.object({
  tasks: z
    .array(
      z.object({
        specialist: z.enum(["weather", "news", "stocks"]),
        question: z.string(),
      }),
    )
    .describe("Which specialists to ask, each with its own sub-question"),
});
const planner = MODEL.withStructuredOutput(Plan);

// Each specialist gets only its own sub-question, not the whole state.
const specialist = (name) => async (input) => ({
  results: [{ specialist: name, text: `${name} says: all good for "${input.question}"` }],
});

const routerGraph = new StateGraph(RouterState)
  .addNode("classify", async (state) => {
    const { tasks } = await planner.invoke([{ role: "user", content: state.question }]);
    console.log("asking:", tasks.map((task) => task.specialist));
    return { plan: tasks };
  })
  .addNode("weather", specialist("weather"))
  .addNode("news", specialist("news"))
  .addNode("stocks", specialist("stocks"))
  .addNode("synthesize", async (state) => {
    const notes = state.results.map((result) => `- ${result.text}`).join("\n");
    const reply = await MODEL.invoke([
      { role: "system", content: "Combine these notes into one short answer." },
      { role: "user", content: notes },
    ]);
    return { answer: reply.content };
  })
  .addEdge(START, "classify")
  .addConditionalEdges(
    "classify",
    (state) => state.plan.map((task) => new Send(task.specialist, { question: task.question })),
    ["weather", "news", "stocks"],
  )
  .addEdge("weather", "synthesize")
  .addEdge("news", "synthesize")
  .addEdge("stocks", "synthesize")
  .addEdge("synthesize", END)
  .compile();

async function lesson8() {
  const result = await routerGraph.invoke({
    question: "Is it a good day to buy shares and go for a walk in Paris?",
  });
  console.log(result.answer);
}
   asking: [ 'stocks', 'weather' ]
   Yes on both counts: the markets look fine and Paris is good for a walk.

Send(node, payload) is the key line. It says “run this node, with this input.” Return a list of them and they all run together. synthesize waits for every branch, because results has a reducer that collects them.

Which pattern, when

Three patterns. Plain rules of thumb:

   SUPERVISOR   one boss, workers report back     a fixed pipeline, easy to debug
   HANDOFFS     no boss, peers pass the baton      a conversation that changes hands
   ROUTER       split it, ask in parallel          independent sub-questions
  • Start with the supervisor. Most jobs are a pipeline, and it’s the easiest to read and to test.
  • Use handoffs when the user is talking and the right agent changes mid-conversation.
  • Use a router when the parts of a question don’t depend on each other and you want them fast.

They combine. A supervisor’s worker can be a router. A router’s specialist can be a subgraph.

Lesson 9: the whole thing, one file

The supervisor team from lesson 5, complete. Save it as team.mjs.

   you ─► supervisor ─► researcher ─► supervisor ─► writer ─► supervisor ─► FINISH
                              (facts)                  (paragraph)
// team.mjs
import { createAgent, tool, AIMessage } from "langchain";
import {
  StateGraph,
  START,
  END,
  Annotation,
  MessagesAnnotation,
} from "@langchain/langgraph";
import { ChatOpenRouter } from "@langchain/openrouter";
import * as z from "zod";

// Paste your key here. Fine for a script on your own machine.
process.env.OPENROUTER_API_KEY = "sk-or-v1-paste-your-key-here";

const MODEL = new ChatOpenRouter({
  model: "openai/gpt-4o-mini",
  temperature: 0,
});
const lastText = (result) => result.messages.at(-1).content;

// ---- tools ----
const searchWeb = tool(
  async ({ topic }) =>
    `Facts about ${topic}: it is open source, it builds agents as graphs, ` +
    `and it is maintained by the LangChain team.`,
  {
    name: "search_web",
    description: "Search the web for facts about a topic",
    schema: z.object({ topic: z.string() }),
  },
);

const countWords = tool(
  async ({ text }) => `${text.trim().split(/\s+/).length} words`,
  {
    name: "count_words",
    description: "Count the words in a piece of text",
    schema: z.object({ text: z.string() }),
  },
);

// ---- specialists: ordinary agents with one job each ----
const researcher = createAgent({
  model: MODEL,
  tools: [searchWeb],
  systemPrompt:
    "You are a researcher. Use search_web, then list the facts you found " +
    "as short bullet points. Nothing else.",
});

const writer = createAgent({
  model: MODEL,
  tools: [countWords],
  systemPrompt:
    "You are a writer. Turn the facts you are given into one short " +
    "paragraph. Use count_words to check it is under 60 words.",
});

// ---- the team graph ----
const TeamState = Annotation.Root({
  ...MessagesAnnotation.spec,
  next: Annotation(),
});

const Decision = z.object({
  next: z.enum(["researcher", "writer", "FINISH"]).describe("Who acts next"),
});
const pickNext = MODEL.withStructuredOutput(Decision);

async function supervisorNode(state) {
  const { next } = await pickNext.invoke([
    {
      role: "system",
      content:
        "You manage a researcher and a writer. Research first, then write. " +
        "Reply FINISH once a paragraph has been written.",
    },
    ...state.messages,
  ]);
  console.log(`supervisor → ${next}`);
  return { next };
}

const runSpecialist = (name, agent) => async (state) => {
  const result = await agent.invoke({ messages: state.messages });
  return { messages: [new AIMessage({ content: lastText(result), name })] };
};

const team = new StateGraph(TeamState)
  .addNode("supervisor", supervisorNode)
  .addNode("researcher", runSpecialist("researcher", researcher))
  .addNode("writer", runSpecialist("writer", writer))
  .addEdge(START, "supervisor")
  .addConditionalEdges(
    "supervisor",
    (state) => (state.next === "FINISH" ? END : state.next),
    ["researcher", "writer", END],
  )
  .addEdge("researcher", "supervisor")
  .addEdge("writer", "supervisor")
  .compile();

const result = await team.invoke({
  messages: [{ role: "user", content: "Write a short paragraph about LangGraph." }],
});

console.log("\nFinal:\n" + lastText(result));

Running it

node team.mjs
   supervisor → researcher
   supervisor → writer
   supervisor → FINISH

   Final:
   LangGraph is an open-source library from the LangChain team for
   building agents as graphs. ...

Three decisions, three model calls for routing, two specialists doing the real work. You can see every step, and each specialist can be swapped or tested on its own.

About the cost

Being honest about the trade-off. A team is not free.

  • Every specialist runs its own model loop. The supervisor adds a routing call per step. A three-step job here is five or six model calls, not one.
  • More agents means more places for a bug to hide. Print the supervisor’s decisions while you build, like team.mjs does.

So don’t split a job that one agent does well. Split when the single agent starts failing, or when the parts need testing on their own.

About that hardcoded key

A key pasted into a script on your own machine is fine. A key in a script that ends up in a git repo is not. Bots scan public GitHub for keys.

Before you commit, drop the line that sets the key, and run like this:

node --env-file=.env team.mjs

Put OPENROUTER_API_KEY=sk-or-v1-... in a .env file, and add .env to .gitignore.

Where to go from here

  • Give a specialist real tools over MCP. The MCP post shows how, and the tools drop straight into createAgent.
  • Ask a human before the writer publishes. The human-in-the-loop post guard works on any specialist’s tool.
  • Add a third specialist, say an editor that reviews the paragraph, and add it to the Decision enum. The supervisor picks it up.
  • There are prebuilt helpers, @langchain/langgraph-supervisor and @langchain/langgraph-swarm, that wire up lessons 5 and 7 for you. Now that you’ve seen the wiring, they’ll make sense.
  • LangChain’s Deep Agents library ships subagents, planning and memory built in. It’s the next step up once a hand-built team feels small.

That’s the whole idea. Small agents with small jobs, and a graph that decides who works next. Supervisor, handoff, or router — pick the shape that matches the work.


Share: