One agent with many tools can become confused, slow and hard to debug. A natural idea is to split the work like a small company: one person plans, others research, one writes, one checks. That idea is a multi-agent system. It can work very well, and it can also be an expensive way to make a simple problem complicated. In this lesson you will learn both sides, then build a small team in your AI Lab and see every hand-off.
What a multi-agent system really is
Despite the grand name, a multi-agent system is usually just several separate model calls, each with its own system prompt, its own tools and its own job, connected by code. Agent A produces something, your code passes it to agent B, and so on. There is no magic. There is no hidden shared brain. Each agent only knows what you hand to it.
That last point is the real benefit. When you give one agent a smaller, focused job, you get:
- Clearer prompts. A prompt that says “you are a reviewer; check each claim against the notes” is much easier to get right than one prompt that says “research, write and check”.
- Fewer tools per agent. A researcher needs search. A writer needs none. Fewer tools mean fewer wrong choices.
- Safer permissions. The agent that reads untrusted web pages does not need the tool that sends email.
- Easier testing. You can test each agent alone with its own examples.
The costs nobody mentions in the demo
- More calls, more tokens, more money. A team of four agents can use five to ten times the tokens of one call.
- More delay. Steps that depend on each other run one after the other.
- More ways to fail. If the researcher gives bad notes, the writer builds on them with confidence. Errors travel down the line.
- Harder debugging. When the final answer is wrong, you must find which agent caused it.
So the advice is the same as in the last lesson: begin with a single model call, then a workflow, then one agent, and add more agents only when you can show that the simpler design is not good enough. A good reason to split is a clear need for different tools, different permissions, or a separate check.
Common patterns
- Supervisor and workers. A supervisor breaks the task into parts and sends each part to a worker. This is what we build.
- Pipeline. Agents run in a fixed order: draft, then edit, then format.
- Writer and reviewer. One agent produces, another checks against rules or sources and asks for fixes. This pattern catches many mistakes, as long as the reviewer sees the sources and not only the draft.
- Parallel workers. Several agents handle independent parts at the same time, then their results are merged. This saves time.
Our team in the lab
The team in the lab handles a topic like “How to prepare a resume and an interview for a first job”. It has five stages:
- Supervisor. Breaks the topic into at most three short research questions. It returns them through a forced tool call, so the answer is structured data and not loose text.
- Researchers. One worker for each question, all running at the same time with
Promise.all. Each worker may callsearch_docsand writes two to four bullet points with the source file named. - Writer. Gets only the notes, not the documents. It writes a short guide using only the notes.
- Reviewer. Compares the draft with the notes and returns
approvedand a list of problems, again as structured data. - Revision, once. If the reviewer finds problems, the writer revises one time. We never loop forever between agents.
Notice three design choices. The agents communicate through small, structured hand-offs (a list of questions, a text of notes, a verdict), not through the whole conversation. The structured outputs are checked with a schema (we use zod), and if a result is unusable the route returns a clear error instead of passing nonsense on. And the loop between writer and reviewer has a hard limit of one revision.
import { Router } from 'express';
import { z } from 'zod';
import { client, MODEL, textOf } from '../claude.js';
import { runAgent } from '../agents.js';
import { retrieve } from '../rag/hybrid.js';
const router = Router();
// Each worker has ONE job, its own system prompt and only the tools it needs.
const searchTool = {
name: 'search_docs',
description: 'Search the help-center documents. Returns the best matching passages.',
input_schema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
};
const searchHandlers = { search_docs: ({ query }) => ({ passages: retrieve(String(query), { mode: 'hybrid', k: 3 }).map((p) => ({ source: p.source, text: p.text })) }) };
// Ask the model to return structured data by forcing it to call one tool.
async function structured(system, user, name, schema, description) {
const reply = await client.messages.create({
model: MODEL,
max_tokens: 600,
system,
tools: [{ name, description, input_schema: schema }],
tool_choice: { type: 'tool', name },
messages: [{ role: 'user', content: user }],
});
const block = reply.content.find((b) => b.type === 'tool_use');
return { data: block?.input, usage: reply.usage };
}
const PlanSchema = z.object({ questions: z.array(z.string().min(5)).min(1).max(3) });
const ReviewSchema = z.object({ approved: z.boolean(), problems: z.array(z.string()).max(5) });
const add = (total, u = {}) => {
total.input += u.input_tokens ?? u.input ?? 0;
total.output += u.output_tokens ?? u.output ?? 0;
};
// POST /api/team { topic } -> { plan, research, draft, review, final, usage }
router.post('/team', async (req, res) => {
const topic = String(req.body?.topic || '').trim().slice(0, 300);
if (!topic) return res.status(400).json({ error: { message: 'Please type a topic.' } });
const usage = { input: 0, output: 0 };
// 1. Supervisor: break the topic into research questions.
const planned = await structured(
'You are a supervisor. Break the topic into at most 3 short research questions that the help-center documents could answer.',
topic, 'submit_plan',
{ type: 'object', properties: { questions: { type: 'array', items: { type: 'string' }, maxItems: 3 } }, required: ['questions'] },
'Submit the research questions.',
);
add(usage, planned.usage);
const plan = PlanSchema.safeParse(planned.data);
if (!plan.success) return res.status(502).json({ error: { message: 'The planner did not return a usable plan. Please try again.' } });
// 2. Researchers: one worker per question, running at the same time.
const research = await Promise.all(plan.data.questions.map(async (q) => {
const r = await runAgent({
system: 'You are a researcher. Use search_docs, then write 2-4 bullet points that answer the question using ONLY what the search returned. Name the source file in brackets after each bullet.',
user: q, tools: [searchTool], handlers: searchHandlers, maxTurns: 3, maxReplyTokens: 400,
});
add(usage, r.usage);
return { question: q, notes: r.text };
}));
const notes = research.map((r) => `## ${r.question}\n${r.notes}`).join('\n\n');
// 3. Writer: turns the notes into a short guide. It never sees the documents, only the notes.
const write = async (extra = '') => {
const reply = await client.messages.create({
model: MODEL, max_tokens: 700,
system: 'You are a writer. Write a short, friendly guide (under 200 words) using ONLY the notes. Do not add facts that are not in the notes.',
messages: [{ role: 'user', content: `Topic: ${topic}\n\n<notes>\n${notes}\n</notes>${extra}` }],
});
add(usage, reply.usage);
return textOf(reply);
};
const draft = await write();
// 4. Reviewer: checks the draft against the notes.
const reviewed = await structured(
'You are a strict reviewer. Compare the draft with the notes. Approve only if every claim in the draft is supported by the notes.',
`<notes>\n${notes}\n</notes>\n\n<draft>\n${draft}\n</draft>`, 'submit_review',
{ type: 'object', properties: { approved: { type: 'boolean' }, problems: { type: 'array', items: { type: 'string' } } }, required: ['approved', 'problems'] },
'Submit the review.',
);
add(usage, reviewed.usage);
const review = ReviewSchema.safeParse(reviewed.data);
const verdict = review.success ? review.data : { approved: false, problems: ['The reviewer returned an unusable answer.'] };
// 5. One revision at most. Loops between agents must always have a limit.
const final = verdict.approved ? draft : await write(`\n\nA reviewer found these problems. Fix them:\n- ${verdict.problems.join('\n- ')}`);
res.json({ plan: plan.data.questions, research, draft, review: verdict, final, revised: !verdict.approved, usage });
});
export default router;
The structured helper forces the model to call one tool, and reads the tool's input as the result. This is one of the most reliable ways to get JSON from a model. The tool's schema tells the model the shape, and zod checks it again on your side.
Frameworks and the Claude Agent SDK
You do not have to write all of this yourself. There are frameworks that handle the loop, tools, hand-offs and state. Anthropic provides the Claude Agent SDK, which gives you the same agent loop and tools that power Claude Code, for building your own agents. Other popular options include LangGraph, the OpenAI Agents SDK and several others. Frameworks change fast, so check each project's current documentation, maintenance and licence before you choose.
How should you choose? Consider what the framework gives you: built-in tools like file access and shell, permission controls, session handling, and tracing. Consider what it costs you: a new vocabulary, more code that you do not control, and upgrades that may break things. A good test is to ask “could I explain what this framework does in a loop and a few functions?” After this lesson you can. That is why we built it by hand first. When you move to a framework, you will know what is happening underneath, and you will debug much faster.
Your task: build the agent team
Build the supervisor-and-workers team and make every hand-off visible in the page.
What you will build
- A route POST /api/team that takes a topic and returns the plan, research notes, draft, review, final text, whether it was revised, and the total token usage.
- A helper that forces a structured answer with a tool call, and zod schemas that check the plan and the review.
- An Angular page that shows the final guide first and then every stage: plan, notes, draft and review.
Steps
- Write the structured helper: one tool, tool_choice set to that tool, and return the tool_use input.
- Write the supervisor call and check the plan with a zod schema. If the plan is invalid, return a 502 with a friendly message.
- Run one researcher per question with Promise.all. Each researcher is a runAgent call with only the search tool and a maxTurns of 3.
- Write the writer as a function that takes optional extra instructions. Give it only the notes.
- Write the reviewer with a forced tool call and a zod check. If its answer is unusable, treat the draft as not approved.
- If the review is not approved, call the writer once more with the problems listed. Add up the tokens used by all agents. Build the page and test one run that is approved and one run where the reviewer asks for a revision.
Hints
- Give the writer only the notes. If it can see the documents, it may add facts that the reviewer cannot check.
- Add the token usage of every call to one total so you can see what a team costs.
- To test the revision path, make your fake or mock model reject the first draft once.
You know it works when one request shows five clear stages, the researchers run side by side, an unusable plan or review is handled without a crash, a rejected draft is revised exactly once, and the page shows the total tokens.
Solution
You have read team.js above. This is the Angular page.
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { errorText } from '../util';
type Team = {
plan: string[];
research: { question: string; notes: string }[];
draft: string;
review: { approved: boolean; problems: string[] };
final: string;
revised: boolean;
usage: { input: number; output: number };
};
@Component({
selector: 'app-team',
imports: [FormsModule],
template: `
<h1>A team of agents</h1>
<p class="hint">A supervisor plans, researchers look things up, a writer drafts and a reviewer checks. Every hand-off is shown below.</p>
<div class="card">
<label for="t">Topic</label>
<input id="t" [(ngModel)]="topic" (keyup.enter)="run()" />
<button (click)="run()" [disabled]="busy() || !topic.trim()">{{ busy() ? 'The team is working...' : 'Start' }}</button>
@if (error()) { <div class="err">{{ error() }}</div> }
</div>
@if (team(); as t) {
<div class="card">
<h2>Final guide @if (t.revised) { <span class="muted">(revised after review)</span> }</h2>
<div class="answer">{{ t.final }}</div>
<p class="muted">{{ t.usage.input + t.usage.output }} tokens across all agents.</p>
</div>
<div class="card">
<h2>1. Supervisor plan</h2>
<ol>@for (q of t.plan; track q) { <li>{{ q }}</li> }</ol>
<h2>2. Researcher notes</h2>
@for (r of t.research; track r.question) {
<p><strong>{{ r.question }}</strong></p>
<pre class="out">{{ r.notes }}</pre>
}
<h2>3. Writer draft</h2>
<pre class="out">{{ t.draft }}</pre>
<h2>4. Reviewer</h2>
<p [class]="t.review.approved ? 'ok' : 'bad'">{{ t.review.approved ? 'Approved' : 'Problems found' }}</p>
<ul>@for (p of t.review.problems; track p) { <li>{{ p }}</li> }</ul>
</div>
}
`,
})
export class TeamPage {
private http = inject(HttpClient);
topic = 'How to prepare a resume and an interview for a first job';
busy = signal(false);
error = signal('');
team = signal<Team | null>(null);
async run() {
this.busy.set(true);
this.error.set('');
this.team.set(null);
try {
this.team.set(await firstValueFrom(this.http.post<Team>('/api/team', { topic: this.topic })));
} catch (e) {
this.error.set(errorText(e));
} finally {
this.busy.set(false);
}
}
}
What you learned
- A multi-agent system is several focused model calls, each with its own prompt and tools, connected by your code.
- It can give clearer prompts, safer permissions and easier testing, at the price of more tokens, delay and failure points.
- Pass small, structured hand-offs and validate them. Put a hard limit on every loop between agents.
- Frameworks and the Claude Agent SDK wrap these ideas. Knowing the loop first makes any framework easier to judge.
In the next three lessons we look at a different problem: how an AI app talks to the outside world in a standard way, using the Model Context Protocol.