A Multi-Agent Research Team

Assemble a planner that spawns researcher and writer subagents into one synthesised report.

This recipe builds a research team: one question goes in, a synthesised report comes out. A planner decomposes the question, spawns researcher and writer children through the auto-injected task tool, and composes their validated outputs.

Level 6 of 7. Many agents, one run. It assumes you have read Level 5 (memory) — this page layers subagents, a static registry, fan-out caps, shared global memory, a per-run tracer, and storage for child lineage on top of what you already know.

Three specs

The planner holds no tools of its own — its only lever is task. The two children own narrow schemas. Write a one-line description on each child: the harness surfaces it to the planner’s subagent picker, so a good hint is the difference between the model choosing the right child or guessing.

team.ts
ts
import { Type } from "@sinclair/typebox";
import { defineAgent } from "@drover/core";

const researcher = defineAgent({
  id: "researcher",
  description: "Gathers concise factual points on one narrow sub-topic.",
  systemPrompt:
    "Given a narrow sub-topic, return 3-5 concise factual points " +
    "(one sentence each). No preamble, no synthesis.",
  inputSchema: Type.Object({ prompt: Type.String() }),
  outputSchema: Type.Object({
    points: Type.Array(Type.String(), { minItems: 3, maxItems: 5 }),
  }),
  model: "mini",
  tools: [],
  quota: { maxTurns: 2 },
});

const writer = defineAgent({
  id: "writer",
  description: "Turns research notes into one polished prose section.",
  systemPrompt:
    "Given a heading prompt and research notes, write one tight prose " +
    "section (120-250 words). No headings, no bullet lists.",
  inputSchema: Type.Object({
    prompt: Type.String(),
    notes: Type.Array(Type.String()),
  }),
  outputSchema: Type.Object({ section: Type.String() }),
  model: "mini",
  tools: [],
  quota: { maxTurns: 2 },
});

const planner = defineAgent({
  id: "planner",
  description: "Decomposes a question, dispatches subagents, synthesises a report.",
  systemPrompt: [
    "You answer a question by orchestrating a team.",
    "1. Split the question into 2-3 focused sub-topics.",
    "2. For each, call `task` with agent_type 'researcher' (pass the",
    "   sub-topic as prompt) to collect points.",
    "3. For each sub-topic, call `task` with agent_type 'writer', passing",
    "   input { prompt, notes } where notes are the researcher's points.",
    "4. Concatenate the sections into the final report.",
  ].join(" "),
  inputSchema: Type.Object({ question: Type.String() }),
  outputSchema: Type.Object({
    report: Type.String({ minLength: 100 }),
    sources_used: Type.Integer({ minimum: 0 }),
    subtopics: Type.Array(Type.String(), { minItems: 2, maxItems: 3 }),
  }),
  model: "sonnet",
  tools: [],
  subagents: { allowed: ["researcher", "writer"], depth: 2, fanOut: 3 },
  quota: { maxTurns: 10 },
});

The planner runs on sonnet because orchestration and synthesis are the quality-sensitive step; the children run on mini. The task call can also override per-spawn — task({ agent_type, prompt, model: "haiku" }).

The task tool shape

spec.subagents auto-injects task into the planner. Its call shape:

ts
task({
  agent_type: "researcher",   // must be in subagents.allowed
  prompt: "How are agent harnesses converging in 2026?",
  input?: { prompt, notes },  // full inputSchema match (writer needs this)
  model?: "haiku",            // per-spawn override
  max_turns?: 3,             // per-spawn quota
})

If input is omitted, the child receives { prompt }. That matches researcher (its inputSchema is just { prompt }), so the planner can spawn researchers with prompt alone. writer needs notes too, so the planner must pass input explicitly there.

Wire the registry, memory, tracer, and storage

A spec that declares subagents is inert without a registry — the harness resolves agent_type strings through it. staticRegistry is required here.

run.ts
ts
import { runAgent, staticRegistry } from "@drover/facade";
import { createMarkdownMemory } from "@drover/memory";
import { createLibsqlStorage } from "@drover/storage";
import { stepTracerPlugin } from "@drover/plugins";
import { researcher, writer, planner } from "./team";

const registry = staticRegistry({ researcher, writer });

// Shared store: global entries reach the whole tree.
const memory = await createMarkdownMemory({ root: "./.memory" });

// Persists every run + its children with parent_run_id lineage.
const storage = await createLibsqlStorage({ url: "file:./var/runs.db" });

// Fresh tracer PER RUN — stateful, never share across runs.
const tracer = stepTracerPlugin();

const handle = runAgent(
  planner,
  { question: "How are agent harnesses evolving in 2026?" },
  {
    agentRegistry: registry,
    memory,
    storage,
    plugins: [tracer.plugin],
  },
);

Read the parent stream, then the result

subagent_start and subagent_end fire on the parent stream. The children’s internal events (their turn_start, llm_call, etc.) are not mirrored up — too noisy. The parent stream is your team’s-eye view.

ts
for await (const e of handle.events) {
  if (e.kind === "subagent_start") {
    console.log("spawn", e.childRunId, "as", e.agentId);
  } else if (e.kind === "subagent_end") {
    console.log("done ", e.childRunId, "status:", e.status);
  }
}

const r = await handle.result;
if (r.status === "success") {
  console.log(r.output.subtopics);     // ["...", "..."]
  console.log(r.output.sources_used);  // count the planner reports
  console.log(r.output.report);        // synthesised prose
}

// The tracer captured the full timeline of this run.
console.log(tracer.steps.filter((s) => s.kind === "subagent"));

Child run ids are derived from the parent: the first spawn is parent:1, the second parent:2. A grandchild (allowed because depth: 2) would be parent:2:1. The tracer records each subagent_start/subagent_end as a subagent step carrying the childRunId in its meta.

r.output is undefined unless r.status === "success" — the promise never rejects. Branch on status.

Enforcement is a tool error, not a crash

The three subagents limits surface as task tool errors the planner’s model can read and adapt to — never run crashes:

LimitTriggerSubagentLimitError reason
allowedagent_type not in the allowlist"not_allowed"
depthchild would exceed depth (default 2)"depth"
fanOutmore than fanOut children in flight (default 3)"fan_out"

If the planner asks for a summariser you never registered, it gets a not_allowed tool result and can re-plan — the run keeps going.

What the children see

The shared adapter reaches the whole tree, but scope isolation is automatic:

  • A child reads global memory plus its own agent-scoped entries (keyed by the child’s spec.id). It never sees the planner’s agent-scoped memory.
  • Put cross-cutting facts (“the operator wants metric units”) at global scope to reach planner, researcher, and writer alike.

Children inherit the parent’s cwd, env, signal (abort cascades down), meta, and sandbox; they get a fresh suffixed runId, their own depth, and a parentRunId. With storage wired, each child lands in its own runs row with parent_run_id set — the eval viewer renders the lineage tree.

What you assembled

  • defineAgent ×3 — one planner, two leaf children with narrow schemas and description hints for the picker.
  • subagents: { allowed, depth, fanOut } — the planner’s only lever; auto-injects task.
  • staticRegistry({ researcher, writer }) — resolves agent_type strings; required whenever a spec uses subagents.
  • createMarkdownMemory — one shared store; global reaches the tree, agent scope stays private per child.
  • stepTracerPlugin() — per-run observer projecting the parent stream (including subagent steps) into a flat timeline.
  • createLibsqlStorage — one row per run, children linked by parent_run_id.

Level up

See also the multi-agent research example and the plugins guide for tracer and observer patterns.

Type to search…

↑↓ navigate open esc close