Deep research agent
An anchor agent that dispatches bounded subagents, with depth-tracked fanout and a turn-rooted tree you can read top-down from the ledger.
A research agent is the canonical multi-subagent workload: one
anchor turn fans out into N parallel investigations, each of
which may itself recurse. Pleach tracks the tree by construction
— every subagent carries its parent's turnId and its own
subagentDepth, so the whole investigation is one query against
the ledger.
This page walks the anchor → subagent shape, the fanout limits that protect the budget, and the rollup query that turns the tree into a UI.
Related shapes. Coding agent if subagents run code in a sandbox. Customer support agent if the anchor turn is part of a longer-lived support session. Multi-tenant SaaS agent if one runtime serves many research customers.
What you're building
An agent that takes a research question and returns a synthesized answer, citing the sources it consulted. Under the hood:
- The anchor agent decomposes the question into sub-questions.
- Each sub-question dispatches a
web_searchsubagent. - Each subagent has its own bounded tool surface and its own context window.
- The anchor synthesizes the subagent returns into one answer.
The anchor and the subagents all write to the same audit ledger.
The tree structure is encoded in parent_turn_id and
subagent_depth.
Subagent spec
A subagent spec is authored as a skill — a markdown file
with YAML frontmatter that the SkillLoader discovers at
session start. The skill's execution block (maxSteps,
timeout) marks it subagent-capable; its intents field is
what the orchestrator's intent classifier routes against.
skills/builtin/web-search/SKILL.md:
---
name: web-search
description: Investigate one sub-question. Return up to 5 cited findings.
allowed-tools:
- search_web
- fetch_page
intents:
- research
- literature_review
activation: intent-matched
execution:
maxSteps: 6
timeout: 60000
---
You investigate one focused question. Cite every claim with a
URL. Return at most 5 findings. Do not synthesize across
findings — that's the anchor's job.The subagent's tool surface is bounded by allowed-tools — it
can't escalate, it can't call the anchor's tools, and it can't
dispatch past SUBAGENT_LIMITS.maxDepth. The boundary is
structural, not prompted.
See Skills for the full Skill shape, the
three-source merge order (builtin / org / user), and the
getByIntent / getByName loader API.
Dispatch from the anchor
The anchor agent runs on SessionRuntime with subagent
concurrency enabled. The runtime picks a registered skill spec
when the planner emits a subagent task or the intent classifier
routes a sub-question to a subagent-capable skill. The cap is
enforced at the runtime level.
import {
SessionRuntime,
AiSdkProvider,
SUBAGENT_LIMITS,
} from "@pleach/core";
import { SupabaseAdapter, createSupabaseAdapter } from "@pleach/core";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY!,
});
const runtime = new SessionRuntime({
storage: createSupabaseAdapter({ client: supabase }),
userId: "user_123",
tenantId: "tenant_abc",
// Subagent concurrency — off by default; cap is per-session.
// The substrate enforces SUBAGENT_LIMITS.maxDepth (3) and
// SUBAGENT_LIMITS.maxPerTurn (5) independently.
enableSubagentConcurrency: true,
maxConcurrentSubagents: Math.min(5, SUBAGENT_LIMITS.maxConcurrent),
// Anchor-side orchestrator wiring (provider, intent detector,
// prompt contributions) goes through orchestratorConfig; the
// anchor's tool surface is registered with the orchestrator,
// not on this config object directly.
orchestratorConfig: {
provider: new AiSdkProvider({
model: openrouter("anthropic/claude-sonnet-4-5"),
}),
},
});maxConcurrentSubagents caps how many subagents run in
parallel within one session — additional spawns queue until a
slot frees. SUBAGENT_LIMITS are the substrate-enforced hard
ceilings (read-only constants):
| Limit | Value | What it caps |
|---|---|---|
maxDepth | 3 | Parent → child → grandchild nesting |
maxConcurrent | 3 | Concurrent subagents per session |
maxPerTurn | 5 | Subagents spawned in one parent turn |
timeoutMs | 120_000 | Default per-subagent timeout |
A consumer-supplied maxConcurrentSubagents higher than
SUBAGENT_LIMITS.maxConcurrent is clamped to the substrate
ceiling. See Subagents for the full
spawn / lifecycle surface.
What the event stream looks like
Subagent events are namespaced. The frontend can render a tree without rebuilding the structure from string parsing.
turn-start (anchor)
tool-call synthesize_findings.preflight
subagent-spawn web_search#1 question="...", depth=1
subagent-spawn web_search#2 question="...", depth=1
subagent-spawn web_search#3 question="...", depth=1
text-delta [web_search#1] "checking..."
tool-call [web_search#1] search_web
tool-result [web_search#1] search_web ok
subagent-end web_search#1 ok, 4 findings
...
tool-call synthesize_findings
text-delta "Based on the investigation..."
turn-completeThe [name#id] prefix is added by the runtime; you don't have to
maintain it. See Stream events for the
event taxonomy.
The tree query
Every subagent's audit rows carry parent_turn_id and
subagent_depth. The investigation tree is a recursive CTE.
with recursive tree as (
select turn_id, parent_turn_id, subagent_depth, tool_name, payload
from harness_auditable_calls
where turn_id = $1
union all
select c.turn_id, c.parent_turn_id, c.subagent_depth, c.tool_name, c.payload
from harness_auditable_calls c
join tree t on c.parent_turn_id = t.turn_id
)
select * from tree
order by subagent_depth, created_at;This is enough to render the full investigation as a tree, with each node's tool calls, model outputs, and timing.
Budget guard
Subagent fanout is where budgets evaporate. Two patterns to put in production from day one:
-
Per-turn token budget. Set
maxTokensPerTurnon the runtime. The runtime aborts the turn when the cumulative token count across the anchor and all subagents crosses the line. The audit ledger records the cutoff. -
Per-tenant rate limit. Outside the runtime, gate
runtime.runTurncalls per tenant. The ledger'stenant_idmakes after-the-fact attribution one query, but it doesn't stop a runaway request before it costs you.
Eval: lock the tree, vary the model
The investigation tree is recordable. Record one golden run, then replay it against a fresh runtime built around a different provider. The diff tells you whether the new model investigates differently or just synthesizes differently.
The recording lives on the harness_event_log ledger as a
sequence of subagent.spawned / subagent.completed rows
keyed by chatId + turnId. Replay is built on top via
@pleach/replay's ReplayClient (which consumes the public
runtime.events.iterate surface — no raw DB access).
import { SessionRuntime, AiSdkProvider } from "@pleach/core";
import { createReplayRuntime } from "@pleach/replay";
const challenger = new SessionRuntime({
storage: createSupabaseAdapter({ client: supabase }),
userId: "user_123",
tenantId: "tenant_abc",
enableSubagentConcurrency: true,
orchestratorConfig: {
provider: new AiSdkProvider({
model: openrouter("anthropic/claude-opus-4-7"), // the candidate
}),
},
});
const replayRuntime = createReplayRuntime({
sessionRuntime: challenger,
tenantId: "tenant_abc",
});
// Replays the new provider through the same anchor → subagent dispatch tree
// captured in the ledger; diverging spawn shapes surface in the rebuilt state.
const result = await replayRuntime.replayTurn({
chatId: goldenChatId,
tenantId: "tenant_abc",
messageId: goldenTurnId,
});
// `result.state` is the reconstructed HydratedHarnessState (typed `unknown`) —
// inspect it to compare the subagent spawn tree and the synthesis against the
// recorded turn.
console.log(result.state);See Eval and replay for the recording
workflow and the full ReplayClient surface.
Project layout
Two adds on top of the baseline:
a subagents/ module — separate from tools/ because subagents
have their own contract — and an eval/ entry point so the
"lock the tree, vary the model" walk is one command, not a
hand-assembled script.
my-app/
src/
pleach/
runtime.ts # constructs SessionRuntime + SkillLoader
tools/
synthesize-findings.ts # the anchor's tools
eval/
replay-fixed-tree.ts # createReplayRuntime + replayTurn
app/
api/agents/[id]/route.ts
skills/
builtin/
web-search/SKILL.md # subagent spec — own tools + execution block
deep-read/SKILL.md # subagent spec
evals/
fixtures/ # recorded sessions, gitignored or LFSWhat changes from the baseline:
skills/is separate fromsrc/pleach/tools/. A subagent isn't a tool — it has its own tool list (declared in the skill'sallowed-toolsfrontmatter), its own budget guard, and its own channel for stream isolation. Skill files live at the repo root underskills/builtin/<name>/SKILL.mdbecause that's where theSkillLoaderscans by default. Keeping subagent specs out ofsrc/pleach/tools/stops the two from drifting into each other and matches the Subagents contract.eval/is insrc/, nottests/. The replay path is product code —replayTurnruns against the sameSessionRuntimeyour host route uses, just withmode: "replay". Putting it undersrc/pleach/eval/means it imports the live runtime, not a test double.evals/fixtures/holds recorded sessions. The fixtures are what the eval section diffs against. Treat them like checked-in test data: small fixtures in git, large ones in LFS or object storage.
Where to go next
Subagents
The spawn surface, the workspace context, and SUBAGENT_LIMITS.
Channels
Per-subagent stream isolation, so the UI can render the tree without rebuilding it.
Audit ledger
The harness_auditable_calls schema the recursive tree query reads from.
Multi-tenant SaaS agent
parent_turn_id rollup so a five-level fan-out still attributes to one user turn.
Customer support agent
A multi-turn support agent with tool-backed lookups, human escalation, and a per-customer audit trail you can hand to a regulator without grep.
Internal knowledge agent
A retrieval-grounded agent over your private docs — with per-chunk provenance in the audit ledger and a "no chunk, no answer" safety policy.