# Deep research agent (/docs/research-agent)



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](/docs/coding-agent) if
subagents run code in a sandbox.
[Customer support agent](/docs/customer-support-agent) if the
anchor turn is part of a longer-lived support session.
[Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent) if one
runtime serves many research customers.

## What you're building [#what-youre-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_search` subagent.
* 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 [#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`:

```markdown
---
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](/docs/skills) for the full `Skill` shape, the
three-source merge order (builtin / org / user), and the
`getByIntent` / `getByName` loader API.

## Dispatch from the anchor [#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.

```typescript
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](/docs/subagents) for the full
spawn / lifecycle surface.

## What the event stream looks like [#what-the-event-stream-looks-like]

Subagent events are namespaced. The frontend can render a tree
without rebuilding the structure from string parsing.

```text
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-complete
```

The `[name#id]` prefix is added by the runtime; you don't have to
maintain it. See [Stream events](/docs/stream-events) for the
event taxonomy.

## The tree query [#the-tree-query]

Every subagent's audit rows carry `parent_turn_id` and
`subagent_depth`. The investigation tree is a recursive CTE.

```sql
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 [#budget-guard]

Subagent fanout is where budgets evaporate. Two patterns to put
in production from day one:

1. **Per-turn token budget.** Set `maxTokensPerTurn` on 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.

2. **Per-tenant rate limit.** Outside the runtime, gate
   `runtime.runTurn` calls per tenant. The ledger's `tenant_id`
   makes 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 [#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).

```typescript
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](/docs/eval-and-replay) for the recording
workflow and the full `ReplayClient` surface.

## Project layout [#project-layout]

Two adds on top of the [baseline](/docs/project-layout#a-layout-that-works):
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 LFS
```

What changes from the baseline:

* **`skills/` is separate from `src/pleach/tools/`.** A subagent
  isn't a tool — it has its own tool list (declared in the
  skill's `allowed-tools` frontmatter), its own
  [budget guard](#budget-guard), and its own channel for stream
  isolation. Skill files live at the repo root under
  `skills/builtin/<name>/SKILL.md` because that's where the
  `SkillLoader` scans by default. Keeping subagent specs out of
  `src/pleach/tools/` stops the two from drifting into each
  other and matches the [Subagents](/docs/subagents) contract.
* **`eval/` is in `src/`, not `tests/`.** The replay path is
  product code — `replayTurn` runs against the same
  `SessionRuntime` your host route uses, just with `mode: "replay"`.
  Putting it under `src/pleach/eval/` means it imports the live
  runtime, not a test double.
* **`evals/fixtures/` holds recorded sessions.** The fixtures are
  what the [eval section](#eval-lock-the-tree-vary-the-model)
  diffs against. Treat them like checked-in test data: small
  fixtures in git, large ones in LFS or object storage.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Subagents" href="/docs/subagents" description="The spawn surface, the workspace context, and SUBAGENT_LIMITS." />

  <Card title="Channels" href="/docs/channels" description="Per-subagent stream isolation, so the UI can render the tree without rebuilding it." />

  <Card title="Audit ledger" href="/docs/audit-ledger" description="The harness_auditable_calls schema the recursive tree query reads from." />

  <Card title="Multi-tenant SaaS agent" href="/docs/multi-tenant-saas-agent" description="parent_turn_id rollup so a five-level fan-out still attributes to one user turn." />
</Cards>
