Multi-Agent Orchestration
View phase4 on GitHubLangGraph, specialist agents, MCP protocol
What You Will Build
A research pipeline made of three specialist agents that work as a team. A Researcher gathers facts, an Analyst finds trends and numbers, and a Writer turns both into a clean markdown report. LangGraph wires them into a fixed order and threads a shared state object through each step. You will expose the pipeline as one streaming HTTP endpoint that reports progress node by node. By the end of this phase you can show that three-agent pipeline running end to end.
Data Flow Visualization
Watch a single topic travel through the whole team. The compiled graph runs Researcher, then Analyst, then Writer — that order is wired in orchestrator.ts, not chosen by an LLM. Each node writes into shared state, and a step event fires as that node finishes.
Click any node to see the shape of the data at that point. Press Animate Flow to step through the request from topic to done.
Why This Phase Exists
In Phase 2 you built one agent with a set of tools. That works until the task gets long. Give one agent ten tools and it starts to struggle. It picks the wrong tool, mixes up steps, and small errors add up across a long job.
Specialist agents fix this. Each agent is good at one thing and holds one job. The Researcher only researches. The Analyst only analyzes. The Writer only writes. In this phase the graph runs them in that fixed order — nobody picks the next agent at runtime. (A supervisor that does choose is a short home exercise after the pipeline works; it is not in the phase4 code.)
You cannot skip any part of this phase. It sets the pattern for everything after it: shared state, per-node error handling, and a protocol for tools (MCP) that becomes its own service in Phase 8. Master the fixed pipeline first. Dynamic routing comes later, and it will make sense only after the simple version is solid.
How Multi-Agent Orchestration Actually Works
There are four common ways to run more than one agent. You will build the first one in this phase. Knowing the other three tells you when to reach for them.
- ▸Pipeline (this phase): Researcher → Analyst → Writer in a fixed order. Each node reads shared state, does its work, and writes its result back. This is the correct starting pattern. It is easy to reason about and easy to debug.
- ▸Supervisor: Add an orchestrator LLM node that reads intermediate results and decides what happens next. It can re-run a node, skip a node, or send work to a different specialist. In LangGraph you build this with conditional edges.
- ▸Debate: Several agents answer the same question. A judge agent picks the best answer. This can raise accuracy on hard questions, at the cost of extra LLM calls.
- ▸Parallel: Send the same task to several agents at once. The fastest correct result wins.
Gotcha: the supervisor pattern looks more powerful, so people start there. Do not. A dynamic graph with loops is hard to debug when a node misbehaves. Ship the fixed pipeline first. The supervisor stays homework — see Next Steps.
Why LangGraph for This Phase
You could wire three functions together with plain async/await and skip the library. That works for three nodes in a straight line. It stops working the moment you add a branch, a retry, or streaming progress per node.
- ▸Shared state as a first-class thing. LangGraph gives you one typed state object that flows through every node. You do not pass a growing bag of arguments from function to function.
- ▸Reducers control how state merges. Each field defines how a new value combines with the old one. This is what makes concurrent and looping graphs safe later.
- ▸Streaming is built in.
pipeline.stream(...)emits an update after each node finishes. You get progress events without writing your own event plumbing. - ▸The graph is the design. Nodes and edges are declared in one place. Anyone reading
orchestrator.tssees the whole workflow at a glance.
We start with a fixed pipeline. You can grow this same graph into a supervisor later without a rewrite — that is why we use the library now.
Shared State — The Contract Between Agents
Every agent reads from and writes to one state object. If you get this contract wrong, the agents cannot pass work to each other. LangGraph defines state with Annotation, where each field also declares a reducer — a function that says how a new value merges with the current one.
import { Annotation } from '@langchain/langgraph'; export const ResearchState = Annotation.Root({ userRequest: Annotation<string>({ reducer: (_, b) => b }), researchNotes: Annotation<string>({ reducer: (_, b) => b, default: () => '' }), analysis: Annotation<string>({ reducer: (_, b) => b, default: () => '' }), finalReport: Annotation<string>({ reducer: (_, b) => b, default: () => '' }), currentStep: Annotation<string>({ reducer: (_, b) => b, default: () => 'start' }), errors: Annotation<string[]>({ reducer: (a, b) => [...a, ...b], default: () => [] }), }); export type ResearchStateType = typeof ResearchState.State;
Look at the two reducer shapes. Most fields use (_, b) => b: the new value replaces the old one. The errors field uses (a, b) => [...a, ...b]: it appends instead of replacing. That difference matters. If any node hits a problem, it adds to errors without wiping out an error a previous node already recorded. By the end of the run, errors holds every failure from every node.
Test It posts __forceErrorIn in non-production. That field is on the real route and on ResearchState. It is omitted from the snippet above on purpose.
Gotcha: if two nodes write the same field with a replace reducer, the last write wins and the earlier one is lost silently. That is fine for a straight pipeline where only one node writes each field. It becomes a real bug in a parallel or looping graph. When you design state, decide up front whether each field should replace or append.
Specialist Tool Calling — You Cannot Skip
Phase 2's chat agent is createReactAgent with every tool. That is right for a general assistant. It is wrong if every pipeline node gets the same list.
Problem: if Researcher and Analyst share the full tool list, you are back to one agent with ten tools, just split across files. The Researcher will reach for the calculator. The Analyst will skip the notes and search Wikipedia again.
The fix: Researcher and Analyst each get their own createReactAgent with a short list. Researcher gets Wikipedia and the knowledge base. Analyst gets the calculator. Writer is a plain ChatGroq call with no tools — not createReactAgent. The default model (openai/gpt-oss-20b) supports the ReAct loop on Researcher and Analyst — the same path as /api/chat.
A prompt that says “call each tool at most once” is not a stop. openai/gpt-oss-20b can keep calling until LangGraph hits recursionLimit. The stop is in code: each tool is wrapped so a second call returns a skip, and if the loop still does not produce notes, the node runs one plain ChatGroq write from the topic plus whatever tool output already came back.
const llm = new ChatGroq({ apiKey: env.GROQ_API_KEY, model: env.GROQ_MODEL, temperature: 0.1 }); export async function researcherNode(state: ResearchStateType): Promise<Partial<ResearchStateType>> { try { const researchNotes = await runSpecialistLoop({ llm, tools: [wikipediaTool, knowledgeBaseTool], human: `You are a research agent. Use search_wikipedia and search_knowledge_base at most once each, then stop calling tools. ` + `Compile a structured fact summary with sources.\n\nTOPIC: ${state.userRequest}`, synthesisPrompt: (transcript) => `Write a structured fact summary with sources from the research below. Do not call tools.\n\n${transcript}`, }); return { researchNotes, currentStep: 'research_complete' }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { errors: [`Researcher error: ${message}`], currentStep: 'research_failed' }; } }
runSpecialistLoop builds a fresh createReactAgent per request (so one-shot flags cannot leak across runs), streams with { recursionLimit: 8 }, and treats a recursion throw as “write notes now,” not as a failed node.
The Analyst is the same idea with only calculatorTool. Groq still runs a ReAct loop inside the node: tool call, observation, then a final answer written into shared state.
Gotcha: a specialist can skip its tools and guess. The research SSE only reports step complete or failed — it does not list which tools fired. If the notes look empty of sources, the model skipped Wikipedia, not the pipeline. search_wikipedia calls /w/rest.php/v1/search/page and returns the top hits (title, excerpt, url), so a topic like "EV adoption in Southeast Asia" does not have to be a Wikipedia title.
Per-Node Error Isolation
Problem: in a three-node pipeline, one agent will eventually fail. A network blip, a bad response, a timeout. If that failure crashes the process, the client gets nothing and you lose the work the other nodes already did.
Each node wraps its body in try/catch and returns the error into state instead of throwing. Look again at the pattern from the Researcher: on success it returns { researchNotes, currentStep: 'research_complete' }; on failure it returns { errors: [...], currentStep: 'research_failed' }. The pipeline keeps moving. LangGraph's append reducer keeps every error in graph state. The route does the same when it merges stream chunks into lastState (errors: [...prev, ...next]), so the final done event carries the full errors array — not only the last node that failed.
This gives you two clear failure levels:
- ▸Node-level failure — one agent fails, records its error, and the run still finishes with partial results. The client sees
status: 'failed'for that node. - ▸Route-level failure — LangGraph itself throws (a graph misconfiguration, for example). The route's outer
try/catchcatches it and sends an SSEerrorevent.
Gotcha: a failing node must set currentStep to something ending in _failed and push to errors. If it returns status: 'complete' on a failure, the stream lies to the client and the bug hides. Test both paths on purpose: force a node to fail, and mock a LangGraph exception, and confirm each surfaces in the right place.
Core Pipeline — Code Deep Dive
The orchestrator is where the team becomes a workflow. It declares three nodes and the edges between them. The edges are the fixed order: start → researcher → analyst → writer → end.
import { StateGraph } from '@langchain/langgraph'; import { ResearchState } from './state'; import { researcherNode } from './researcher.agent'; import { analystNode } from './analyst.agent'; import { writerNode } from './writer.agent'; const workflow = new StateGraph(ResearchState) .addNode('researcher', researcherNode) .addNode('analyst', analystNode) .addNode('writer', writerNode) .addEdge('__start__', 'researcher') .addEdge('researcher', 'analyst') .addEdge('analyst', 'writer') .addEdge('writer', '__end__'); export const researchPipeline = workflow.compile();
compile() turns the declaration into a runnable graph. Now the route streams it to the client. The key detail is the single stream pass: you run the pipeline once, collect each node's update from the stream, and never run it twice.
researchRouter.post('/', async (req, res) => { const parsed = z.object({ topic: z.string().min(5) }).safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid request', details: parsed.error.flatten() }); return; } const { topic } = parsed.data; res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('X-Accel-Buffering', 'no'); res.flushHeaders(); const send = (ev: string, d: unknown) => res.write(`event: ${ev}\ndata: ${JSON.stringify(d)}\n\n`); try { send('start', { topic }); const stream = researchPipeline.stream({ userRequest: topic }, { streamMode: 'updates' }); let lastState: Partial<ResearchStateType> = {}; for await (const chunk of await stream) { const [node] = Object.keys(chunk) as (typeof PIPELINE_NODES)[number][]; if (node && PIPELINE_NODES.includes(node as (typeof PIPELINE_NODES)[number])) { const nodeUpdate = chunk[node] as Partial<ResearchStateType>; lastState = { ...lastState, ...nodeUpdate, errors: [...(lastState.errors ?? []), ...(nodeUpdate.errors ?? [])], }; const nodeFailed = nodeUpdate.currentStep?.endsWith('_failed') ?? false; const nodeErrors = nodeUpdate.errors ?? []; if (nodeFailed || nodeErrors.length > 0) { send('step', { node, status: 'failed', error: nodeErrors[0] ?? 'Unknown node error' }); } else { send('step', { node, status: 'complete' }); } } } send('done', { report: lastState.finalReport ?? '', notes: lastState.researchNotes ?? '', analysis: lastState.analysis ?? '', errors: lastState.errors ?? [], }); } catch (err) { send('error', { message: err instanceof Error ? err.message : String(err) }); } finally { res.end(); } });
streamMode: 'updates' makes LangGraph emit one chunk per node, keyed by node name. You merge each update into lastState, and you append errors instead of replacing them — a plain { ...lastState, ...nodeUpdate } would drop earlier failures if a later node also writes errors. After the loop you hold the report, the notes, the analysis, and every error. The client sees a step event as each agent finishes, then one done event with the result. The Writer is a plain ChatGroq call (no tools). Its system prompt asks for four ## sections: Executive Summary, Key Findings, Analysis, Conclusion. A small normalizer then rewrites those four titles to ## if the model emits # instead, and demotes any other ## heading to ###, so the report shape stays stable. If researchNotes is empty, Analyst and Writer still run (fail-and-continue), but their prompts tell them not to invent facts. The code prepends > Research notes were empty for this run... on both analysis and the report so a confident model cannot hide the failure.
Pipeline vs Supervisor vs Debate vs Parallel
This answers a common interview question: "You have several agents — how do you decide how to connect them?"
- ▸Pipeline — fixed order, one pass. Use it when the steps are known and always the same. Research, then analyze, then write. It is cheap, fast to build, and easy to debug. This is your default.
- ▸Supervisor — an LLM node routes work based on intermediate results. Use it when the next step depends on what the last step found. It costs more (the router is an extra LLM call each hop) and it can loop, so you need a loop counter.
- ▸Debate — several agents answer, a judge picks. Use it when accuracy matters more than cost and latency. It multiplies your token spend by the number of debaters plus the judge.
- ▸Parallel — same task, many agents, first correct answer wins. Use it to cut latency when one agent is unreliable but the task is quick.
Gotcha: more coordination is not free. Every extra LLM node is another API call, more latency, and more cost. Pick the simplest pattern that solves the task. Most production systems are pipelines with one or two supervised branches, not a debate of ten agents.
MCP and A2A — Why Now, Not Later
Two protocols show up the moment you have more than one agent and more than one tool. Learn the names now; you will build the full versions in later phases.
MCP (Model Context Protocol) is the USB standard for AI tools. Build a tool once, and any compatible agent framework can discover and call it. In this phase the registry is an in-process Map. At boot, registerMCPTools() copies Wikipedia, calculator, and the knowledge base onto that Map, and startup logs mcpServer.listTools(). Researcher and Analyst still receive LangChain tools through createReactAgent — this pipeline does not call mcpServer.execute. Phase 8 moves the same registry into its own service.
export class MCPToolServer { private tools = new Map<string, MCPTool>(); register(tool: MCPTool) { this.tools.set(tool.name, tool); } async execute(name: string, input: unknown) { const tool = this.tools.get(name); if (!tool) throw new Error(`MCP tool not found: ${name}`); return tool.execute(tool.inputSchema.parse(input)); } listTools() { return [...this.tools.values()].map(t => ({ name: t.name, description: t.description })); } } export const mcpServer = new MCPToolServer();
A2A (Agent-to-Agent Protocol) is the next layer up. MCP is about tools inside one agent. A2A is about how one agent finds, authenticates, and calls another agent across service or company boundaries. Where MCP treats tools as callable functions, A2A treats agents as services. You expose an agent card at GET /.well-known/agent.json that describes what your agent can do.
export const AGENT_CARD = { name: 'AgentTelar Research Agent', version: '1.0.0', description: 'Multi-agent research pipeline: researcher, analyst, writer', url: env.PUBLIC_URL ?? `http://localhost:${env.PORT}`, capabilities: { streaming: true, pushNotifications: false }, skills: [{ id: 'research-pipeline', name: 'Research Pipeline', description: 'Given a topic, produces a structured research report', inputModes: ['text/plain'], outputModes: ['text/markdown'], }], }; // Expose at GET /.well-known/agent.json
The MCP registry you build here is the seed for the Phase 8 tool service. A2A is the next layer: you already serve an agent card at GET /.well-known/agent.json. Phase 9 puts a card on each Kubernetes service.
Test It — curl Commands + Expected Output
This is the verification gate. Run one phase stack at a time. Volumes do not collide (agenttelar-phase4 vs agenttelar-phase3), but host ports do: Phase 3 and Phase 4 both publish :3000, :6379, and :5432. From your phase4/ folder:
# Required if Phase 3 is still running — frees shared host ports (volumes stay): (cd ../phase3/docker && docker compose down) cp apps/api/.env.example apps/api/.env # Open apps/api/.env and set GROQ_API_KEY and OPENAI_API_KEY # (the Researcher still queries the knowledge base; embeddings use OpenAI) cd docker && docker compose up --build -d
Compose also sets RATE_LIMIT_MAX_REQUESTS: 100 on the API (it overrides the 20 in
.env.example), so your curl checklist will not trip the limiter under normal use.
Once docker compose ps shows healthy services, run the checks below in order.
1. Confirm the API is Phase 4:
curl http://localhost:3000/health # {"status":"ok","phase":4,...}
2. Run the pipeline (watch each agent finish live):
This happy path can take 1–2 minutes. Researcher and Analyst each run a ReAct loop; a second consecutive run can exceed 90 seconds. Wait for event: done instead of aborting the curl.
curl -N -X POST http://localhost:3000/api/research \ -H 'Content-Type: application/json' \ -d '{"topic": "EV adoption in Southeast Asia"}'
Expected output: a sequence of SSE events, one step per node, then done:
event: start
data: {"topic":"EV adoption in Southeast Asia"}
event: step
data: {"node":"researcher","status":"complete"}
event: step
data: {"node":"analyst","status":"complete"}
event: step
data: {"node":"writer","status":"complete"}
event: done
data: {"report":"## Executive Summary\n...","notes":"...","analysis":"...","errors":[]}
3. A short topic is rejected (input validation):
curl -X POST http://localhost:3000/api/research \ -H 'Content-Type: application/json' \ -d '{"topic": "hi"}' # {"error":"Invalid request","details":{...}} (HTTP 400 — topic must be at least 5 characters)
4. The A2A agent card is published:
curl http://localhost:3000/.well-known/agent.json # {"name":"AgentTelar Research Agent","version":"1.0.0","capabilities":{"streaming":true,...},"skills":[...]}
5. A failing node still finishes the run. In non-production, POST __forceErrorIn: "researcher" (the route ignores this field when NODE_ENV is production). The step event for researcher should report status: "failed" with an error string. Later nodes still run: Analyst and Writer emit step events, and done must still fire. Notes may be empty. done.analysis and done.report must both start with > Research notes were empty for this run — they must not invent a full brief or a table of stats. done.errors is an append of every node's errors — if more than one node fails, you see every message, not only the last.
curl -N -X POST http://localhost:3000/api/research \ -H 'Content-Type: application/json' \ -d '{"topic": "EV adoption in Southeast Asia", "__forceErrorIn": "researcher"}'
event: step
data: {"node":"researcher","status":"failed","error":"Researcher error: Researcher forced failure (test injection)"}
event: step
data: {"node":"analyst","status":"complete"}
event: step
data: {"node":"writer","status":"complete"}
event: done
data: {"report":"> Research notes were empty for this run. Treat any facts below as unverified.\n\n## Executive Summary\n...","notes":"","analysis":"> Research notes were empty for this run. Treat any numbers below as unverified.\n\n...","errors":["Researcher error: Researcher forced failure (test injection)"]}
The process must not crash. A route-level LangGraph exception is a different path: it surfaces as an SSE error event instead of done.
Try It Live
Open the phase4 API in the browser editor to read orchestrator.ts and the
agent nodes side by side with this lesson. StackBlitz is for code exploration —
the research pipeline needs Groq, OpenAI embeddings, Postgres with pgvector, and Redis
(GROQ_API_KEY + OPENAI_API_KEY + DATABASE_URL + REDIS_URL), which is awkward in
the browser. Use the local Docker path above for the curl verification.
phase4 — Multi-Agent Orchestration (code explorer)
Browse the Phase 4 API on StackBlitz; run the pipeline via local Docker
Click to launch editor
Runs entirely in your browser — no install needed
Cost Awareness — Numbers Table
One chat call costs you one LLM request. One research report costs more, because each specialist is its own loop. A tool call inside Researcher or Analyst is another model round trip. Multi-agent quality is not free, so track it from the first run.
| Step | LLM calls | Notes |
|---|---|---|
| Researcher | 1+ | createReactAgent with Wikipedia + knowledge base; each tool call is another round |
| Analyst | 1+ | createReactAgent with the calculator; each tool call is another round |
| Writer | 1 | Plain ChatGroq, no tools |
| Per report | 3+ calls | Tool loops add rounds; later nodes also read more of the growing state |
On Groq's openai/gpt-oss-20b (the default in this phase), pricing is $0.075 / 1M input tokens and $0.30 / 1M output tokens on the Groq model catalog — check that page; list prices change. A three-node research report is already several times a single chat turn, and each call reads more of the growing state, so token cost per call climbs too. Add a supervisor or a debate pattern and the call count climbs fast. The errors and step data you already stream are the start of the per-request cost tracking you finish in Phase 10.
Completion Checklist — Technical
Before you consider Phase 4 done, all of these should be true:
- ▸
POST /api/researchstreams astepevent per node withstatuscompleteorfailed - ▸A failing node emits
{ node, status: 'failed', error: '...' }— notstatus: 'complete' - ▸The
doneevent always fires last and includes the fullerrorsarray - ▸The final report has Executive Summary, Key Findings, Analysis, and Conclusion sections
- ▸Each agent uses only its own tools — the Researcher never calls the calculator
- ▸LangGraph state threads correctly through all three nodes
- ▸The MCP server lists its registered tools in the startup log
- ▸
GET /.well-known/agent.jsonreturns the A2A agent card with the right capabilities - ▸An error in one node surfaces in
done.errorsand the process does not crash - ▸A route-level exception surfaces in an SSE
errorevent - ▸Both failure modes are tested: a forced node failure and a mocked LangGraph exception
Conceptual Checklist — Can You Explain?
If you cannot answer these out loud without checking your code, re-read the matching section above before moving on:
- ▸Why does splitting work across specialist agents beat one agent with all the tools?
- ▸What is LangGraph shared state, and what happens if two nodes write the same field?
- ▸Why does the
errorsfield use an append reducer while the others replace? - ▸What is the difference between the pipeline and supervisor patterns, and when do you use each?
- ▸What is the difference between MCP (a tool protocol) and A2A (an agent protocol)?
- ▸Why does each specialist get a short tool list instead of sharing every tool?
- ▸How does a single failing node avoid crashing the whole run?
What You Will Learn
- ▸Coordinate specialist agents into a reliable pipeline with LangGraph
- ▸Design shared state with the right reducer for each field
- ▸Isolate node failures so one agent error does not sink the whole run
- ▸Know when to pick pipeline, supervisor, debate, or parallel coordination
- ▸Understand MCP for tools and A2A for agents, and why both matter
Next Steps
Clone the phase4 folder on GitHub. Stop Phase 3 with docker compose down in phase3/docker (frees :3000, :6379, :5432), copy apps/api/.env.example to apps/api/.env, set both GROQ_API_KEY and OPENAI_API_KEY, then cd docker && docker compose up --build -d. Work through the curl tests above until each one matches. When the pipeline is solid, the home exercise is a supervisor: one extra LLM node, a conditional edge, and a loop counter (max two retries) so weak research can run again. That code is yours to add — it is not in phase4. Complete the quiz before Phase 5.
Phase Check
0/8 answered1.Your single agent has ten tools and keeps picking the wrong one on long tasks, with small errors adding up. Why does splitting the work into a Researcher, Analyst, and Writer help?
2.In the shared state, most fields use the reducer (_, b) => b but errors uses (a, b) => [...a, ...b]. What does that difference do?
3.The Researcher and Analyst are separate LangGraph nodes. Why does each one get its own createReactAgent with a short tool list instead of sharing every tool?
4.The Researcher node throws mid-run because its API key is broken. What should the client see, and why?
5.The research route uses researchPipeline.stream(..., { streamMode: 'updates' }) and merges each chunk into lastState. Why stream once and merge instead of running the pipeline twice?
6.The next step of your workflow depends on what the previous agent found — sometimes you need to re-run research, sometimes skip straight to writing. Which coordination pattern fits?
7.A teammate asks about MCP versus A2A. Which description is correct?
8.One chat turn is one LLM call, but one research report costs more. Where does the extra cost come from?