Sub-agents
Handing a bounded piece of work to a cheaper, narrower version of the agent.
With the TypeScript SDK, pass an agent returned by defineAgent directly in
subagents: [researcher]. See multiple agents for
reusable references, shared routing, and deployment.
subagents is a roster on the definition, so it is versioned, diffable and auditable
exactly as tools is. An agent with no roster cannot spawn sub-agents.
export default defineAgent({
agent: {
slug: "coding-agent",
model: { provider: "gateway", model: "anthropic/claude-sonnet-4.6" },
system: "You are a careful engineer.",
subagents: [
// Relational: the parent's setup, narrowed. Every omitted override means
// *inherit*, never "default".
{
kind: "derived",
name: "explore",
description: "Read-only codebase search. Give it a question, not a plan.",
extends: "parent",
overrides: {
model: { model: "anthropic/claude-haiku-4.6" },
tools: { subset: ["read", "grep", "glob"] },
system: { mode: "append", text: "You cannot write. Answer with findings." },
limits: { maxTurns: 12 }
}
},
// By reference: an agent that exists in its own right.
{ kind: "agent", name: "reviewer", description: "Second-opinion review.", agent: "code-reviewer" }
],
subagentPolicy: { maxDepth: 2, maxConcurrent: 4, maxTotalPerSession: 16 }
},
tools: [/* … */]
})Write a description that tells the model when to use the sub-agent and what result
to expect.
tools.subset can only narrow. It is intersected with what the base can already
reach, so explore's inability to write is a property of the resolver rather than of
its prompt. A name the parent cannot reach is refused by sync before anything is
sent.
extends: "parent" inherits the spawning session's effective setup, its own
overrides already applied. So a session created with a per-tenant paragraph appended
to its prompt spawns children that know which tenant they are working for.
The tools it gets
With a non-empty roster and below the depth limit, the agent gets five:
sys_spawn_subagent, sys_message_subagent, sys_wait_subagent,
sys_stop_subagent, sys_list_subagents. A sub-agent additionally gets
sys_report, the channel back to whoever delegated.
A spawn returns immediately with a handle, two words such as brisk-otter, unique
per parent, and the child works in the background. Its report arrives in the parent's
log as a subagent.completed, waking a turn if the parent is idle and folding into the
next step if it is not, so the parent keeps working rather than polling. Every tool
that names a child takes the handle. A raw session id still resolves, and an unknown
name is a tool error listing the handles that exist.
wait: true, and sys_wait_subagent over several handles, block for at most a
bounded window, sixty seconds by default, and then return with the settled children
in results and the rest in pending. The child's report still arrives when it finishes.
A child's status is reported in the MCP Tasks vocabulary, working, input_required,
completed, failed and cancelled, with a statusMessage where the word does not
explain itself. Cereal's own idle is not used here, because a model reads it as "not
started".
A child is a real session
Its own transcript, its own trace, its own usage, its own id. You reach it the way you reach any other:
const children = await client.sessions.children({ id: sessionId })
for await (const event of client.sessions.stream(children[0].id)) { /* … */ }The parent's log carries only the child's outputs: subagent.spawned,
subagent.status, subagent.message and subagent.completed, never its working, so a
busy child cannot fill its parent's context. Subscribe to those four in
hooks.onSessionEvent for a "a sub-agent finished" webhook.
Children are created through the spawn tool. sessions.create does not accept a
parentSessionId. You can send messages to, interrupt, and stream an existing child
directly; those direct actions do not appear as parent-agent messages.
Caps
maxDepth: 2, maxConcurrent: 4, maxTotalPerSession: 16, selfDelegation off. The
depth ceiling is the root session's, so a child cannot raise it by declaring a
bigger one. Usage rolls up: a session's descendantUsage is everything its tree spent,
and limits.maxInputTokensPerSession is checked against the total on the root, so a
budget cannot be evaded by delegating.
A refused spawn is a tool error, not a failed turn: the model can wait for a slot, pick a different sub-agent, or answer without one.
Policies and machines are shared
A sub-agent inherits its parent's policies, so a child's tool call is gated by the
same onToolCall hook, arriving with session.parentSessionId set, which is how you
tell delegated work from the conversation the user is having. The sub-agent tools
themselves are exempt: gating sys_spawn_subagent would ask a person to approve the
question and then approve every answer.
If the agent has a sandbox, the whole tree shares one
machine, same workspace and same processes, which is what makes a review-lens child
possible at all. Narrow what a child may do to it with overrides.sandbox.tools.