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.
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:
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.
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.
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:
| Limit | Trigger | SubagentLimitError reason |
|---|---|---|
allowed | agent_type not in the allowlist | "not_allowed" |
depth | child would exceed depth (default 2) | "depth" |
fanOut | more 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
globalmemory plus its ownagent-scoped entries (keyed by the child’sspec.id). It never sees the planner’sagent-scoped memory. - Put cross-cutting facts (“the operator wants metric units”) at
globalscope 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 anddescriptionhints for the picker.subagents: { allowed, depth, fanOut }— the planner’s only lever; auto-injectstask.staticRegistry({ researcher, writer })— resolvesagent_typestrings; required whenever a spec uses subagents.createMarkdownMemory— one shared store;globalreaches the tree,agentscope stays private per child.stepTracerPlugin()— per-run observer projecting the parent stream (includingsubagentsteps) into a flat timeline.createLibsqlStorage— one row per run, children linked byparent_run_id.
Level up
Move from one run to a persistent queue + worker pool that drains many jobs durably.
The full subagent-as-tool model: child lifecycle, run-id chains, and context inheritance.
See also the multi-agent research example and the plugins guide for tracer and observer patterns.