SubAgents
A SubAgent is an ephemeral child run spawned by a parent agent that inherits the parent’s identity by default: same agent alias, same SecurityPolicy, same memory allowlist, same configured model provider, same tool registry. Auditable as a child via a tracing span agent.<alias>.subagent.<run_id>.
SubAgents are not a separate configuration concept. There is no [subagents.*] block in the schema. Every SubAgent’s identity is whichever parent’s agent loop spawned it.
When to use a SubAgent vs delegate
Two tools sit nearby. They are not interchangeable.
spawn_subagent— runs the SAME agent again under its own identity for a focused subtask. The child sees the parent’s full permissions envelope minus any narrowing. Use when the parent wants to scope an internal subtask out of its main conversation history without changing identity.delegate— hands the request off to a DIFFERENT configured agent (named by alias). The target agent runs under its own identity, its own risk profile, its own model provider. Use when a sibling agent is the right specialist for the work.
This page documents spawn_subagent end to end. delegate lives at crates/zeroclaw-runtime/src/tools/delegate.rs and is a separate surface.
How a SubAgent is instantiated
Two spawn sites converge on SubAgentSpawn (crates/zeroclaw-runtime/src/subagent/mod.rs:97):
- From an agent loop: the model calls the
spawn_subagenttool with apromptstring. The tool is registered like any other in the registry (crates/zeroclaw-runtime/src/tools/mod.rs:437). - From cron:
JobType::Agentjobs run throughrun_agent_job(crates/zeroclaw-runtime/src/cron/scheduler.rs:339) which builds the sameSubAgentContextbut flags the child as a top-level run (not a SubAgent) so it can itself spawn one level of subagent.
Both paths invoke:
#![allow(unused)]
fn main() {
SubAgentSpawn::for_agent(config, parent_alias)? // resolve parent identity
.build(SubAgentOverrides::default())? // validate any narrowing
}
for_agent reads the parent’s risk_profile and [agents.<alias>.workspace.read_memory_from] to build the inherited allowlist; the parent’s own alias is always added so a SubAgent always sees its parent’s own memory rows. build applies optional narrowing (see Permission inheritance below) and returns a validated SubAgentContext.
Lifecycle
Synchronous, in-process, single tokio runtime. Nothing crosses the process boundary.
- Parent’s tool loop dispatches
spawn_subagent. The tool reads itspromptargument, refuses if empty. - The tool checks two guards in order:
- Depth-1 cap. If the calling run was itself a SubAgent (
AgentRunOverrides.is_subagent == true), refuse with"spawn_subagent: a subagent may not spawn its own subagents (depth-1 cap)". SubAgents cannot recurse. risk_profile.allowed_toolsgate. If the parent’s[risk_profiles.<alias>].allowed_toolsdoes not listspawn_subagent, orexcluded_toolslists it, refuse with a message naming the parent alias.
- Depth-1 cap. If the calling run was itself a SubAgent (
- The tool calls
SubAgentSpawn::for_agent+build. Failures (unknown parent alias, escalating override) surface asToolResult { success: false, error: "subagent spawn failed: ..." }. - The tool constructs
AgentRunOverrides { security, memory: None, is_subagent: true }and awaitscrate::agent::run(crates/zeroclaw-runtime/src/agent/loop_.rs:2295) inside a tracing scope keyedsubagent-<uuid>. The parent’stoolexecution blocks until the child returns. - The child agent loop runs to completion. Its tool registry is built fresh, with
is_subagent_caller: trueflowing into its ownSpawnSubagentToolso any attempt to recurse is rejected at the same depth-1 gate. - The child returns
Result<String>. The parent’sspawn_subagenttool wraps it:- Success:
ToolResult { success: true, output: <child's final response>, error: None }. Empty output is replaced with the literal"subagent completed without output". - Failure:
ToolResult { success: false, error: Some("subagent run failed: ...") }.
- Success:
- The parent’s tool loop continues with that
ToolResultin its conversation context. The child’s intermediate turns and tool calls are NOT replayed into the parent’s history; only the final response surfaces.
What gets delivered back upstream
One thing: the child’s final assistant message, as a string, wrapped in ToolResult.output.
- The child’s tool calls, intermediate reasoning turns, and any memory writes the child performed are observable in the structured logs under the child’s tracing span but do not enter the parent’s conversation history.
- The child’s session lives under the path
subagent-<uuid>(orcron-<uuid>for cron-spawned runs). This is the conversation-history key, not a filesystem location — it isolates the child’s history from the parent’s. - Memory writes performed by the child are written to the parent’s identity (same agent UUID at the SQL/Postgres backends; same workspace dir for Markdown). Cron-spawned runs disable
memory.auto_saveso opt-in writes still work but routine recall doesn’t accumulate.
There is no streaming or partial-progress channel back to the parent. Long-running SubAgents stall the parent’s tool execution for their full duration; there is no per-call timeout knob.
Permission inheritance
A SubAgent inherits the parent’s permissions verbatim unless the spawn site supplies a narrowing SubAgentOverrides. Today both in-tree spawn sites pass SubAgentOverrides::default() (inherit everything). The override surface is shipped and validated; a future caller-supplied narrowing path drops in without runtime changes.
Inheritance axis by axis:
SecurityPolicy— inherited byArc<SecurityPolicy>cloning. Override path (SubAgentOverrides::policy = Some(policy)) runsSecurityPolicy::ensure_no_escalation_beyond(crates/zeroclaw-config/src/policy.rs:2051) and rejects any field that adds privilege the parent doesn’t have. Validated axes include autonomy level, allowed_roots (rw + ro + write-only), allowed_commands, workspace_only, forbidden_paths in the parent ⊆ child direction, shell_env_passthrough,max_actions_per_hour,max_cost_per_day_cents,shell_timeout_secs,block_high_risk_commands, andrequire_approval_for_medium_risk. Rejections chain a preciseEscalationViolationso diagnostics name the offending field.- Action / cost budgets —
PerSenderTrackeris shared between parent and child byArcclone. Inherit-verbatim path: the child holds the sameArc<SecurityPolicy>so writes torecord_action()/record_cost()hit the same bucket. Override path:SubAgentSpawn::buildcopies the parent’strackerfield into the narrowed child policy explicitly. A SubAgent cannot bypassmax_actions_per_hourormax_cost_per_day_centsby spawning — the limit is shared. - Tool registry — the child’s registry is built fresh by
tools::all_tools_with_runtimeunder the inherited policy. The registry then passes throughapply_policy_tool_filter(crates/zeroclaw-runtime/src/agent/loop_.rs), which drops any tool whose name fails either gate:- The policy’s
allowed_tools/excluded_tools(sourced from the parent’srisk_profile). - The caller-supplied
allowed_toolsargument toagent::run.spawn_subagentis in the registry but itsis_subagent_callerflag is set totruefor the child, so the depth-1 refusal fires before any spawn work.
- The policy’s
- Memory allowlist — a
HashSet<String>of sibling agent aliases (the[agents.<alias>]config keys). Inherited from the parent’sworkspace.read_memory_fromplus the parent’s own alias. Override path (SubAgentOverrides::allowed_agent_aliases) is validated as a subset; any alias not on the parent’s list is rejected by name. The parent’s own alias is always re-added so a SubAgent always sees its parent’s rows. - Model provider — inherited from the parent’s
[agents.<alias>] model_providerresolution. Temperature comes from the parent’s provider entry (config.model_provider_for_agent(parent_alias).and_then(|e| e.temperature)). - Identity at the data layer — same UUID in the
agentstable (SQL backends), same workspace dir for Markdown, same secret store. The parent-vs-child distinction is purely observability: a separate tracing span and a separate conversation-history session key.
How a user makes one fire
You don’t call these tools yourself; the bot does, from inside its turn. As a user, you influence the bot’s choice with how you phrase the request. There is no special command, no slash-syntax, and no JSON the user types. Whether the model picks spawn_subagent or delegate depends on its system prompt, the tool’s description text (visible to the model), and the user’s wording. Phrasing influences; it does not force.
What CAN be made deterministic is availability: tools that aren’t in the parent agent’s registry can’t be picked. That gate lives in [risk_profiles.<alias>].allowed_tools. If the alias listed for the parent agent’s risk_profile doesn’t include spawn_subagent, the model never sees it. Same for delegate. Restart the daemon after editing the config.
[risk_profiles.frontline]
allowed_tools = ["shell", "file_read", "memory_recall", "spawn_subagent", "delegate"]
What’s verifiable end-to-end:
- The literal output strings the tool returns to the model on each path (success, refusal, failure). Quoted verbatim below, sourced from
tools/spawn_subagent.rsandtools/delegate.rs. - The literal config knobs that change behavior (
allowed_tools,max_delegation_depth, etc.). - The structured tracing span shape that scopes everything emitted during the child run.
What’s NOT verifiable from these docs:
- Whether your specific bot, on your specific model, on your specific system prompt, will pick the tool when asked “Spawn a subagent to …” Wording moves the needle; outcomes vary. If the bot doesn’t pick the tool, the most reliable lever is to extend the bot’s system prompt with explicit instructions (“When asked for a focused subtask, use the
spawn_subagenttool”). - The exact text the bot writes to you in its final reply. The bot reads the tool’s output and generates its own reply on top. The tool’s output text may be quoted, paraphrased, or summarized.
spawn_subagent: refusal strings the model sees
These are exact, sourced from crates/zeroclaw-runtime/src/tools/spawn_subagent.rs. The model receives them as the tool’s error string and reacts. The user-visible bot reply is whatever the model writes next; it commonly references or echoes the refusal.
- Empty/missing
promptargument:Missing or empty 'prompt' parameter - Caller is itself a SubAgent (depth-1 cap):
spawn_subagent: a subagent may not spawn its own subagents (depth-1 cap) - Parent’s
risk_profile.allowed_toolsexcludesspawn_subagent:spawn_subagent: refused — agent '<parent_alias>' risk_profile does not list spawn_subagent in allowed_tools - Unknown parent alias / spawn build error:
subagent spawn failed: <wrapped error> - Child run returned an error:
subagent run failed: <wrapped error>
On success, the tool’s output IS the child’s final response text. If the child returned an empty string, the output is the literal placeholder: subagent completed without output. There is no fixed prefix to grep for in the success case.
spawn_subagent: how to verify it actually fired
Tail your log. The tool-spawned child runs inside a scope! that emits a tracing span named zeroclaw_scope (with target zeroclaw_log_internal_scope) carrying agent_alias=<parent> and session_key=<uuid>. Every log line emitted during the child run carries those fields. The parent’s own turn has its own session_key; a NEW session_key value appearing mid-turn for the same agent_alias is the signal that a SubAgent ran. The child’s conversation-history session path is subagent-<uuid> (filesystem-ish identifier, distinct from the tracing field).
Cron-launched agent jobs use a different, more explicit span name: subagent (literal) with fields category="cron", agent_alias=<owning agent>, cron_job_id=<id>, run_id=<uuid>, spawn_site="cron". Cron paths are trivially greppable: grep 'spawn_site="cron"' zeroclaw.log. Note that cron-launched runs are top-level (is_subagent=false); they may themselves call spawn_subagent once.
This is a thin signal for the agent-loop spawn path. A dedicated “subagent started / completed” record routed through attribution_span!(tool) is tracked as a code-side follow-up — once the agent loop wraps tool execution in an attribution span, every record! inside the tool will carry tool=spawn_subagent automatically and the question becomes a trivial grep.
delegate: output strings the model sees
Exact, sourced from crates/zeroclaw-runtime/src/tools/delegate.rs.
-
Synchronous success: output begins with
[Agent '<target>' (<provider_type>/<model>)]\nfollowed by the target agent’s response. If the target returned an empty string, the body is the literal[Empty response]. -
Synchronous failure: error field begins with
Agent '<target>' failed: <wrapped error>. -
Synchronous timeout (when the target’s runtime profile sets
delegation_timeout_secs): error field isAgent '<target>' timed out after <N>s. -
Background spawn success: output is the three-line literal
Background task started for agent '<target>'. task_id: <uuid> Use action='check_result' with task_id='<uuid>' to retrieve the result.The result file lives at
<workspace>/delegate_results/<uuid>.json. While running, the file’sstatusfield isRunning; terminal states areCompleted,Failed, orCancelled. -
action="check_result"with an unknown task id: error isNo result found for task_id '<uuid>'. -
Parallel fan-out output: begins with
[Parallel delegation: <N> agents]\n\n, followed by per-agent blocks separated by\n\n, each block beginning with--- <target> (success=<bool>) ---\n. On per-agent failure the inner block is--- <target> (success=false) ---\nError: <wrapped error>. -
Unknown target agent: error is
Unknown agent '<target>'. Available agents: <comma-separated list>. -
Depth exceeded (controlled by the parent’s
runtime_profile.max_delegation_depth, default 3): error isDelegation depth limit reached (<depth>/<max>). -
Unknown action: error is
Unknown action '<value>'. Use delegate/check_result/list_results/cancel_task.
delegate: how to verify it actually fired
delegate does not emit a dedicated tracing span today. The signal is the target agent’s loop appearing in the log, which inherits whatever scope the parent’s tool-call dispatch was inside. Background-mode spawns are easier to verify out-of-band: the result file <workspace>/delegate_results/<uuid>.json exists on disk and carries the target agent’s status + output fields; cat or jq works without touching the log at all.
(Cron-launched agent jobs are a separate spawn site and use the explicit subagent span described above; delegate and cron are not the same path.)
What’s not in this page (intentionally)
- Example conversation transcripts. Anything I wrote here describing “what the bot will say” would be model-dependent. The bot’s reply is downstream of the tool’s output, model, system prompt, and current conversation state — none of which this page controls. The verifiable layer is what the tool returns (above) and what the log captures.
- A dedicated “subagent fired” / “delegate fired” log marker. Tracked as a code-side follow-up. Today, operators verify via the scope shape described above (which is the existing structural signal) and via the background-mode result file.
Choosing between spawn_subagent and delegate
spawn_subagent | delegate | |
|---|---|---|
| Identity | Same as parent (same UUID, same risk profile) | Target agent’s identity (different alias, different profile) |
| Permission model | Parent’s policy verbatim (or narrowed subset) | Target agent’s own policy |
| Model provider | Parent’s | Target agent’s configured provider |
| Spawn depth | Hard cap at 1 | Up to runtime_profile.max_delegation_depth (default 3) |
| Background mode | Not supported | background: true returns a task_id |
| Parallel fan-out | Not supported | parallel: [...] runs multiple targets concurrently |
| Use when | Internal subtask that should stay within the same identity | Want a different specialist (different model, different permission envelope) to handle the task |
What’s not supported
- Recursion beyond depth 1. A SubAgent cannot spawn its own SubAgent. The cap is a hard refusal at the tool, not a budget. Cron-launched runs start at depth 0 and may spawn one level; agent-loop-launched SubAgents are at depth 1 and refuse further spawning.
- A separate identity for the child. SubAgents share the parent’s agent UUID. To run under a different identity, use
delegateto hand off to a configured sibling agent. - Per-spawn time budget. There is no
timeout_secsargument. The parent blocks for the full duration of the child run; cancellation has to flow through the broader interruption scope. - Streaming progress back to the parent. The parent sees the child’s final response as a single string after completion.
- A
[agents.<alias>].subagent_*config block. The validator and override type ship today; the operator-facing config surface that plumbs caller-defined narrowing is not in this release. Both spawn sites passSubAgentOverrides::default()until that surface lands.