Role
Let the parent delegate “independent, context-heavy, parallelizable” tasks so it doesn’t bloat its own loop. Each subagent is a transient instance (a localHarnessAgent or a remote stub), with its own session, returning a result via tool result.
A minimal example
Simplest path: drop the spec into the workspace. The filename is theagent_id:
workspace/subagents/reviewer.md:
Three ways to declare
Three sources are merged at build time:Workspace spec files
Non-recursive scan ofworkspace/subagents/*.md; the filename (minus .md) is the agent_id — do not also set name in the front matter.
Programmatic declarations
workspace(...), inlineAgentsBody(...), url(...) — pick one.
Automatically built local subagents, including general-purpose, inherit the parent’s
HarnessAgent.Builder.enablePendingToolRecovery(...) setting (default false). A declaration can
override it with .enablePendingToolRecovery(true) or .enablePendingToolRecovery(false); null
inherits. Workspace specs accept enable_pending_tool_recovery (or enablePendingToolRecovery).
When enabled, a new ordinary message repairs orphaned pending tool calls with synthetic error
results, including calls loaded from a failed session. Pending permission confirmations still
require confirmation; empty-input resume and caller-supplied tool results retain their existing
behavior. Remote agents and custom factories configure recovery themselves.
Built-in general-purpose
No spec file needed; always available. Its role is “generic fallback” — it mirrors the parent’s capability (same model, tools, skills) and shares the parent’s workspace. Useful when the parent wants to isolate context for a sub-task without writing a dedicated spec.
ISOLATED vs SHARED
workspaceMode decides what counts as the subagent’s workspace:
- ISOLATED (default): the subagent has its own workspace (if
workspace.pathis omitted, the framework auto-creates a subdirectory). Subagent runtime state is bucketed per “parent sessionId × user” — so spawning the same subagent across different conversations of the same user doesn’t cross-contaminate. - SHARED: the subagent uses the parent’s workspace directly. Good for cases where the subagent’s output is read by the parent immediately (e.g.
general-purpose).
Sync or background?
The parent creates a subagent withagent_spawn; the key knob is timeout_seconds:
timeout_seconds > 0(default 30, max 600) — synchronous call; the parent blocks on this step, result returns as the tool result. By default, when the wait expires the in-flight run is promoted to a background task (status: timeout_promoted+task_id) and keeps running.timeout_seconds = 0— background call; returns atask_idimmediately, subagent runs in the background.
RuntimeContext. Application code can put AgentSpawnTool.CTX_FORCE_SYNC = true on the current call’s RuntimeContext to override the LLM’s async choice, and optionally CTX_FORCE_SYNC_TIMEOUT_SECONDS for a hard wait budget (seconds):
- If
CTX_FORCE_SYNC_TIMEOUT_SECONDSis set, it fully replaces the LLM’stimeout_seconds(<= 0falls back to 30s; max 600s). - Without that override, an LLM-supplied
timeout_seconds=0is coerced to the default sync timeout (30s) — no background task is submitted — while a positive LLM timeout is preserved. - If the sync wait expires, the tool returns
status: timeoutand interrupts the subagent — it is not promoted to a backgroundtask_id.
agent_send honors the same switch. Multiple force-sync agent_spawn calls in one turn still run in parallel under the Toolkit default.
If a goal can be split into independent subtasks that do not conflict on resources, the parent can issue multiple synchronous subagent calls in the same reasoning turn. Toolkit defaults to parallel tool execution (ToolkitConfig.parallel=true), so those synchronous calls advance in parallel on both ReActAgent and HarnessAgent; the parent enters the next reasoning step only after that batch of tool results has returned, forming a synchronous fan-out / fan-in barrier. To serialize tool calls, pass a custom Toolkit built with ToolkitConfig.builder().parallel(false).build().
Decompose work by independence and dependency graph first: nodes without dependency edges are good candidates for parallel subagents; dependent nodes should wait for upstream results before dispatch or merge. Short or critical-path tasks are good candidates for synchronous waiting or an explicit barrier so the parent can continue reasoning with their results. Long tasks can run in the background while the parent continues other work, then be collected and merged later.
Background tasks push back automatically
When a background task finishes, the parent does not need to poll — before the parent’s next reasoning step, the framework injects completed task results as a system reminder at the end of the conversation:Background task tools
Behind the scenes, subagent lifecycle is split across two groups of tools:agent_spawn / agent_send manage subagent instances (create, reuse, communicate); task_output / wait_async_results / task_cancel / task_list manage background task results (check status, fetch output, wait, cancel). The bridge between them is the task_id — returned by agent_spawn or agent_send when timeout_seconds=0.
In most cases the auto push-back mechanism delivers results without any explicit tool call. The task tools are useful as escape hatches: checking progress before push-back fires, waiting for a set of results that must be available together, cancelling tasks that are no longer needed, or recovering task state after conversation compaction.There are three common ways to collect asynchronous results:
- Automatic push-back: the default path when you do not block. Completed child tasks are injected as a
<system-reminder>before the next reasoning step. - Targeted task check: call
task_output(task_id, block=false)to inspect one task’s current state or final result. - Wait barrier (preferred for must-collect-all): call
wait_async_results(task_ids="id1,id2")orwait_async_results(wait_all=true). Barrier mode waits until the set is terminal and embeds each task’s result in the tool return, so you can continue immediately.wait_all=trueuses a snapshot of unfinished tasks at call start and does not add tasks created while waiting.
Legacy inbox-any: callingwait_async_resultswithouttask_idsand withoutwait_allonly waits until any inbox message arrives. That is not a wait-all barrier — usetask_idsorwait_all=truewhen every task in a group must finish.
Send a follow-up to an existing subagent
agent_spawn returns an agent_key (runtime instance handle). Use it or a label to send follow-up messages:
label at spawn time, you can use that instead of the agent_key:
agent_list.
Persistent sessions
By default everyagent_spawn creates a fresh subagent with a new session — no memory of previous calls. Set persistSession(true) in the declaration to reuse the same subagent instance across multiple spawns:
persistSession is on, the framework derives a deterministic key from (parentSessionId, agentId, label). If agent_spawn is called again with the same combination, the existing agent instance is reused — its conversation history and state are preserved.
Exposing subagents to the user
Normally subagents are invisible to the user — they run behind the scenes as the parent’s internal tools. Withexpose_to_user=true, the parent can make a subagent directly addressable by the user through the Channel:
- Registers the subagent in the Gateway as a user-addressable entry point
- Emits a
SubagentExposedEventinto the streaming event flow, carrying asubagentIdhandle
SubagentExposedEvent, and can then send messages directly to the subagent — bypassing the parent agent entirely:
How to enable
Useagent.channel(...) — the bridge is wired automatically, zero configuration:
expose_to_user=true in agent_spawn is silently ignored — the subagent still works normally, just not exposed to the user. For multi-agent setups with GatewayBootstrap, see Channel — Thread exposure with GatewayBootstrap.
Controlling exposure from code
Relying on the LLM to passexpose_to_user=true is not always flexible enough. You can override the decision from application code in two ways, and the effective value is resolved with this precedence (highest first):
RuntimeContextper-call override — applies to everyagent_spawnin the current callSubagentDeclarationper-type policy — a static default for that subagent type- The LLM’s
expose_to_usertool argument falsewhen none of the above expresses an opinion
RuntimeContext. Put a Boolean (or its string form) under the AgentSpawnTool.CTX_EXPOSE_TO_USER key:
exposeToUser — TRUE always exposes, FALSE never exposes (overriding an LLM expose_to_user=true), and null (default) defers to the context override and then the LLM argument:
Across restarts and multiple replicas
By default the exposure is in-process: thesubagentId is only valid on the node that created it and is lost on restart. To make an exposed subagent resolvable on any replica and across restarts, build the agent with a distributedStore(...) — the same one-liner used for state and filesystem:
subagentId is persisted in the store, and the subagent’s own conversation is reloaded from the distributed AgentStateStore by session — so the user keeps talking to the same subagent even if a later message lands on a different node. For multi-agent GatewayBootstrap, pass .distributedStore(...) (otherwise it inherits the main agent’s). Deployment guidance — including routing a subagentId back to its live node (sticky routing) — is in Going to Production.
Let the agent author new subagent specs
Theagent_generate tool (off by default) lets the LLM draft a new subagent spec and write it to workspace/subagents/<name>.md:
Behavior notes
- Write
descriptionwell: it’s the model’s primary signal for delegating. “Code review” is far less useful than “Use when the user wants to review a PR or check code style”. - Recursion safety: subagents cannot spawn further subagents (force-marked as leaves); plus a hard cap of 3 levels.
- userId is propagated: parent’s
RuntimeContext.userIdis forwarded to the child, so the multi-tenant isolation chain stays intact. - Permission inheritance: all DENY permission rules from the parent are automatically propagated to the child. If the parent is denied a tool, the child is also denied — the security boundary cannot be bypassed by delegation. Set
inheritParentPermissions(false)in the declaration to opt out. - Streaming forwarding: during the parent’s
stream(), intermediate events from synchronous subagents are forwarded back into the parent’sFluxlive (with source tags); see Subagent streaming below.
Remote subagent
Just seturl + optional headers and the subagent runs through a remote HTTP service (Agent Protocol):
timeout_seconds>0) / background (timeout_seconds=0) semantics apply.
Declaration knobs specific to remote mode:
Remote streaming detail
A local subagent forwards its child’s events verbatim. A remote one has to cross the wire, and how much crosses is controlled byremoteStreamDetail, sent as context.detail:
Pick
VERBOSE when the parent’s stream should look the same whether the subagent is local or remote; that is the only level at which the remote subagent’s tool output content and token usage reach the parent. It is not the default because the extra events are pure volume for callers that only render text.
Events without a dedicated wire type travel as AGENT_EVENT with the original event serialized in the payload field, so the parent decodes the exact same class it would have received locally, ids, timestamps and metadata included. A client older than this field still reads the flat per-type fields and simply never sees the passthrough events.
Remote authorization
Parent DENY permission rules are forwarded in the remote submitcontext.deny_rules (same inheritance as local children; opt out with inheritParentPermissions(false)).
When the remote agent pauses for tool confirmation (awaiting_confirm):
- Streaming parent +
remoteAskPolicy=PROPAGATE: aRequireUserConfirmEventis forwarded into the parent’sstreamEvents()stream with a non-nullsourcetag. Resume the remote task via Agent ProtocolPOST /tasks/{id}/resumewithdecisions[{toolCallId, approved}]. - Non-streaming parent (
call) orremoteAskPolicy=DENY(default): pending confirmations are auto-denied. The tool result includes a note:remote tool confirmation(s) were auto-denied.
RUNNING (awaitingConfirm=true). Barriers such as wait_async_results therefore keep waiting until the task is resumed and reaches a terminal status.
Background task storage
Background task state is written by default toworkspace/agents/<parentAgentId>/tasks/<sessionId>.json. So:
- In shared-store mode (multi-replica) any node can read task state;
- Task execution pins to the creating node, but any node can read the result and push it back to the parent;
- Cancel from any node via
task_cancel— the executing node polls the cancel flag and aborts.
Delegating during Plan Mode
When the parent is in Plan Mode, spawned subagents automatically inherit the read-only restriction. The child enters Plan Mode at spawn time, so it cannot perform write operations — the safety boundary is maintained across the delegation chain.Subagent streaming
New code should useWhen the parent calls a synchronous subagent viastreamEvents()(returnsFlux<AgentEvent>). The legacystream()family (Flux<Event>) is@Deprecated(forRemoval = true)since 2.0.0 — see Message & Event and V1 Migration Guide B.4.
agent_spawn / agent_send, the child’s intermediate events are forwarded live into the parent’s streamEvents() stream. Each child event carries a source field (a /-separated path like "main/researcher") so you can tell parent events (source == null) from child events. Remote Agent Protocol children additionally set metadata.taskId (AgentEvent.METADATA_TASK_ID) to the harness task id and metadata.parentSessionId (AgentEvent.METADATA_PARENT_SESSION_ID) to the parent session, so two concurrent / same-turn calls to the same remote agent remain distinguishable even when they share a source path.
Using streamEvents() (recommended)
SSE forwarding
Behavior boundaries
Error handling
When a child throws internally, the framework captures it and writes aTOOL_RESULT back to the parent. It does not propagate onError into the parent stream — child failures don’t break the parent. If the parent stream itself errors, use standard Reactor semantics (onErrorResume, etc.).
Related pages
- Channel —
expose_to_user,SendOptions, direct user-to-subagent messaging - Workspace —
subagents/andagents/<id>/tasks/layout - Plan Mode — restrictions on subagents during the plan phase
- Architecture — how parent and child cooperate
- Agent Protocol — remote task endpoints (SSE + HITL resume)
- Message & Event —
AgentEventhierarchy (recommended) and the deprecatedEvent/EventType/StreamOptionstypes - V1 Migration Guide B.4 —
stream()→streamEvents()deprecation timeline