pleach
Cookbook

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_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

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):

LimitValueWhat it caps
maxDepth3Parent → child → grandchild nesting
maxConcurrent3Concurrent subagents per session
maxPerTurn5Subagents spawned in one parent turn
timeoutMs120_000Default 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-complete

The [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:

  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

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 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, 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 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 diffs against. Treat them like checked-in test data: small fixtures in git, large ones in LFS or object storage.

Where to go next

On this page