Sub-Agents
agentTool wraps a focused agentic loop as a callable Tool — with a live-forwarded stream and inherited tool approval, and no new runtime.
agentTool turns a model + its own tools into a Tool the parent loop can call. The parent model delegates a sub-task ("research X", "write and test this function") to a nested agentic loop that runs to completion and returns its final text — the same tool loop machinery, one level down, with no new runtime concept to learn.
What's first-class here
The common way to compose agents is the "agent-as-tool" pattern: a tool whose execute calls another agent and returns its final text. agentTool is that pattern, but with three things provided out of the box instead of hand-wired:
| Capability | agentTool | Hand-rolled agent-as-tool |
|---|---|---|
| Sub-agent stream visible in the parent stream | Live — agentPath-tagged sub-agent parts on fullStream | You write your own UI-writer plumbing |
| Tool approval inside the sub-agent | Inherited from the parent's approveToolCall, at every depth | Not part of the pattern — you build it |
| Per-sub-agent usage attribution | meta.agentPath + folded into the parent total | Manual bookkeeping |
| New runtime concept | None — the same streamChat loop, one level down | — |
All three come for free because execute just drives the same tool loop internally — there's no separate agent runtime to adopt.
Defining a sub-agent
interface AgentToolDef {
/** Sub-agent identity — used in `agentPath`; use the same string as the tool key. */
name: string;
/** Shown to the parent model so it knows when to delegate. */
description: string;
model: LanguageModel;
tools?: ToolSet;
system?: string;
maxSteps?: number; // default 10
maxDepth?: number; // default 2
needsApproval?: Tool['needsApproval'];
compaction?: CompactionOption;
stopWhen?: StopCondition | StopCondition[];
subAgentStream?: 'full' | 'none'; // default 'full'
}
function agentTool(def: AgentToolDef): Tool;| Field | Type | Default | Notes |
|---|---|---|---|
name | string | — | Identity used in agentPath. Use the same string for the tools map key and name — they should always match. |
description | string | — | What the parent model reads to decide when to delegate. Write it like any tool description. |
model | LanguageModel | — | The sub-agent's own model — can differ from the parent's (e.g. a cheaper model for a research sub-agent). |
tools | ToolSet | — | The sub-agent's own tools. Omit for a tool-less sub-agent (a single focused turn). |
system | string | — | The sub-agent's system prompt. |
maxSteps | number | 10 | Max turns of the sub-agent's own loop — sub-agents are inherently multi-step, so the default is higher than the root loop's 1. |
maxDepth | number | 2 | Nesting cap — see maxDepth guard below. |
needsApproval | Tool['needsApproval'] | — | Gates the sub-agent call itself in the parent's own approval flow (independent of what happens inside the sub-agent). |
compaction | CompactionOption | — | The sub-agent's own compaction policy — useful for long-lived research agents. |
stopWhen | StopCondition | StopCondition[] | — | The sub-agent's own stop condition(s), OR-ed with its maxSteps. |
subAgentStream | 'full' | 'none' | 'full' | 'full' forwards the sub-agent's live stream into the parent; 'none' runs it silently. |
agentTool returns a plain Tool — register it in tools exactly like any other tool. The tool's input schema is fixed to { prompt: string }; the model passes the sub-task as prompt, and the tool's return value is the sub-agent's final text.
import { generateText, agentTool } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const haiku = anthropic('claude-haiku-5');
const opus = anthropic('claude-opus-4-8');
const { text } = await generateText({
model: opus,
messages: [{ role: 'user', content: 'Research the latest Anthropic pricing and summarize it.' }],
maxSteps: 5,
tools: {
researcher: agentTool({
name: 'researcher', // same string as the map key
description: 'Delegate research tasks to a focused sub-agent with web search.',
model: haiku,
tools: { webSearch: /* … a Tool with execute … */ },
maxSteps: 6,
}),
},
});
console.log(text);A buffered generateText parent always runs sub-agents silently — there's no stream to forward into — and just gets the returned text back as a normal tool_result.
Live visibility: sub-agent stream parts
When the parent is streaming, the sub-agent's entire canonical stream — every text-delta, tool-call, tool-result, step-start, everything — forwards live into the parent's fullStream, wrapped as a sub-agent part tagged with the full nesting path:
interface SubAgentPart {
type: 'sub-agent';
agentPath: string[]; // ['researcher'], or ['researcher', 'coder'] one level deeper
part: StreamPart; // the sub-agent's own canonical part, unwrapped
}A second-level sub-agent's parts ride agentPath: ['researcher', 'coder'] — single-wrapped, never a sub-agent part nested inside another sub-agent part.
import { streamChat, agentTool } from '@deuz-sdk/core';
const result = streamChat({
model: opus,
messages: [{ role: 'user', content: 'Research X and write me a summary.' }],
maxSteps: 5,
tools: {
researcher: agentTool({
name: 'researcher',
description: 'Delegate research tasks to a focused sub-agent.',
model: haiku,
tools: { webSearch: /* … */ },
}),
},
});
for await (const part of result.fullStream) {
if (part.type === 'sub-agent') {
const path = part.agentPath.join(' > ');
if (part.part.type === 'text-delta') {
process.stdout.write(`[${path}] ${part.part.text}`);
} else if (part.part.type === 'tool-call') {
console.log(`\n[${path}] calling ${part.part.toolName}`);
}
continue;
}
// the PARENT's own parts (text-delta, tool-call for `researcher` itself, …) arrive un-wrapped
}Pass subAgentStream: 'none' on the definition to run a sub-agent silently even from a streaming parent — the parent still gets the final text as a normal tool-result, it just skips forwarding the internals.
Approval inheritance
The parent's server-mode approveToolCall is inherited to every nesting depth — a sub-agent's own tool calls stay gated by the exact same policy the parent uses, with no extra wiring:
import { generateText, agentTool } from '@deuz-sdk/core';
import { z } from 'zod';
const result = await generateText({
model: opus,
messages: [{ role: 'user', content: 'Have the coder agent fix the failing test.' }],
maxSteps: 5,
tools: {
coder: agentTool({
name: 'coder',
description: 'Writes and runs code changes to fix a failing test.',
model: opus,
tools: {
runShellCommand: {
description: 'Run a shell command.',
parameters: z.object({ cmd: z.string() }),
needsApproval: true, // gated even though it's called from INSIDE the sub-agent
execute: async ({ cmd }) => runShell(cmd),
},
},
}),
},
// Inherited down: coder's runShellCommand calls are decided here too.
approveToolCall: async (call) => policyAllows(call),
});Approval is not sandboxing. Gating a
runShellCommand/writeFile/applyPatchtool withneedsApprovaldecides whether it runs — it does nothing to contain what it does. For shell/file/code tools, also: run inside a sandbox or a restricted workspace root, enforce allow/deny lists, passctx.signalinto long-running commands, capturestdout/stderrseparately, cap output size before feeding it back to the model, and never expose host secrets or unrestricted filesystem access. See the coding-agent cookbook for the full pattern.
1.4 limitation. Client-mode approval — omitting
approveToolCalland breaking the loop intopendingApprovals— is not supported inside a sub-agent yet; it needs durable suspend/resume across a nested loop, which lands in 1.5. A gated sub-agent tool call with no inherited server-mode approver returns a clearis_errorexplaining this (the parent model can react to it), rather than hanging or silently allowing the call. Server-modeapproveToolCallon the parent call works today at any depth.
Nesting sub-agents (orchestrator → worker)
agentTool inside agentTool composes: an orchestrator delegates to a researcher, and the researcher can itself delegate to a coder. Each level is a normal tool inside the level above it; agentPath records the full chain (['researcher', 'coder']), approval and usage inherit straight down, and a streaming parent sees every level's parts live.
import { generateText, agentTool } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { z } from 'zod';
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const opus = anthropic('claude-opus-4-8');
const haiku = anthropic('claude-haiku-5');
// A leaf coding sub-agent with a gated shell tool.
const coder = agentTool({
name: 'coder',
description: 'Writes and runs code changes to satisfy a spec.',
model: opus,
maxSteps: 8,
tools: {
runShellCommand: {
description: 'Run a shell command.',
parameters: z.object({ cmd: z.string() }),
needsApproval: true, // still gated when reached two levels deep
execute: async ({ cmd }) => runShell(cmd),
},
},
});
// A research sub-agent that can itself delegate coding to `coder`.
const researcher = agentTool({
name: 'researcher',
description: 'Researches a task and delegates any code changes to the coder.',
model: haiku,
maxSteps: 6,
tools: { webSearch: /* … a Tool with execute … */, coder },
});
// The orchestrator only knows about `researcher`.
const { text, usage } = await generateText({
model: opus,
messages: [{ role: 'user', content: 'Investigate the failing build and fix it.' }],
maxSteps: 5,
tools: { researcher },
// Inherited all the way down: coder.runShellCommand calls are decided here.
approveToolCall: async (call) => policyAllows(call),
});
// usage.totalTokens includes the orchestrator + researcher + coder.The maxDepth guard
Nesting is capped by maxDepth (default 2) to stop runaway self-recursion. Each agentTool checks its own maxDepth against how deep its agentPath already is when it runs; exceeding it throws, which the loop's self-healing turns into an is_error tool result — the parent model can recover (e.g. try the task itself) instead of the whole call failing.
Abort
The parent call's signal propagates down into every sub-agent's own loop, exactly like it propagates into a plain tool's execute — aborting the parent tears down sub-agents at any depth too.
Usage attribution
A sub-agent's cumulative usage folds into the parent's total: result.usage, budget stops (totalTokensExceed/costExceeds), and cost calculations all include every sub-agent's tokens, at every depth. Per-call onUsage events from inside a sub-agent are additionally tagged with meta.agentPath, so you can attribute usage to a specific sub-agent if you need finer-grained accounting:
const result = await generateText({
model: opus,
messages,
maxSteps: 5,
tools: { researcher: agentTool({ name: 'researcher', description: '…', model: haiku, tools: {} }) },
onUsage: (usage, meta) => {
// meta.agentPath is ['researcher'] for usage produced inside that sub-agent,
// undefined for the parent's own steps.
console.log(meta.model, meta.agentPath?.join(' > ') ?? 'root', usage.totalTokens);
},
});
console.log(result.usage.totalTokens); // includes every sub-agent's tokens tooSee also
- Tool loop — the agentic loop
agentToolruns one level down; every invariant (self-healing, immutable history, runaway guards) applies to sub-agents too. - Tools —
prepareStep,activeTools, and budget stop conditions, all usable inside a sub-agent's own loop via its own call options. - Context compaction — a sub-agent accepts its own
compactionpolicy, handy for long-lived research agents. - Client-side tools — the approval round-trip
approveToolCallinherits into sub-agents.
Provider-Executed Tools
Provider-native tools (Anthropic web search, OpenAI web search, Gemini Google Search) that run on the provider's own infrastructure and never touch your local execute.
Build a Coding Agent
An end-to-end, Codex/Claude-Code-style autonomous coding agent — an orchestrator that delegates to a coder sub-agent, with file/shell/test tools, budgets, and automatic compaction.