Project: Build a Multi-Agent Research System with Web Search
Build a research system where a lead agent plans, parallel subagents search the web, and a durable worker produces a cited report, with progress and cost limits.
On this page· What you will build
Why this matters
Research is the task where multi-agent systems most clearly beat a single agent: a broad question splits into independent threads, each needing lots of reading. Building one end to end forces you to solve the problems that make agents hard in production: long-running work outside the request cycle, crash recovery, parallelism with limits, cost control, and citations a reader can verify. It is an impressive, demo-friendly portfolio piece because the output, a cited report, is easy for anyone to judge.
What you will build#
- A research API: submit a question, get a run ID immediately, poll for progress.
- A durable worker that runs the research in checkpointed steps and survives crashes.
- A lead agent that plans independent subtasks with structured output.
- Parallel subagents that research with the server-side web search tool and return cited summaries.
- A final report with numbered sources, and cost and size limits per run.
Hover or tap a component to see what it does.
Show text descriptionHide text description
Long-running agent work belongs in a background worker, not in a request handler.
- Research UI: Starts a run and shows live progress: plan, subagent status, sources found.
- API: Validates the question and budget, creates the run, and returns its ID immediately.
- Durable worker: Runs can take minutes; a worker claims them with a lease and checkpoints each step.
- Lead agent: Plans subtasks, writes briefs, and writes the final report from condensed findings.
- Run store: Run status, checkpoints, subagent summaries, and every source with its URL.
- Subagents: Each researches one subtask in its own context and returns a short, cited summary.
- Web search: Search and page fetching, with results the model can cite.
This project combines multi-agent systems and durable execution.
1. Schema#
CREATE TABLE research_runs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
question text NOT NULL,
status text NOT NULL DEFAULT 'queued', -- queued | running | done | failed
attempts int NOT NULL DEFAULT 0,
lease_until timestamptz,
report text,
error text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE research_steps (
run_id uuid NOT NULL REFERENCES research_runs(id),
step_key text NOT NULL,
result jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (run_id, step_key)
);import pg from "pg";
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });2. Durable steps#
The step helper from the durable execution article: each step's result is saved once, and a resumed run replays saved results instead of paying for them again.
import { pool } from "./db.ts";
export async function step<T>(runId: string, key: string, fn: () => Promise<T>): Promise<T> {
const saved = await pool.query<{ result: T }>("SELECT result FROM research_steps WHERE run_id = $1 AND step_key = $2", [runId, key]);
if (saved.rows[0]) return saved.rows[0].result;
const result = await fn();
await pool.query("INSERT INTO research_steps (run_id, step_key, result) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", [runId, key, JSON.stringify(result)]);
await pool.query("UPDATE research_runs SET lease_until = now() + interval '5 minutes' WHERE id = $1", [runId]);
return result;
}
export async function claimRun(): Promise<{ id: string; question: string } | null> {
const { rows } = await pool.query<{ id: string; question: string }>(
`UPDATE research_runs SET status = 'running', attempts = attempts + 1, lease_until = now() + interval '5 minutes'
WHERE id = (SELECT id FROM research_runs
WHERE status IN ('queued', 'running') AND (lease_until IS NULL OR lease_until < now()) AND attempts < 3
ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED)
RETURNING id, question`,
);
return rows[0] ?? null;
}3. A web-searching subagent#
Subagents use Anthropic's server-side tools: the API runs web search and page fetching itself, so there is no tool loop to write. Two details matter. Long server-tool turns can end with stop_reason: "pause_turn", which means "send the conversation back to continue"; and search results come back with citations that point at real URLs.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
export interface Source {
url: string;
title: string;
}
export interface Findings {
subtask: string;
summary: string;
sources: Source[];
tokens: number;
}
export async function researchSubtask(subtask: string, brief: string): Promise<Findings> {
const messages: Anthropic.MessageParam[] = [{ role: "user", content: brief }];
const sources = new Map<string, string>();
let tokens = 0;
let summary = "";
for (let i = 0; i < 4; i++) {
const message = await client.messages.create({
model: "claude-sonnet-5-5",
max_tokens: 8192,
system:
"You are a research subagent. Research only the brief you are given, using web search and page fetching. Prefer primary sources. Reply with at most 200 words of findings, and say clearly what you could not verify.",
tools: [
{ type: "web_search_20260318", name: "web_search", max_uses: 5 },
{ type: "web_fetch_20260318", name: "web_fetch", max_uses: 3 },
],
messages,
});
tokens += message.usage.input_tokens + message.usage.output_tokens;
messages.push({ role: "assistant", content: message.content });
for (const block of message.content) {
if (block.type !== "text") continue;
for (const c of block.citations ?? []) {
if (c.type === "web_search_result_location") sources.set(c.url, c.title ?? c.url);
}
}
summary = message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
if (message.stop_reason !== "pause_turn") break; // pause_turn: the server paused a long turn; continue it
}
return { subtask, summary, sources: [...sources].map(([url, title]) => ({ url, title })), tokens };
}4. The research pipeline#
The pipeline is three kinds of durable steps: plan, one subtask step per subagent (run in parallel), and report. A crash after two subagents finish resumes with only the remaining work.
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";
import { step } from "./durable.ts";
import { researchSubtask, type Findings } from "./subagent.ts";
const client = new Anthropic();
const MAX_SUBTASKS = 4;
const Plan = z.object({
subtasks: z
.array(z.object({ name: z.string(), brief: z.string().describe("Objective, what to look for, output, and boundaries") }))
.describe(`2 to ${MAX_SUBTASKS} independent subtasks`),
});
export async function runResearch(runId: string, question: string): Promise<string> {
const plan = await step(runId, "plan", async () => {
const res = await client.messages.parse({
model: "claude-opus-5-5",
max_tokens: 4096,
output_config: { effort: "medium", format: zodOutputFormat(Plan) },
system: "You plan research. Split the question into independent subtasks for parallel researchers. Each brief must stand alone.",
messages: [{ role: "user", content: question }],
});
if (!res.parsed_output) throw new Error(`No plan (stop_reason: ${res.stop_reason})`);
return res.parsed_output.subtasks.slice(0, MAX_SUBTASKS);
});
// Each subtask is its own durable step, so finished ones are never repeated.
const findings: Findings[] = await Promise.all(
plan.map((t, i) =>
step(runId, `subtask-${i}`, async () => {
try {
return await researchSubtask(t.name, t.brief);
} catch (error) {
return { subtask: t.name, summary: `FAILED: ${error instanceof Error ? error.message : String(error)}`, sources: [], tokens: 0 };
}
}),
),
);
return step(runId, "report", async () => {
// Number sources globally so the report can cite them as [1], [2], …
const all = [...new Map(findings.flatMap((f) => f.sources).map((s) => [s.url, s])).values()];
const numbered = all.map((s, i) => `[${i + 1}] ${s.title} (${s.url})`).join("\n");
const res = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 8192,
system:
"You write research reports from your researchers' findings. Cite sources as [n] using the numbered list. Do not add facts that are not in the findings. Call out disagreements and gaps.",
messages: [
{
role: "user",
content: `Question: ${question}\n\n${findings.map((f) => `<findings subtask="${f.subtask}">\n${f.summary}\n</findings>`).join("\n")}\n\n<sources>\n${numbered}\n</sources>`,
},
],
});
const body = res.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
return `${body}\n\n## Sources\n${numbered}`;
});
}5. Worker and API#
import { claimRun } from "./lib/durable.ts";
import { pool } from "./lib/db.ts";
import { runResearch } from "./lib/research.ts";
// One pass: claim and run every runnable job, then exit. Schedule it, or wrap it in a loop.
for (let run = await claimRun(); run; run = await claimRun()) {
console.log(`run ${run.id}: ${run.question}`);
try {
const report = await runResearch(run.id, run.question);
await pool.query("UPDATE research_runs SET status = 'done', report = $2, lease_until = NULL WHERE id = $1", [run.id, report]);
console.log(" done");
} catch (error) {
// Leave it claimable for a retry after the lease; after 3 attempts claimRun skips it.
await pool.query("UPDATE research_runs SET error = $2 WHERE id = $1", [run.id, error instanceof Error ? error.message : String(error)]);
console.error(" failed:", error);
}
}
await pool.end();import { z } from "zod";
import { pool } from "@/lib/db";
const Body = z.object({ question: z.string().trim().min(10).max(500) });
export async function POST(request: Request) {
const parsed = Body.safeParse(await request.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "Ask a question of 10 to 500 characters." }, { status: 400 });
const { rows } = await pool.query<{ id: string }>("INSERT INTO research_runs (question) VALUES ($1) RETURNING id", [parsed.data.question]);
return Response.json({ id: rows[0]!.id }, { status: 202 });
}import { pool } from "@/lib/db";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const run = await pool.query("SELECT id, question, status, report, error FROM research_runs WHERE id = $1", [id]);
if (!run.rows[0]) return Response.json({ error: "Not found" }, { status: 404 });
// Progress for the UI: which steps are finished.
const steps = await pool.query<{ step_key: string }>("SELECT step_key FROM research_steps WHERE run_id = $1 ORDER BY created_at", [id]);
return Response.json({ ...run.rows[0], completedSteps: steps.rows.map((s) => s.step_key) });
}The UI posts the question, then polls GET /api/research/:id every few seconds and shows the plan, each subtask's status, and finally the report with clickable sources. In production, add authentication and associate runs with users.
6. Limits and cost#
Research runs are the most expensive thing in this wiki. Put limits everywhere:
- Subtasks: at most 4 per run (
MAX_SUBTASKS). - Searches and fetches:
max_usesper subagent. - Words: 200-word findings keep the synthesis context small.
- Attempts: 3 per run, then it stays failed with its error.
- Per user: a daily number of research runs, checked in the POST route.
Record tokens per subtask (already returned by researchSubtask) and show the cost of each run in an admin view. You will quickly learn which questions are expensive.
Evaluating research quality#
Research quality is harder to grade than a classification. Useful checks:
- Citation validity: every
[n]in the report refers to an existing source, and sources are fetchable. - Grounding: an LLM judge checks a sample of sentences against the cited source's content.
- Coverage: for questions with known answers, a rubric of points the report must cover.
- Cost and time per run, as metrics alongside quality.
Stretch goals#
- Stream progress with server-sent events instead of polling.
- Let the lead agent run a second round of subtasks for gaps it finds in the first.
- Export the report as Markdown or PDF.
- Restrict search to trusted domains with
allowed_domainsfor specific use cases.
Common mistakes#
- Running research inside the request. It takes minutes; use a worker and return a run ID.
- Not handling
pause_turn. Long server-tool turns pause; continue them or lose the work. - Passing full transcripts to the lead. Pass short, cited findings.
- Unchecked citations. A report with broken or made-up references destroys trust. Validate them.
- No limits. One curious user can spend your monthly budget in an afternoon.
Try it
Run it on a real question
- Create the schema, start the API, and submit "What are the trade-offs between pgvector and dedicated vector databases for a 10-million-document RAG system?".
- Run the worker, and kill it after the first subtask step is saved. Run it again and confirm it resumes without repeating finished steps.
- Check every citation in the report by hand. Write down the cost of the run from the tokens recorded.
Check your understanding
Key takeaways
- Run research in a durable worker with checkpointed steps; the API returns a run ID and progress.
- A lead agent plans with structured output; parallel subagents use server-side web search and return short, cited findings.
- Handle
pause_turn, number sources globally, and validate citations. - Limit subtasks, searches, length, attempts, and runs per user, and track cost per run.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Multi-Agent Systems: Orchestrators, Subagents, and When to Use ThemBuild an orchestrator that delegates to parallel subagents in TypeScript, write good briefs, control token costs, and learn when multiple agents beat a single one.
- Agent Memory, State, and Durable Execution with PostgresGive agents memory that lasts and runs that survive crashes: memory tiers, checkpointed steps in Postgres, leases, and idempotent side effects.
- Project: Build a Docs Q&A Assistant with RAG, Citations, and EvalsBuild a documentation assistant: incremental ingestion with content hashes, hybrid search and reranking, streamed answers with source links, feedback, and evals.