Skip to content
AI Full-Stack Wiki

Tutorial: Build a Multi-Agent Migration Planner in TypeScript

Build an orchestrator that splits a codebase audit across scoped subagents with structured findings, then measure coverage and cost against a single agent.

Difficulty: Advanced26 min readUpdated
On this page· What you will build

Why this matters

Multi-agent systems explains when an orchestrator with subagents beats a single agent: the work splits into independent parts, each needs a lot of reading, and the result is worth the extra tokens. This tutorial puts that claim to a measurable test. A lead agent plans an audit of a codebase migration, one subagent per service reads only its own directory and returns structured findings, and code merges them into a plan. Because the codebase is generated, you know the true answer, so you can measure exactly what each design found and what it cost.

What you will build#

approach         found  calls   tokens  peak prompt       ms
single agent     40/50      3    7,481        3,369      392
multi-agent      50/50     17   21,568        1,813      834
  lead: 2 calls, 2,641 tokens; subagents: 15 calls, 18,927 tokens
  • A sample repository of five services with 50 calls to an old HTTP client, legacyHttp, to migrate to fetch.
  • Scoped tools: each subagent can only search and read its own service.
  • A findings schema, the contract between subagents and the lead, so code can merge and count results.
  • An orchestrator that plans, checks the plan against the real directories, runs subagents three at a time, turns failures into gaps, and writes the plan.
  • A single-agent baseline with the same tools and model, and a comparison against ground truth.
Multi-agent migration plannerThe lead plans one subtask per service from the directory tree; code checks the plan against the real directories. Scoped subagents audit their own service in parallel, three at a time, and submit structured findings. Code builds the counts table and the lead writes the phases. A single agent with the same tools is the baseline, and both are scored against a scan of the code.subtasksLeadplan, then writePlan checkreal directoriesSubagentservices/billingSubagentservices/ordersSubagentone per serviceMigration plantable from code

Hover or tap a component to see what it does.

Migration planner: the lead plans, code checks the plan, scoped subagents audit services in parallel, and code merges their structured findings into the plan.
Show text description

Each subagent sees one service; the lead sees only the tree and the findings.

  • Lead: Plans from the directory tree, never the code, and writes the phases from the findings.
  • Plan check: Drops invented directories, adds forgotten ones, and runs subagents three at a time.
  • Subagent: Tools confined to one service; submits findings that match the schema.
  • Subagent: A fresh context: its prompts stay small however large the repository is.
  • Subagent: A failed or silent subagent becomes a reported gap.
  • Migration plan: Exact counts computed from findings, plus the lead's phases, scored against ground truth.

You need Node.js 22.18 or later and an Anthropic API key.

1. Set up the project#

mkdir migration-planner && cd migration-planner
npm init -y
npm pkg set type=module
npm install @anthropic-ai/sdk zod
npm install -D typescript @types/node
mkdir src test scripts
npm pkg set scripts.make-repo="node scripts/make-repo.ts" scripts.compare="node --env-file=.env src/compare.ts" scripts.test="node --test" scripts.typecheck="tsc"

Copy the tsconfig.json from Foundations Tutorial 1, add "scripts" to its include list, and add your key to .env.

2. A codebase with a known answer#

A generated repository means the ground truth is known: every call site, its file, and its line.

scripts/make-repo.ts
// Generates sample-repo/: five services that call an old HTTP client, legacyHttp.
import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
 
type Call = "get" | "post" | "stream";
// Per service: files, each with the calls it makes, in order.
const SERVICES: Record<string, Record<string, Call[]>> = {
  billing: { "invoices.ts": ["get", "get", "post", "get"], "payments.ts": ["post", "post", "get", "stream"], "refunds.ts": ["get", "post", "post", "get"] },
  catalog: { "products.ts": ["get", "get", "get", "post"], "search.ts": ["get", "get"], "prices.ts": ["get", "post", "get"] },
  notifications: { "email.ts": ["post", "post", "get"], "push.ts": ["post", "stream", "get"], "inbox.ts": ["get", "stream", "get", "post"] },
  orders: { "cart.ts": ["get", "post", "post"], "checkout.ts": ["post", "get", "post", "get"], "history.ts": ["get", "get", "get", "get"] },
  users: { "profile.ts": ["get", "post", "get"], "sessions.ts": ["post", "get"], "teams.ts": ["get", "get", "post"] },
};
 
const root = "sample-repo";
await rm(root, { recursive: true, force: true });
await mkdir(path.join(root, "lib"), { recursive: true });
await writeFile(path.join(root, "lib", "legacy-http.ts"), "// The old HTTP client being retired.\nexport const legacyHttp = { get: async (url: string) => url, post: async (url: string, body: unknown) => body, stream: async (url: string) => url };\n");
 
let total = 0;
for (const [service, files] of Object.entries(SERVICES)) {
  await mkdir(path.join(root, "services", service), { recursive: true });
  for (const [file, calls] of Object.entries(files)) {
    const lines = [`import { legacyHttp } from "../../lib/legacy-http.ts";`, ""];
    calls.forEach((call, i) => {
      const name = `${file.replace(".ts", "")}Step${i + 1}`;
      const args = call === "post" ? `"/${service}/${name}", { id }` : `"/${service}/${name}/" + id`;
      lines.push(`export async function ${name}(id: string) {`, `  return legacyHttp.${call}(${args});`, "}", "");
      total++;
    });
    await writeFile(path.join(root, "services", service, file), lines.join("\n"));
  }
}
console.log(`wrote ${root}/ with ${total} legacyHttp calls`);
npm run make-repo   # wrote sample-repo/ with 50 legacyHttp calls

The three stream calls matter: fetch has no drop-in replacement for them, so a good plan must flag them as blockers.

3. Scoped tools#

Each subagent gets tools that only see its own directory. That keeps subagents from duplicating each other's work, and it is the same least-privilege rule as in the repo explorer, applied per agent.

src/repo.ts
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";
 
const isOutside = (rel: string) => rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
const posix = (p: string) => p.split(path.sep).join("/");
 
/** Source files under `dir` (relative to root), sorted, as POSIX paths. */
export async function listSources(root: string, dir = "."): Promise<string[]> {
  const entries = await readdir(path.join(root, dir), { recursive: true });
  return entries
    .filter((e) => e.endsWith(".ts"))
    .map((e) => posix(path.join(dir, e)))
    .sort();
}
 
/** The service directories, such as services/billing. */
export async function serviceDirs(root: string): Promise<string[]> {
  const entries = await readdir(path.join(root, "services"), { withFileTypes: true });
  return entries.filter((e) => e.isDirectory()).map((e) => `services/${e.name}`).sort();
}
 
/**
 * Read-only tools confined to `scope`, a directory inside `root`. A subagent gets the tools for
 * its own service only, so it cannot wander into work that belongs to another subagent.
 */
export function repoTools(root: string, scope: string) {
  const scopeAbs = path.resolve(root, scope);
  const inScope = (requested: string) => {
    const abs = path.resolve(root, requested);
    if (isOutside(path.relative(scopeAbs, abs))) throw new Error(`"${requested}" is outside your scope (${scope}).`);
    return posix(path.relative(root, abs)) || ".";
  };
 
  const search = betaZodTool({
    name: "search",
    description: `Search .ts files under a directory for a literal string. Returns up to 40 matches as path:line: text. Your scope is ${scope}.`,
    inputSchema: z.object({ query: z.string().min(2), dir: z.string().default(scope) }),
    run: async ({ query, dir }) => {
      const hits: string[] = [];
      for (const file of await listSources(root, inScope(dir))) {
        (await readFile(path.join(root, file), "utf8")).split("\n").forEach((line, i) => {
          if (line.includes(query)) hits.push(`${file}:${i + 1}: ${line.trim()}`);
        });
      }
      if (hits.length > 40) return `${hits.slice(0, 40).join("\n")}\n[${hits.length - 40} more matches not shown; search a smaller directory]`;
      return hits.join("\n") || `No matches for "${query}" in ${dir}.`;
    },
  });
 
  const readFileTool = betaZodTool({
    name: "read_file",
    description: "Read a file with line numbers, at most 120 lines per call.",
    inputSchema: z.object({ path: z.string(), start_line: z.number().int().min(1).default(1) }),
    run: async ({ path: requested, start_line }) => {
      const rel = inScope(requested);
      const lines = (await readFile(path.join(root, rel), "utf8")).split("\n");
      return lines
        .slice(start_line - 1, start_line + 119)
        .map((l, i) => `${start_line + i}| ${l}`)
        .join("\n");
    },
  });
 
  return [search, readFileTool] as const;
}

The search result cap matters for what follows: a search over the whole repository returns 40 of the 50 matches and says that more were cut off.

4. The findings contract#

Subagents do not return prose. They return a structure that code can validate, merge, and count.

src/findings.ts
import { z } from "zod";
 
export const GOAL = "Plan the migration of every legacyHttp call (get, post, stream) to the built-in fetch API.";
 
// The contract between subagents and the lead: structured, so code can merge and count it.
export const Usage = z.object({
  file: z.string(),
  line: z.number().int(),
  call: z.enum(["get", "post", "stream"]),
  change: z.string().describe("The replacement, in one line"),
});
 
export const ServiceFindings = z.object({
  service: z.string().describe("The service directory, such as services/billing"),
  usages: z.array(Usage).describe("Every legacyHttp call in the service"),
  blockers: z.array(z.string()).describe("Calls with no direct fetch equivalent, and why"),
  risk: z.enum(["low", "medium", "high"]),
});
export type ServiceFindings = z.infer<typeof ServiceFindings>;
 
/** Tokens and size of one agent run. `peakInput` is the largest single prompt it sent. */
export interface RunStats {
  calls: number;
  tokens: number;
  peakInput: number;
}
 
export const emptyStats = (): RunStats => ({ calls: 0, tokens: 0, peakInput: 0 });
 
export function addUsage(stats: RunStats, usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null }) {
  const input = usage.input_tokens + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
  stats.calls++;
  stats.tokens += input + usage.output_tokens;
  stats.peakInput = Math.max(stats.peakInput, input);
}
 
export const AUDITOR = `You are a migration auditor. Find every call to legacyHttp (get, post, stream) in your scope.
Search first, read files only when a match needs context, and do not guess line numbers.
For each call give the fetch replacement in one line. stream has no direct fetch equivalent: list it as a blocker.
When you have covered your whole scope, call submit_findings once.`;

peakInput, the largest single prompt an agent sent, is the number that shows context isolation at work: each subagent's prompts stay small, however large the whole job is.

5. A subagent#

src/subagent.ts
import Anthropic from "@anthropic-ai/sdk";
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { addUsage, AUDITOR, emptyStats, type RunStats, ServiceFindings } from "./findings.ts";
import { repoTools } from "./repo.ts";
 
export interface AuditResult {
  scope: string;
  findings: ServiceFindings | null; // null: the subagent failed or never submitted
  error?: string;
  stats: RunStats;
}
 
/** One subagent: a fresh context, tools limited to its directory, and a structured result. */
export async function runAuditor(root: string, scope: string, brief: string, client = new Anthropic()): Promise<AuditResult> {
  const stats = emptyStats();
  let findings: ServiceFindings | null = null;
  const submit = betaZodTool({
    name: "submit_findings",
    description: "Submit your findings. Call it once, after you have covered your whole scope.",
    inputSchema: ServiceFindings,
    run: async (input) => {
      findings = input;
      return "Submitted.";
    },
  });
 
  const runner = client.beta.messages.toolRunner({
    model: "claude-sonnet-5-5", // focused reading: a faster model is enough
    max_tokens: 8192,
    max_iterations: 8,
    output_config: { effort: "low" },
    system: AUDITOR,
    tools: [...repoTools(root, scope), submit],
    messages: [{ role: "user", content: brief }],
  });
  for await (const message of runner) addUsage(stats, message.usage);
 
  const result = findings as ServiceFindings | null;
  return result ? { scope, findings: result, stats } : { scope, findings: null, error: "finished without submitting findings", stats };
}

6. The orchestrator#

src/orchestrator.ts
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";
import { addUsage, emptyStats, GOAL, type RunStats, type ServiceFindings } from "./findings.ts";
import { listSources, serviceDirs } from "./repo.ts";
import { type AuditResult, runAuditor } from "./subagent.ts";
 
const Plan = z.object({
  subtasks: z
    .array(z.object({ dir: z.string().describe("A directory from the tree"), brief: z.string() }))
    .describe("One subtask per service directory"),
});
 
const briefFor = (dir: string) =>
  `Objective: audit ${dir} for the migration below.\n${GOAL}\nScope: only files under ${dir}; other services are audited by other agents.\n` +
  `Output: call submit_findings with service "${dir}", every usage with its file and line, blockers, and a risk level.`;
 
// Run async work with at most `limit` tasks in flight.
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
  const results: R[] = new Array(items.length);
  let next = 0;
  const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (next < items.length) {
      const i = next++;
      results[i] = await fn(items[i]!);
    }
  });
  await Promise.all(workers);
  return results;
}
 
export async function planMigration(root: string, client = new Anthropic()) {
  const lead = emptyStats();
  const dirs = await serviceDirs(root);
  const tree = (await Promise.all(dirs.map(async (d) => `${d}/ (${(await listSources(root, d)).length} files)`))).join("\n");
 
  // 1. Plan. The lead sees the tree, not the code.
  const planned = await client.messages.parse({
    model: "claude-opus-5-5",
    max_tokens: 4096,
    output_config: { effort: "low", format: zodOutputFormat(Plan) },
    system: "You are the lead engineer planning a codebase migration. Split the audit into one subtask per service directory. Each brief must stand alone: the subagent sees nothing else.",
    messages: [{ role: "user", content: `${GOAL}\n<tree>\n${tree}\n</tree>` }],
  });
  addUsage(lead, planned.usage);
 
  // 2. Check the plan against what exists: drop invented directories, add forgotten ones.
  const proposed = (planned.parsed_output?.subtasks ?? []).filter((t) => dirs.includes(t.dir));
  const subtasks = dirs.map((dir) => proposed.find((t) => t.dir === dir) ?? { dir, brief: briefFor(dir) });
 
  // 3. Delegate: subagents in parallel, three at a time. A failure becomes a gap, not a crash.
  const audits = await mapLimit(subtasks, 3, async (t): Promise<AuditResult> => {
    try {
      return await runAuditor(root, t.dir, t.brief, client);
    } catch (error) {
      return { scope: t.dir, findings: null, error: error instanceof Error ? error.message : String(error), stats: emptyStats() };
    }
  });
  const findings = audits.flatMap((a) => (a.findings ? [a.findings] : []));
  const gaps = audits.filter((a) => !a.findings).map((a) => `${a.scope}: ${a.error}`);
 
  // 4. Synthesize from the structured findings only, never the subagents' transcripts.
  const written = await client.messages.create({
    model: "claude-opus-5-5",
    max_tokens: 8192,
    output_config: { effort: "medium" },
    system: "You are the lead engineer writing a migration plan. Order the work in phases, lowest risk first, and explain each blocker. The counts are already in a table; do not repeat them.",
    messages: [{ role: "user", content: `${GOAL}\n<findings>\n${JSON.stringify(findings)}\n</findings>\n<gaps>\n${gaps.join("\n") || "none"}\n</gaps>` }],
  });
  addUsage(lead, written.usage);
  const narrative = written.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
 
  const subagents = audits.reduce<RunStats>((s, a) => ({ calls: s.calls + a.stats.calls, tokens: s.tokens + a.stats.tokens, peakInput: Math.max(s.peakInput, a.stats.peakInput) }), emptyStats());
  return { markdown: render(findings, gaps, narrative), findings, gaps, stats: { lead, subagents } };
}
 
/** Counts come from code, so they are exact; the model writes only the narrative. */
function render(findings: ServiceFindings[], gaps: string[], narrative: string): string {
  const rows = findings.map((f) => {
    const n = (c: string) => f.usages.filter((u) => u.call === c).length;
    return `| ${f.service} | ${f.usages.length} | ${n("get")} / ${n("post")} / ${n("stream")} | ${f.risk} | ${f.blockers.length} |`;
  });
  return [
    "# Migration plan: legacyHttp to fetch",
    "",
    "| Service | Calls | get / post / stream | Risk | Blockers |",
    "| --- | --- | --- | --- | --- |",
    ...rows,
    ...(gaps.length ? ["", `**Not audited:** ${gaps.join("; ")}`] : []),
    "",
    narrative,
    "",
  ].join("\n");
}

The orchestrator does four things a naive one skips:

  • It checks the plan against reality. A directory the lead invented is dropped; a directory it forgot gets a default brief. Code knows the list of services; the model only writes the briefs.
  • It caps concurrency at three subagents, which bounds rate-limit pressure and cost spikes.
  • It turns failures into gaps. A subagent that crashes or never submits shows up under "Not audited" instead of silently disappearing from the plan.
  • It splits the work between code and model. Counts in the table are computed from the findings, so they are exact. The lead writes only what needs judgment: the phases and the explanation of blockers.

7. The single-agent baseline#

Same tools, same model, same instructions; the only difference is one agent with the whole repository as its scope.

src/single.ts
import Anthropic from "@anthropic-ai/sdk";
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";
import { addUsage, AUDITOR, emptyStats, GOAL, ServiceFindings } from "./findings.ts";
import { repoTools } from "./repo.ts";
 
/** The baseline: one agent, the same tools and model as a subagent, the whole repository as its scope. */
export async function auditWithOneAgent(root: string, client = new Anthropic()) {
  const stats = emptyStats();
  let findings: ServiceFindings[] = [];
  const submit = betaZodTool({
    name: "submit_findings",
    description: "Submit findings for every service. Call it once, after you have covered the whole repository.",
    inputSchema: z.object({ services: z.array(ServiceFindings) }),
    run: async (input) => {
      findings = input.services;
      return "Submitted.";
    },
  });
 
  const runner = client.beta.messages.toolRunner({
    model: "claude-sonnet-5-5",
    max_tokens: 16_000,
    max_iterations: 20,
    output_config: { effort: "low" },
    system: AUDITOR,
    tools: [...repoTools(root, "."), submit],
    messages: [{ role: "user", content: `${GOAL}\nScope: the whole repository. Group your findings by service directory (services/<name>).` }],
  });
  for await (const message of runner) addUsage(stats, message.usage);
  return { findings, stats };
}

8. Measure against the truth#

src/truth.ts
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { ServiceFindings } from "./findings.ts";
import { listSources } from "./repo.ts";
 
/** Ground truth from a plain scan, so coverage is measured, not estimated. */
export async function groundTruth(root: string): Promise<Set<string>> {
  const truth = new Set<string>();
  for (const file of await listSources(root, "services")) {
    (await readFile(path.join(root, file), "utf8")).split("\n").forEach((line, i) => {
      if (/legacyHttp\.(get|post|stream)\(/.test(line)) truth.add(`${file}:${i + 1}`);
    });
  }
  return truth;
}
 
/** How many true call sites the findings reported, by file and line. */
export function coverage(findings: ServiceFindings[], truth: Set<string>): number {
  const found = new Set(findings.flatMap((f) => f.usages.map((u) => `${u.file}:${u.line}`)));
  return [...truth].filter((key) => found.has(key)).length;
}
src/compare.ts
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import type { RunStats } from "./findings.ts";
import { planMigration } from "./orchestrator.ts";
import { auditWithOneAgent } from "./single.ts";
import { coverage, groundTruth } from "./truth.ts";
 
const root = path.resolve(process.argv[2] ?? "sample-repo");
const truth = await groundTruth(root);
 
const row = (name: string, found: number, s: RunStats, ms: number) =>
  console.log(
    `${name.padEnd(14)}${`${found}/${truth.size}`.padStart(8)}${String(s.calls).padStart(7)}${s.tokens.toLocaleString("en-US").padStart(9)}` +
      `${s.peakInput.toLocaleString("en-US").padStart(13)}${Math.round(ms).toLocaleString("en-US").padStart(9)}`,
  );
console.log(`${"approach".padEnd(14)}${"found".padStart(8)}${"calls".padStart(7)}${"tokens".padStart(9)}${"peak prompt".padStart(13)}${"ms".padStart(9)}`);
 
let started = performance.now();
const single = await auditWithOneAgent(root);
row("single agent", coverage(single.findings, truth), single.stats, performance.now() - started);
 
started = performance.now();
const multi = await planMigration(root);
const ms = performance.now() - started;
const { lead, subagents } = multi.stats;
const total = { calls: lead.calls + subagents.calls, tokens: lead.tokens + subagents.tokens, peakInput: Math.max(lead.peakInput, subagents.peakInput) };
row("multi-agent", coverage(multi.findings, truth), total, ms);
console.log(`  lead: ${lead.calls} calls, ${lead.tokens.toLocaleString("en-US")} tokens; subagents: ${subagents.calls} calls, ${subagents.tokens.toLocaleString("en-US")} tokens`);
 
await mkdir("out", { recursive: true });
await writeFile("out/migration-plan.md", multi.markdown);
console.log("\nwrote out/migration-plan.md");
npm run compare
approach         found  calls   tokens  peak prompt       ms
single agent     40/50      3    7,481        3,369      392
multi-agent      50/50     17   21,568        1,813      834
  lead: 2 calls, 2,641 tokens; subagents: 15 calls, 18,927 tokens
 
wrote out/migration-plan.md

And the plan it wrote:

out/migration-plan.md
# Migration plan: legacyHttp to fetch
 
| Service | Calls | get / post / stream | Risk | Blockers |
| --- | --- | --- | --- | --- |
| services/billing | 12 | 6 / 5 / 1 | high | 1 |
| services/catalog | 9 | 7 / 2 / 0 | medium | 0 |
| services/notifications | 10 | 4 / 4 / 2 | high | 2 |
| services/orders | 11 | 7 / 4 / 0 | medium | 0 |
| services/users | 8 | 5 / 3 / 0 | medium | 0 |
 
**Phase 1 (medium risk): catalog, orders, users.** Posts need an explicit method, a JSON body, and a content-type header; start with the services that have the fewest.
 
**Phase 2 (high risk): billing, notifications.** Build one shared stream adapter over response.body first, then migrate the stream calls behind it.

9. Read the comparison#

  • Coverage. The single agent's one broad search hit the result cap, and it submitted what it had. Each subagent searched only its own service, which always fit. Splitting the work kept every piece within the limits of one context and one tool result.
  • Tokens. The multi-agent run used about three times as many tokens: five subagents each pay for their own system prompt, tool definitions, and conversation, plus the lead's two calls. This matches the article's warning; on bigger tasks the ratio is often higher.
  • Peak prompt. The largest prompt the multi-agent system sent was about half the single agent's. On a real codebase, this is the difference between a prompt that fits comfortably and one that crowds out the instructions.
  • Latency. Five subagents at a concurrency of three run in two waves, so the multi-agent run was slower here. Raise the limit and it gets faster, at the cost of more simultaneous requests.

The decision rule from the article holds up: split when the parts are independent and each needs a lot of reading, and when complete coverage is worth several times the tokens. For a small repository, one agent that narrows its searches is enough.

10. Test the orchestration#

The subagents run in parallel, so the fake client answers each request by looking at it, as in the postmortem tutorial. This test makes the lead invent a directory and forget another, and makes one subagent fail.

test/planner.test.ts
import assert from "node:assert/strict";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
import Anthropic from "@anthropic-ai/sdk";
import { planMigration } from "../src/orchestrator.ts";
import { repoTools } from "../src/repo.ts";
import { coverage, groundTruth } from "../src/truth.ts";
 
// A tiny repository: three services, one legacyHttp call each, on line 2.
const root = await mkdtemp(path.join(tmpdir(), "planner-"));
for (const service of ["alpha", "beta", "gamma"]) {
  await mkdir(path.join(root, "services", service), { recursive: true });
  await writeFile(path.join(root, "services", service, "api.ts"), `// ${service}\nexport const load = () => legacyHttp.get("/${service}");\n`);
}
 
type Body = { system: string; messages: { role: string; content: string | { type: string }[] }[]; output_config?: { format?: unknown } };
 
/** A real client whose HTTP layer answers each request with `respond(body)`, whatever the order. */
function fakeClient(respond: (body: Body) => Record<string, unknown>[]) {
  const bodies: Body[] = [];
  const client = new Anthropic({
    apiKey: "test-key",
    maxRetries: 0,
    fetch: async (_url, init) => {
      const body = JSON.parse(String(init?.body)) as Body;
      bodies.push(body);
      const content = respond(body);
      return Response.json({
        id: `msg_${bodies.length}`,
        type: "message",
        role: "assistant",
        model: "claude-opus-5-5",
        stop_reason: content.some((b) => b.type === "tool_use") ? "tool_use" : "end_turn",
        stop_sequence: null,
        usage: { input_tokens: 100, output_tokens: 20 },
        content,
      });
    },
  });
  return { client, bodies };
}
 
const text = (t: string) => [{ type: "text", text: t }];
 
test("subagent tools are confined to their scope", async () => {
  const [search, read] = repoTools(root, "services/alpha");
  assert.match(String(await search.run({ query: "legacyHttp", dir: "services/alpha" })), /services\/alpha\/api.ts:2:/);
  await assert.rejects(Promise.resolve(search.run({ query: "legacyHttp", dir: "services/beta" })), /outside your scope/);
  await assert.rejects(Promise.resolve(read.run({ path: "services/alpha/../beta/api.ts", start_line: 1 })), /outside your scope/);
});
 
test("the lead's plan is checked, subagents run, a failure becomes a gap, and counts come from code", async () => {
  const { client, bodies } = fakeClient((body) => {
    if (body.system.startsWith("You are the lead engineer planning")) {
      // The plan invents a directory and forgets gamma.
      return text(JSON.stringify({ subtasks: [{ dir: "services/alpha", brief: "Audit services/alpha" }, { dir: "services/beta", brief: "Audit services/beta" }, { dir: "services/payments", brief: "?" }] }));
    }
    if (body.system.startsWith("You are the lead engineer writing")) return text("Phase 1: alpha.");
    // Subagents: submit on the first turn, then finish. beta never submits.
    const brief = String(body.messages[0]!.content);
    const scope = /services\/\w+/.exec(brief)![0];
    if (body.messages.length > 1) return text("Done.");
    if (scope === "services/beta") return text("I looked around.");
    const usage = { file: `${scope}/api.ts`, line: 2, call: "get", change: "fetch(url)" };
    return [{ type: "tool_use", id: `t_${scope}`, name: "submit_findings", input: { service: scope, usages: [usage], blockers: [], risk: "low" } }];
  });
 
  const result = await planMigration(root, client);
 
  const briefs = bodies.filter((b) => b.system.startsWith("You are a migration auditor")).map((b) => String(b.messages[0]!.content));
  assert.ok(!briefs.some((b) => b.includes("payments")), "the invented directory was not delegated");
  assert.ok(briefs.some((b) => b.includes("services/gamma")), "the forgotten directory was added");
  assert.deepEqual(
    result.findings.map((f) => f.service),
    ["services/alpha", "services/gamma"],
  );
  assert.deepEqual(result.gaps, ["services/beta: finished without submitting findings"]);
  assert.match(result.markdown, /\| services\/alpha \| 1 \| 1 \/ 0 \/ 0 \| low \| 0 \|/);
  assert.match(result.markdown, /Not audited:\*\* services\/beta/);
  assert.equal(coverage(result.findings, await groundTruth(root)), 2);
});
npm test   # 2 passing, no API calls

Common mistakes#

  • Trusting the lead's plan. Plans name directories, files, or tasks that do not exist, and skip ones that do. Check the plan against what code can list.
  • Prose findings. A lead that must parse paragraphs miscounts and drops items. Make subagents submit a schema, and compute totals in code.
  • Overlapping scopes. Two subagents reading the same files double the cost and produce duplicate findings. Give each a scope its tools enforce.
  • Losing failed subagents. A failure that is silently skipped looks like "no work needed here". Report it as a gap.
  • Judging the design by the answer's tone. A confident plan can still miss a fifth of the call sites. Measure coverage against ground truth whenever you can build one.

Try it

Extend the planner

  1. Add a verification step: after the subagents finish, have code re-scan each reported file:line and drop any usage that is not really there.
  2. Raise the concurrency to five and compare latency and peak request rate.
  3. Give the single agent a better instruction ("if a result says matches were cut off, search each service separately") and see whether it closes the coverage gap at a lower cost.
  4. Add a sixth service with 60 calls of its own, so even one subagent's search is cut off, and decide how the orchestrator should split it.

Check your understanding

The lead's plan includes services/payments, which does not exist, and leaves out services/users. What should the orchestrator do?
Why do subagents return structured findings instead of a summary paragraph?
The multi-agent run used about three times the tokens. When is that worth it?

Key takeaways

  • Give each subagent a scope its tools enforce, a standalone brief, and a schema to submit.
  • Check the lead's plan against what code can list, cap concurrency, and turn failed subagents into reported gaps.
  • Compute counts and tables in code from structured findings; let the model write only the judgment.
  • Decide between one agent and many by measuring coverage, tokens, peak prompt size, and latency against a known answer.

Next steps#

Finished this article? Track it to see your progress on the roadmap.