# Subagents (/docs/subagents)



A subagent is a child runtime spawned from a tool call. The parent
turn pauses on the subagent's tool, the subagent runs its own
anchor-plan → tool-loop → synthesize → post-turn cycle, and the
result bubbles back to the parent as the tool result. Every call
the child makes lands on the same audit ledger with
`parent_turn_id` and `subagent_depth` columns set, so a `GROUP BY
parent_turn_id` rolls nested fan-out back to the user message that
caused it. See [Event log](/docs/event-log) for the broader
projection surface.

Subagent is one of three concepts in the
[composing-agents cluster](/docs/agents#the-composing-agents-cluster),
alongside the skill that defines the spec and the agent profile
that records every invocation. The subagent is the runtime spawn;
the skill is the durable definition; the profile is the rolling
signal that decides whether the spec gets routed to again.

Subagents are off by default — the substrate runs strictly serial.
Enable them when the parent agent decomposes work into independent
subtasks (research fan-out, multi-document analysis, parallel
benchmark runs).

```typescript
import {
  SubagentManager,
  createSubagentManager,
  SUBAGENT_LIMITS,
  RESULT_PREVIEW_MAX_CHARS,
  buildDependencyGraph,
  topologicalSort,
} from "@pleach/core";
import type {
  Subagent,
  SubagentStatus,
  SubagentTask,
  SubagentSpawnConfig,
  SubAgentConfig,
  SubAgentHandle,
  SubAgentResult,
  SubAgentToolCall,
  SubagentSessionFlags,
} from "@pleach/core";

// LightweightSessionRuntime, the workspace-context helpers, and the
// WorkspaceContext type ship only from the `@pleach/core/subagents`
// subpath — they are not re-exported from the root barrel.
import {
  LightweightSessionRuntime,
  fetchWorkspaceContext,
  formatWorkspaceContextPrompt,
} from "@pleach/core/subagents";
import type { WorkspaceContext } from "@pleach/core/subagents";
```

<SourceMeta source="{ label: &#x22;src/subagent/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/subagent&#x22; }" />

## Enabling subagents [#enabling-subagents]

```typescript
import { SessionRuntime } from "@pleach/core";

const runtime = new SessionRuntime({
  storage: supabaseAdapter,
  enableSubagentConcurrency: true,
  maxConcurrentSubagents:    4,
  userId: "user_123",
});
```

| Config field                | Default | Effect                                                    |
| --------------------------- | ------- | --------------------------------------------------------- |
| `enableSubagentConcurrency` | `false` | Master switch                                             |
| `maxConcurrentSubagents`    | `4`     | Cap on the `SubagentManager` (the host-driven queue path) |

<Callout type="warn" title="Bare core needs a host-supplied executor">
  The flags above turn the surface on; they don't make a subagent
  run on bare `@pleach/core`. The runtime's default executor is a
  no-op that returns an "executor not configured" failure, so the
  spawn emits `subagent.spawned` then `subagent.failed` with no work
  done. To actually run subagents the host supplies a
  `subagentExecutor` on `SessionRuntimeConfig` (the LLM call is
  bring-your-own) and/or drives the `SubagentManager` itself. Without
  that wiring the model is offered no delegation tool and spawns
  don't execute. `@pleach/core` provides scheduling and lifecycle,
  not the model call.
</Callout>

Two concurrency paths exist, and they cap differently.

The imperative spawn path (`runtime.async.spawn` / `spawnAgent`)
caps on the hard `SUBAGENT_LIMITS.maxConcurrent` constant (`3`),
not on `maxConcurrentSubagents`. When the active subagent count is
already at that limit, the spawn does not queue or wait — it
returns a failed `SubAgentResult` (`status: "failed"`, error
`Sub-agent concurrency limit reached`) and emits `subagent.spawned`
immediately followed by `subagent.failed`. Depth behaves the same
way: exceeding `SUBAGENT_LIMITS.maxDepth` (`3`) rejects the spawn
rather than queuing it.

`maxConcurrentSubagents` (default `4`) caps the `SubagentManager` —
the queue/DAG fan-out component. The runtime constructs a
`SubagentManager` when `enableSubagentConcurrency` is set, but does
not drive it on the imperative spawn path. The queue,
`buildDependencyGraph`, and `topologicalSort` are a host-driven
surface: a host that wants queue-on-cap (rather than fail-on-cap)
fan-out orchestrates the `SubagentManager` directly.

## How a subagent gets spawned [#how-a-subagent-gets-spawned]

Spawns are imperative in bare `@pleach/core` — there is no
metadata-flag auto-escalation. The entry points are:

* `runtime.async.spawn(config)` (or the `@deprecated`
  `runtime.spawnAgent(config)`) — the host or a tool calls it
  directly with a `SubAgentConfig`.
* The `SubagentManager` API — `createSubagentManager(...)` then
  its spawn/queue methods, for host-driven fan-out.
* A host `OrchestratorAdapter` that surfaces delegation tools
  (the Ivy host uses the `delegate_to_*` prefix; the prefix is
  host-configurable via `delegationToolPrefix`). The model calling
  one of those tools routes through the host's `subagentExecutor`.

There is no `spawnsSubagent: true` tool-metadata flag in core
(the field does not exist), and bare core wires neither a
planner-emitted subagent task nor an intent-classifier route to a
spec — those are host responsibilities.

When a spawn runs, the runtime allocates a
`LightweightSessionRuntime` and dispatches through the configured
`subagentExecutor`.

## Namespacing in the event stream [#namespacing-in-the-event-stream]

Every event a subagent emits carries `namespace: string[]`.
Empty array for the root orchestrator; populated for events
emitted from inside a subagent.

```typescript
for await (const event of runtime.executeMessage(sessionId, prompt)) {
  if (event.namespace && event.namespace.length > 0) {
    // Subagent event — route to a sub-panel
    renderInSubagentPanel(event.namespace, event);
  } else {
    renderInRootPanel(event);
  }
}
```

The namespace path is the subagent tree — a subagent that itself
spawns a subagent emits events with `namespace: ["parent",
"child"]`. UIs render this as a tree of nested transcripts.

## Subagent lifecycle events [#subagent-lifecycle-events]

Four stream events bracket a subagent's life:

| Event                | Payload                                                | When                                                                       |
| -------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------- |
| `subagent.spawned`   | `{ subagentId, task, specName?, context? }`            | Allocated; runtime is starting it                                          |
| `subagent.progress`  | `{ subagentId, progress, message?, activeToolName? }`  | Periodic progress update                                                   |
| `subagent.completed` | `{ subagentId, content, toolsUsed, toolCallDetails? }` | Finished cleanly                                                           |
| `subagent.failed`    | `{ subagentId, error, terminalStatus? }`               | Errored; `terminalStatus` discriminates `cancelled` / `failed` / `timeout` |

`progress` is 0–1. `activeToolName` lets a UI show what the
subagent is currently doing without subscribing to its full
event stream.

## `WorkspaceContext` [#workspacecontext]

Live workspace state a subagent can inherit when its spec is
`workspaceAware`. Queried from a sandbox-style execution
environment at spawn time so the subagent doesn't burn a turn
asking what's installed.

```typescript
interface WorkspaceContext {
  pythonVersion: string;
  packages:      Array<{ name: string; version: string }>;
  cliTools:      string[];
  disk?:         { total: string; used: string; available: string; usePct: string };
  fileCount:     number;
  files?:        string[];
}
```

`fetchWorkspaceContext(sandbox)` queries a sandbox connection and
returns the snapshot (or `null` if the sandbox isn't active —
callers proceed without context rather than failing the spawn).
`formatWorkspaceContextPrompt(ctx)` renders the snapshot as a
prompt section. `setDomainClassifierPatterns(patterns)`
configures the file-classification heuristic that picks which
files surface in `files`.

A workspace-aware subagent needs the host to supply the sandbox
connection. Hosts wire one in via the `contributeSandboxBridge`
plugin hook — see [Plugin contract](/docs/plugin-contract) for
the bridge surface.

## `SubAgentConfig` and the spawn surface [#subagentconfig-and-the-spawn-surface]

```typescript
interface SubAgentConfig {
  task:                 string;
  name?:                string;       // spec name; default "general-purpose"
  tools?:               string[] | "inherit" | "none";
  context?:             { conversationSummary?: string; variables?: Record<string, unknown> };
  maxSteps?:            number;       // default 5
  maxDepth?:            number;       // checked at spawn time
  timeout?:             number;       // default 120_000ms
  streamProgress?:      boolean;
  systemPromptSuffix?:  string;
  resumeId?:            string;       // resume a prior subagent's transcript
}

interface SubAgentHandle {
  id:        string;
  status:    "cancelled" | "completed" | "failed" | "pending" | "running";
  progress?: number;  // 0–100
}

interface SubAgentResult {
  id:        string;
  status:    "cancelled" | "completed" | "failed" | "timeout";
  content:   string;
  toolCalls: SubAgentToolCall[];
  error?:    string;
  metrics:   {
    totalSteps:    number;
    durationMs:    number;
    inputTokens?:  number;
    outputTokens?: number;
  };
  transcript?:    Array<{ role: string; content: unknown }>;
  agentName?:     string;
  checkpointId?:  string;
  turnMetrics?:   {
    successfulToolNames: string[];
    failedToolNames:     string[];
    turnHadFailedTools:  boolean;
    retryCount:          number;
    totalToolCalls:      number;
  };
}
```

<Callout type="warn" title="`context.variables` is currently inert">
  `context.variables` is accepted on `SubAgentConfig` but the
  runtime does not consume it — when a spawn supplies it, the
  runtime logs a warning (`context.variables provided but executor
  does not consume them; embed into context.conversationSummary or
  task`) and proceeds without it. Put anything the child needs into
  `context.conversationSummary` or `task` instead.
</Callout>

`SubagentSessionFlags` propagates parent-session state into the
child so the subagent doesn't re-offer tools the parent already
knows are unavailable:

```typescript
interface SubagentSessionFlags {
  sandboxUnavailableInSession?: boolean;
  filteredToolNames?:           string[];
}
```

## `LightweightSessionRuntime` [#lightweightsessionruntime]

Subagents run on `LightweightSessionRuntime` — a slimmer
`SessionRuntime` variant that shares the parent's storage,
checkpointer, and plugin set but isolates its channels and
event stream. It is exported from the `@pleach/core/subagents`
subpath, not the root barrel.

```typescript
import { LightweightSessionRuntime } from "@pleach/core/subagents";

// Usually you don't construct this directly — the SubagentManager does.
// Direct construction is for testing or custom orchestration:
const sub = new LightweightSessionRuntime({
  parentSessionId:      runtime.sessionId,
  agentName:            "research-fanout",
  parentPluginManager:  pluginManager,   // optional
  store:                baseStore,        // optional (BaseStore)
  sessionFlags: {                         // optional
    sandboxUnavailableInSession: false,
    filteredToolNames: [],
  },
});
```

The lightweight runtime inherits:

| From parent     | What                                                                   |
| --------------- | ---------------------------------------------------------------------- |
| Storage adapter | Reads + writes the same DB; isolated `subagent_id` column scope        |
| Checkpointer    | Subagent checkpoints land in the same store with parent-id provenance  |
| Plugin set      | Plugins are shared; per-subagent contributions are filtered by `scope` |
| Audit ledger    | Subagent calls write to the same ledger with the subagent's id         |

What's isolated:

* Channels — the subagent has its own per-channel state
* Event log namespace — events tagged with the subagent's path
* Tool registry filter — subagents typically run a narrower tool
  set than their parent

## `SUBAGENT_LIMITS` and depth tracking [#subagent_limits-and-depth-tracking]

Hard limits the substrate enforces independently of consumer
config:

```typescript
const SUBAGENT_LIMITS = {
  maxDepth:      3,        // parent → child → grandchild
  maxConcurrent: 3,        // concurrent subagents per session
  maxPerTurn:    5,        // subagents spawned in one turn
  timeoutMs:     120_000,  // default timeout
} as const;

const RESULT_PREVIEW_MAX_CHARS = 400;
```

Depth is checked at spawn time — exceeding `maxDepth` rejects the
spawn rather than proceeding into a fourth nested runtime.
`SubAgentToolCall.resultPreview` is truncated at
`RESULT_PREVIEW_MAX_CHARS` so the per-step records on the parent's
`SubAgentResult.toolCalls` stay bounded. `turnDepth` on
`RuntimeStateSnapshot` (passed to runtime-aware prompt
contributions) reports the same value the subagent sees.

The depth value is also what lands on every `AuditableCall` row
emitted from inside a subagent — `payload.subagentDepth` carries
`1` for a direct child, `2` for a grandchild, and the audit ledger
keeps the attribution distinct so a `GROUP BY subagentDepth` query
answers "how much did the fan-out tier cost relative to the root
turn." The same field threads through `llm.turn.depth` on the
stream, so a UI panel can colour-code per-tier spend without
joining back to the ledger.

## Authoring subagent specs [#authoring-subagent-specs]

Subagent specs are authored as skills — markdown files with YAML
frontmatter, loaded by `SkillLoader` from `@pleach/core/skills`. A
skill's `execution` block (`maxSteps`, `timeout`, `canDelegate`)
marks it as subagent-capable. The orchestrator's intent classifier
routes to it via the `intents` field.

A minimal skill file at `skills/builtin/research-fanout/SKILL.md`:

```markdown
---
name: research-fanout
description: Fan out searches across multiple sources in parallel.
allowed-tools:
  - search_corpus
  - search_news
intents:
  - research
  - literature_review
activation: intent-matched
execution:
  maxSteps: 6
  timeout: 120000
---

You are a research subagent. Run each search in parallel and
synthesise the results into a single structured report.
```

Legacy in-code subagent specs — the `subagentSpecs` array passed
to the `SkillLoader` constructor — are migrated to builtin skills
automatically at load time. A spec whose `name` matches an existing
skill file is skipped. Migration is the backward-compatibility path,
not the authoring target.

See [Skills](/docs/skills) for the full `Skill` shape, the
three-source merge order, and the `getByIntent` / `getByName`
loader API.

## Aborting a subagent [#aborting-a-subagent]

The parent's `AbortSignal` propagates to every active subagent —
aborting the parent turn cancels every running child. That's the
primary cancel path today: cancel the parent and every active
child unwinds.

Per-subagent cancellation is partially wired. `SubagentManager`
marks the subagent `"cancelled"` and emits `subagent.cancelled`,
but the async-task path
([Async tasks](/docs/async-tasks))
currently logs a warning rather than tearing down the running
worker — cancellation is cooperative, not preemptive. A subagent
mid-tool-call won't stop until that call returns.

The aborted subagent emits `subagent.failed` with
`terminalStatus: "cancelled"`. The discriminator matters for
dashboards: `"cancelled"` is user-driven, `"failed"` is an error
inside the subagent's own runtime (a tool threw, a provider call
exhausted its cascade), and `"timeout"` is the
`SubAgentConfig.timeout` ceiling firing — typically `120_000`ms.
Alert on `"failed"`; don't alert on `"cancelled"`.

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

<Cards>
  <Card title="Stream events" href="/docs/stream-events" description="`subagent.spawned` / `progress` / `completed` / `failed` payloads." />

  <Card title="Skills" href="/docs/skills" description="`SkillLoader` — define subagent specs as markdown skills; the `execution` block makes a skill delegatable." />

  <Card title="SessionRuntime" href="/docs/session-runtime" description="`enableSubagentConcurrency` and `maxConcurrentSubagents` config fields." />

  <Card title="Auditable call row" href="/docs/auditable-call-row" description="`parent_turn_id` and `subagent_depth` columns roll fan-out back to the user turn." />
</Cards>
