Delegation & 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 spawn_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 and model provider, but delegation is gated: the caller’s risk profile must setdelegation_policy mode = "allow"(default is"forbidden"), and the target must be reachable as either a same-profile peer or an explicitdelegatesentry. Explicit entries choosemode = "bounded"ormode = "independent", which determines whether the caller’s tool ceiling still applies. Use when another configured specialist should own the work. See Delegation gating below.
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,SpawnSubagentTool::new). - From cron:
JobType::Agentjobs run throughrun_agent_job(crates/zeroclaw-runtime/src/cron/scheduler.rs) 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 tool gate. If the parent’s
[risk_profiles.<alias>].deny_all_toolsistrue, orallowed_toolsis non-empty and does not listspawn_subagent, orexcluded_toolslists it, refuse with a message naming the parent alias. An omitted or emptyallowed_toolsleaves the tool available (the legacy unrestricted state).
- 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, suppress_memory_inject: true }(the child’sSubTurnorigin already skips engine memory injection; the flag makes the opt-out explicit) and awaitscrate::agent::run(crates/zeroclaw-runtime/src/agent/loop_.rs,pub async fn run) 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.
A synchronous delegation has no streaming or partial-progress channel back to the parent: a long-running SubAgent stalls the parent’s tool execution for its full duration, and there is no per-call timeout knob. A background delegation reports bounded live progress through check_result and await_sessions instead (see the progress field under the background result shape below).
Execution-tree iteration budget
Set max_execution_tree_iterations on the target’s [runtime_profiles.<alias>] entry to cap aggregate tool-loop iterations across one foreground execution tree:
[runtime_profiles.default]
max_execution_tree_iterations = 12
The field is optional. Omission disables the aggregate budget, while an explicit 0 is invalid. This budget is separate from max_tool_iterations, which remains the local per-loop cap for each agent turn.
One logical root turn owns one atomic budget handle. Foreground spawn_subagent, bounded synchronous or parallel delegate targets, and explicit live SOP child work consume from that handle; a child may reserve only while more than one slot remains. When the root has only its final slot left, it consumes that slot for the existing tools-free completion path. A child that exhausts its view returns a failed ToolResult, allowing the parent root turn to continue, and concurrent reservations cannot wrap the counter.
Independent foreground delegate targets use their own runtime profile’s budget, not the caller’s remaining allowance. Omitting the target’s limit leaves that independent turn uncapped by an execution-tree budget even when the caller has one. Detached background delegate tasks and peer turns also mint a new root from the recipient’s runtime profile rather than sharing the launching turn’s budget.
Multiple calls in one turn
The agent loop applies a per-turn duplicate-call guard: a tool called twice with identical arguments in the same turn normally has the second call skipped. spawn_subagent and delegate are exempt from that guard. Launching several with the same prompt (redundancy, sampling, fan-out) is an intentional pattern, not an accidental repeat, so each identical call runs and each result is returned. Without the exemption only the first identical call would execute and only its output would reach the model.
When parallel tool execution is enabled (parallel_tools = true in the runtime profile), multiple spawn_subagent calls in one turn run concurrently and every child’s final response is returned to the parent, keyed to its own tool call. delegate has its own explicit fan-out via the parallel: [...] argument (see the output-strings section); that path spawns each target on its own task and aggregates all results.
ZeroCode transcript inspection
Foreground spawn_subagent and delegate calls have expandable transcript cards showing the task, delegate target names when provided, and retained result text. Parallel delegation remains one invocation with the existing per-target result blocks. Additional arguments, such as context, remain visible in the input field. Malformed or oversized arguments fall back to the generic tool card. Expansion retains the tool display limits of 8 KiB or 100 lines per field; Copy uses the original retained entry, whose result may already have been capped at 16 KiB.
Delegate target names appear in the card heading, followed by the task and a Response section. For a matching synchronous delegate envelope, the returned answer appears first and the provider/model metadata remains in expanded Details. Other result formats stay intact. A missing response says Result not recorded. The card does not assert that a child is currently running or that the child succeeded, including in restored history or returned diagnostic text. Background launches and management actions such as check_result and await_sessions keep generic cards, so a launch acknowledgment is not displayed as child completion.
Live child inspection still needs runtime metadata correlated to the parent tool-call ID: child task/session identity, authoritative lifecycle and outcome, timestamps, and child tool events or an authorized transcript link. Current parent tool notifications do not carry these fields. ZeroCode does not infer them from result text, tracing logs, prompt matching, or target aliases; the runtime control plane remains the lifecycle owner. This is a partial implementation of #8763, not live child supervision.
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) 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 sameis_subagent_callerflag dropsmodel_switchfrom the child’s registry entirely: a SubAgent inherits the parent’s model verbatim (see axis 5) and must not be able to switch the active model out from under the parent, so the tool is simply not offered to it.
- The policy’s
- Memory allowlist: the parent’s sibling grants from
workspace.read_memory_fromplus the parent’s own alias. Category restrictions remain exact and are never broadened by a SubAgent; the parent’s own alias is always re-added so a SubAgent always sees the parent’s rows. The currentSubAgentOverrides::allowed_agent_aliasesoverride is validated as a name subset, so it can only narrow which granted siblings are visible. - 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)). This inheritance is enforced, not merely a default:model_switchis excluded from the SubAgent’s tool registry (see axis 3), so a SubAgent cannot switch its own model. To run a subtask on a different model, usedelegateto a sibling agent whosemodel_providernames that model. - 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. The risk-profile gate lives in [risk_profiles.<alias>].allowed_tools, [risk_profiles.<alias>].deny_all_tools, and [risk_profiles.<alias>].excluded_tools. A non-empty allowed_tools list must include spawn_subagent or delegate for the model to see that tool; an omitted or empty allowed_tools leaves tool availability unrestricted unless excluded_tools names the tool; deny_all_tools = true is the explicit deny-all gate and hides both tools (an empty allowed_tools list is not deny-all). Restart the daemon after editing the config.
What’s verifiable end-to-end:
- Protocol-owned tool output and refusal strings are literal Rust contracts. User-visible terminal-completion failure delivery is a Fluent catalog contract: the English source is named below, and a non-English catalog or disk override that defines the same key may render it differently.
- 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 tool gate excludes
spawn_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.
Delegation gating
delegate enforces two gates in crates/zeroclaw-runtime/src/tools/delegate.rs before a target agent runs, in this order:
-
delegation_policy.mode: the caller’s risk profile must permit delegation.[risk_profiles.<alias>].delegation_policyis{ mode = "forbidden" }by default; setmode = "allow"to permit delegation at all. When forbidden, the refusal is:delegation is forbidden for caller "<caller>" by risk profile "<caller_profile>" delegation_policy; set [risk_profiles.<caller_profile>].delegation_policy mode = "allow"This is editable in the gateway dashboard and zerocode at Config → Risk profiles →
<profile>→delegation_policy.mode(a forbidden/allow select). -
Reachability: the target agent must be in the caller’s reachable set, resolved by
Config::reachable_delegate_target_configs. The reachable set is the union of two per-agent sources on[agents.<caller>], minus the caller itself:- same-profile peers: every other agent sharing the caller’s risk profile, included while
delegate_same_risk_profile = true(the default). Set itfalseto opt the caller out of auto-allowing peers. - explicit roster:
delegates, a possibly-empty list of targets the caller may delegate to even across risk profiles. String entries are convenient for manual editing and stand for bounded targets. Object entries make the mode explicit:
When config is saved, every entry is written in object form withdelegates = [ "reviewer", { agent = "sysadmin", mode = "independent" }, ]mode = "bounded"ormode = "independent". Do not roll this config shape out before the daemon and UI binaries have been upgraded to a build that supports delegate modes. Older binaries expectdelegatesto contain only strings; an object entry makes theagentssection invalid for that binary and the resilient loader drops the section so the repair surfaces can still start.
When the target is outside that set, the refusal names the cause. For example:
delegate target "<target>" is not reachable from "<caller>": different risk profile (caller uses "<caller_profile>", target uses "<target_profile>"). delegate_same_risk_profile only reaches agents with the same risk profile; add an explicit [agents.<caller>].delegates entry with the intended mode, or change one agent's risk_profile.delegate target "<target>" is not reachable from "<caller>": delegate_same_risk_profile is disabled and the target is not listed in [agents.<caller>].delegatesdelegate target "<target>" is not reachable from "<caller>": the target agent is disabledA bounded target inherits the caller’s action/cost tracker with the effective ceilings tightened to the caller’s values at every hop, so a caller’s action budget binds its whole subtree no matter what the target’s own profile allows. Spawned background and parallel workers restore the originating sender’s rate-limit bucket before their first admission, so the budget stays per-sender across spawn boundaries instead of falling back to the unscoped bucket. The cost ceiling is combined the same way at every hop and enforced inside the delegated sub-loop against the target agent’s own daily spend (see the cost-tracking paragraph below). When the bounded target shares the caller’s risk profile, it also inherits the caller’s session workspace boundary. A bounded cross-profile target is allowed when it is reachable through the caller’s delegate roster and
delegation_policy; it runs under the target’s resolved policy, while agentic tool availability is capped by the caller’s tool registry.An independent target is available only when explicitly listed with
mode = "independent". It still requiresdelegation_policy.mode = "allow"and reachability throughdelegates, but once selected it resolves the target agent’s own policy without the caller’s non-escalation ceiling, session workspace override, or action/cost tracker.Cost tracking is enforced inside every delegated sub-loop, bounded and independent alike: the delegated sub-loop runs under a cost-tracking scope attributed to the target agent alias, so its token usage lands in the shared daily cost ledger under that alias. The target’s effective per-hop ceiling from
[runtime_profiles.<target>].max_cost_per_day_cents(a value of0inherits the global limit) is checked against the target agent’s own daily spend on that ledger, while the process-global[cost] daily_limit_usdand monthly limits continue to apply to the shared total; the shared limit wins whenever it is the lower of the two. The scope kind (per-agent or shared-capped, from[cost] track_per_agent), whether a delegated run is scoped at all ([cost] enabled), and the per-agent attribution of its ledger rows are all decided once at the root delegation of a tree and inherited by every nested hop: a nested delegation derives its tracker from the delegating parent’s, so the mode the tree started under binds the whole tree, and a mid-run reload of those flags can neither untrack a scoped run nor start reattributing an unattributed one halfway through. The limits and the pricing map stay live at every hop: the derived tracker shares the base’s config handle and the pricing map is rebuilt from the resolved config, so an operator reloading the global[cost]limits binds running delegates at their next budget check. When the target’s own spend already exceeds its ceiling, the delegation fails before the target’s provider is called, and the refusal surfaces to the caller as the delegate tool’s error naming the agent. An ancestor’s ceiling also covers everything it delegated to: every bounded hop’s budget scope carries the ancestor chain, so a bounded descendant’s spend counts against each ancestor’s per-hop ceiling even though the descendant’s ledger rows stay attributed to the descendant’s own alias. An independent target runs under its own per-hop ceiling and the shared global limits instead: it inherits the tree’s enforcement mode but not the caller’s ceiling or chain, so neither the caller’s exhausted ceiling nor its descendant accumulator can gate the independent hop. Descendant accounting is per process lifetime and per UTC day: it rolls over with the ledger’s daily totals at UTC midnight, and a daemon restart forgets descendant spend while every agent’s own ledger totals persist. With[cost] track_per_agent = falseno per-agent daily totals exist, so the per-hop ceiling degrades to a check against the shared daily total. Non-agentic delegate targets (single model call, no tool loop) run under the same scope, but their dispatch path reports no token usage, so those single calls are neither priced nor budget-checked today. - same-profile peers: every other agent sharing the caller’s risk profile, included while
The advertised roster is included in the agent parameter description in the tool schema. It lists exactly this reachable set, and only when delegation_policy.mode = "allow". Disabled agents (enabled = false) are never reachable, whether as same-profile peers or explicit delegates entries.
In bounded agentic delegation the sub-agent’s tools are drawn from the caller’s already-policy-filtered registry, intersected with the target’s own allowed_tools. An omitted or empty allowed_tools on the target means “inherit”: the sub-agent runs with the caller’s full delegatable registry rather than being rejected (an empty list is the legacy unrestricted state, not deny-all). deny_all_tools = true is the explicit deny-all gate and yields an empty child registry. A non-empty list intersects with that registry. Either way the caller’s registry is the ceiling: a bounded cross-profile target whose risk profile names a tool the caller was never granted does not receive it. Bounded delegation is therefore tool-bounded, not a full SecurityPolicy::ensure_no_escalation_beyond check. If that intersection is empty, the target still receives a normal agentic model turn with no tools.
The delegate tool itself follows the same bounded shape: it is stripped from the child registry unless the target’s risk profile sets delegation_policy mode = "allow" (the default "forbidden" keeps today’s no-sub-delegation behavior), and the caller-side gates still apply: the caller’s allowed_tools/excluded_tools must admit delegate, and the target’s tool policy must not exclude it. When granted, the child’s delegate instance is bound to the target’s identity: its calls resolve delegation_policy, reachability, and the advertised roster from the target’s own alias and delegates config, never the delegating parent’s, and each granted hop increments the depth counter (see below).
Two approval and scoping rules apply to the granted instance. First, it is delegate-only: the task-management actions (check_result, list_results, cancel_task, await_sessions) are refused, because the child instance’s identity is transient. The task row records the delegation chain of caller aliases, so every ancestor of the child is admitted by the owner check; only an ancestor whose own delegate tool carries task management (normally the root delegating agent, since bounded intermediate children are delegate-only too) can act on that admission, so a child passes the task id upward until it reaches one. Note the boundary precisely: this scopes the delegate API surface only. The underlying delegate_results/*.json artifact remains an ordinary workspace file holding task id and output only, and anything the owner check gates lives in the task row. Second, sub-agent loops have no operator approval route. If the target’s risk profile would prompt for delegate (the supervised default, or the tool listed in always_ask), the child approval gate refuses the call before the delegate implementation runs, reporting that no operator decision was available. The delegate implementation retains its own target-policy check for calls that reach it. Nested delegation therefore requires the target profile to explicitly auto-approve delegate or run at full autonomy; a caller profile that would prompt keeps prompting in its own loop, unchanged.
The same no-operator boundary applies to tool calls in a bounded agentic child. The child loop creates a fresh non-interactive approval manager from the target’s risk profile at the dispatch seam. A prompt-required call is denied before the tool implementation runs, while a call explicitly listed in auto_approve (or allowed by full autonomy) may proceed. For inherited caller-owned command tools (shell, schedule, cron_add, cron_update, and cron_run), that target-profile decision approves only tool invocation: it does not supply or override the command-specific approval consumed by the caller’s command policy. The caller’s command policy remains authoritative, so those tools continue to enforce its approval requirements. The inherited caller-capped registry is unchanged: approval evaluates calls that survived capability filtering; it does not grant additional tools or forward the caller’s approval session.
In independent agentic delegation the sub-agent’s tools are built from the target agent’s own configured policy and runtime registry, like opening a fresh chat with that target. The parent registry is not used as the ceiling. The delegate tool is removed from that child registry: independent delegation does not recurse through another delegate call.
Depth is capped per the delegating parent’s runtime_profile.max_delegation_depth, and each granted hop carries that ceiling tightened (min) with the next target’s own profile cap, so a parent’s cap binds its whole subtree and a chain can only tighten. Set it to 1 to allow the top agent a single delegation hop with no further sub-delegation.
Agentic target tool policy
If the target agent’s [runtime_profiles.<target>].agentic = true, delegate builds the target sub-loop’s tool registry from either the parent’s available tools (mode = "bounded") or the target’s own runtime registry (mode = "independent"). The target risk profile then filters that registry:
- An omitted or empty
[risk_profiles.<target_profile>].allowed_toolsfield leaves the selected registry unrestricted.deny_all_tools = trueis the explicit deny-all gate and yields an empty child registry. - A non-empty
allowed_toolslist keeps only exact matching tool names. [risk_profiles.<target_profile>].excluded_toolsalways subtracts from the result.delegateis removed from the child registry unless bounded sub-delegation is granted: a bounded target whose risk profile setsdelegation_policy mode = "allow"(and whose caller-side gates admit the tool) receives a target-bound, delegate-only instance that additionally refuses delegation when the target’s profile would prompt for it; independent child registries always drop it.
This policy lives on the target, not the caller. Same-profile peers use the shared risk profile. Explicit cross-profile delegates use the target’s risk profile after the reachability and delegation-policy gates. Bounded agentic delegates receive only the caller-capped tool registry intersected with the target’s tool policy; independent agentic delegates receive the target-owned tool registry. A missing target risk profile refuses before the sub-loop starts. A configured profile that leaves zero executable child tools still permits a normal model turn with no tools.
When the target’s configured Reliable provider chain mixes native-tool-capable and text-only candidates, strict_tool_parsing = false uses one text/XML tool protocol for the whole agentic turn so every reachable fallback can execute tools. If effective tools remain and strict_tool_parsing = true, ZeroClaw rejects the mixed chain before making a provider request because strict parsing forbids that text/XML fallback protocol. Uniform chains are unchanged: all-native chains use native tool transport, while deliberately all-text chains follow the configured text-tool policy.
Independent delegate approvals
An independent agentic target can send tool approval requests to the operator selected by its own risk profile’s approval_route. Set both approver_channel and a non-blank approver_recipient, and keep that channel enabled and registered in the running daemon. The caller must already authorize that named target in independent mode; an approval route does not grant delegation reach or additional tools.
For example, a target using the worker risk profile can route its approvals to a configured operations channel:
[risk_profiles.worker.approval_route]
approver_channel = "telegram.ops"
approver_recipient = "operations-room"
on_no_approver = "deny"
timeout_secs = 120
Foreground, background, and parallel independent delegates use the same target-route approval gate. The prompt identifies the target and caller, keeps the real tool name and argument summary, and uses the configured recipient. A real approval allows the target-owned call subject to its existing tool and command policy. A denial, missing channel, unsupported approval surface, disconnection, or timeout leaves the tool unexecuted. The child execution timeout also bounds the approval wait and can expire before the route timeout.
A complete target route also applies when the profile was already configured for the target’s own channel turns. For a Supervised independent child, shell calls now require an operator decision unless the target explicitly auto-approves shell. This includes low-risk commands that previously went directly to the shell tool’s command policy. In a process without a channel registry, such as a one-shot CLI run, those prompt-required calls are denied. Full autonomy and explicit auto_approve entries retain their existing behavior, while always_ask still requires a decision. Remove the route to restore the previous non-interactive shell path only if that matches the intended target policy.
Each child run has a fresh approval manager. An “Always” answer applies only within that child run; it cannot approve the caller’s calls, later delegate runs, or parallel siblings. Tools in always_ask still require a decision for every call, including under Full autonomy. Explicitly auto-approved tools otherwise retain their target-profile behavior.
Delegates do not inherit the caller’s CLI, ACP, RPC, or dashboard approval session. Even on_no_approver = "inherit-originator" denies when the configured approver cannot answer, because an independent child has no originating approval channel to fall back to. Pending approval is process-local and is abandoned on child cancellation or timeout; restarting a daemon does not resume that request or restore child approval grants.
Without a complete target route, independent targets with non-empty always_ask are refused before execution, including background and parallel admission. Other prompt-required calls still fail closed at tool dispatch. Bounded agentic delegates keep their existing no-operator behavior and caller-owned command policy.
delegate: output strings the model sees
User-visible failure strings are localized Fluent messages. Their English source
of truth is crates/zeroclaw-runtime/locales/en/cli.ftl; examples below show
the current English catalog values, not a wire-level string contract. The
remaining listed strings are protocol/tool outputs unless this section labels
them as Fluent keys.
- Synchronous success: output begins with
[Agent '<target>' (<provider_type>/<model>)]\nfollowed by a non-empty target agent response. When the target recovers through a configured provider fallback, its header instead identifies the requested and served provider/model, for example[Agent 'reviewer' (requested: anthropic.primary/claude; served: openai.terra/gpt-5.6-terra, agentic)]. For an agentic target, this attribution describes the model request that produced the final response, not an earlier request that only produced a tool call. The result also ends with the localizeddelegate-provider-fallback-warning. In English:Warning: The delegated agent recovered through a provider fallback. Provider failure details were logged and omitted from this result.This attribution and warning belong to the delegated result; they must not be presented as a fallback of the calling agent. They intentionally omit rejected-provider error details, endpoints, and credentials. Retrying the same configured candidate does not produce this warning; reaching a later configured candidate does, even when its provider and model labels match the first candidate. - A terminal empty response is a synchronous failure: its error field uses
cli-delegate-error-invalid-semantic-completion, withagent_nameset to the target. In English:Agent '<target>' failed: model provider returned an invalid semantic completion. - Other synchronous failures: error field begins with
Agent '<target>' failed: <wrapped error>. If every configured provider candidate fails,<wrapped error>is Reliable’s ordered safe summary of the failure events, retry count, failure class, phase, and fixed remediation hint. Provider response bodies, endpoints, aliases, models, and credentials are not returned to the calling agent; investigate provider-attempt logs through the installation’s normal operator logging policy when more detail is needed. The result remains an error, not a recovery warning. - 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
Lifecycle state is stored inBackground task started for agent '<target>'. task_id: <uuid> Use action='check_result' with task_id='<uuid>' to retrieve the result.data/control_plane.db. The workspace artifact at<workspace>/delegate_results/<uuid>.jsoncontains task identity and output only; it is not a second status record. Thecheck_resultview (and each entry of anawait_sessionsresult) carries aprogressobject once the task has started, ornullfor rows that never recorded one. Its fields:last_activity_at,iterations(model requests issued),tools_completed,last_tool(name,started_at,finished_at,success: the newest call still running, withfinished_atnull, else the last call that finished),timeout_budget_secs(the agentic timeout, or the single-call timeout for a target without a tool loop),recent_tools(the last 5 finished calls),receipt_tail(the last 5 receipts collected so far, as<tool>: <token>, empty when tool receipts are off; receipts are collected when a tool batch returns, so a call can be counted before its receipt appears), andelapsed_secs, computed at read time. A single-call target counts its one request initerations. Tool names are cut at 64 characters everywhere they appear, including the receipt tail; signed tokens are kept whole. Tool arguments, tool results, error text and prompt content are never recorded. Progress lives in the task row, so it is visible exactly to the callers that can read the task’s output. While the task runs, its row’s heartbeat is refreshed at least once a minute. action="check_result"with an unknown task id: error isNo result found for task_id '<uuid>'.action="await_sessions"withtask_ids: [<uuid>, ...]waits for multiple background result files at once. The output is a JSON object withstatus(completeortimeout),completed,pending,missing,failed, andresults.timeout_msdefaults to 30000 and is capped at 120000; on timeout the tool returns partial results and an error saying one or more tasks are still pending or missing. Duplicate task IDs are rejected.- 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. A recovered target keeps its requested-versus-served attribution and generic fallback warning inside its own block. 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/await_sessions. - Target whose risk profile has
always_askentries: an independent target without a complete explicit approval route returnsdelegate target "<target>" cannot run in independent mode from "<caller>": risk profile "<profile>" has always_ask entries (<list>). See ZeroClaw docs, "Delegation & SubAgents" > "What's not supported".A bounded agentic target returns the same error withcannot run in bounded agentic mode. - Agentic target with a missing target risk profile: error is
Agent '<target>' is agentic but risk_profile '<target_profile>' is not configured. - Agentic target with zero executable child tools: no error is emitted for the empty tool set itself; the target receives a normal model turn without tools.
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. For background mode, use check_result or await_sessions to read the authoritative lifecycle state and output together. Those actions, listing, and cancellation are limited to delegate rows whose recorded chain contains the caller’s alias. The workspace artifact can confirm that output was persisted, but it does not carry status. When tool receipts are enabled, background delegations run with a receipt scope that shares the parent’s generator and uses their own collector, so their tool results are signed and verifiable but are not appended to the launching turn’s receipts block.
(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.)
delegate: what stopping the turn cancels
Stopping the turn that called delegate (for example POST /api/sessions/{id}/abort, or a channel interruption) reaches the work that turn started, even though the children run in their own tasks and outlive the parent’s dropped tool future:
- Agentic children (bounded or independent) are bound to the launching turn’s cancellation and stop with it.
- Parallel children (
parallel: [...]) that run their own tool loop are bound the same way, at every depth. A parallel child that makes a plain model call without a tool loop is not interrupted mid-call: it ends with that call’s own timeout. - Background tasks (
background: true) are not bound to the turn at any depth, including one launched by a child that was itself started in the foreground. They outlive the turn by design and stop only throughcancel_task.
spawn_subagent has none of this: it has no background or parallel mode, and its child is cancelled with the parent future.
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; same-profile peer or an explicit cross-profile delegate) |
| Permission model | Parent’s policy verbatim (or narrowed subset) | Bounded targets run under the target policy with the caller’s agentic tool registry as ceiling; independent targets run under the target policy and target-owned registry |
| 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 | No built-in argument; multiple calls in one turn run concurrently when parallel_tools = true | parallel: [...] runs multiple targets concurrently |
| Gating | Omitted or empty risk_profile.allowed_tools leaves spawn_subagent available; deny_all_tools = true denies it; a non-empty list must include spawn_subagent; excluded_tools must not list it | The caller’s omitted or empty allowed_tools leaves delegate available; deny_all_tools = true denies it; a non-empty list must include delegate; excluded_tools must not list it; caller’s delegation_policy mode = "allow"; and the target is in the caller’s reachable set (same-profile peer or explicit delegates entry). A bounded target may itself receive the delegate tool only when its own risk profile sets delegation_policy mode = "allow"; the granted instance is delegate-only and refuses delegation when the target’s profile would prompt for it |
| Use when | Internal subtask that should stay within the same identity | Want a different configured specialist (different model, different alias) to own the task under bounded or independent delegation |
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 (fordelegate, see what stopping the turn cancels). - 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. - Origin-session approval forwarding and bounded approvals. Independent delegates can use an explicit target-owned approval route, but cannot forward approval to the caller’s originating CLI, ACP, RPC, or dashboard session. Bounded agentic targets with non-empty
always_askremain blocked before execution, including background and parallel delegation. Non-agentic bounded delegation remains available because it cannot execute child tools.