Skip to main content

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 local HarnessAgent 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 the agent_id: workspace/subagents/reviewer.md:
The parent can now call it during reasoning:
No registration step.

Three ways to declare

Three sources are merged at build time:

Workspace spec files

Non-recursive scan of workspace/subagents/*.md; the filename (minus .md) is the agent_iddo not also set name in the front matter.

Programmatic declarations

Three sources are mutually exclusive: 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.path is 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 with agent_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 = 0background call; returns a task_id immediately, subagent runs in the background.
Force sync via 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):
When enabled:
  1. If CTX_FORCE_SYNC_TIMEOUT_SECONDS is set, it fully replaces the LLM’s timeout_seconds (<= 0 falls back to 30s; max 600s).
  2. Without that override, an LLM-supplied timeout_seconds=0 is coerced to the default sync timeout (30s) — no background task is submitted — while a positive LLM timeout is preserved.
  3. If the sync wait expires, the tool returns status: timeout and interrupts the subagent — it is not promoted to a background task_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:
The parent naturally responds or continues. This means you do not write “remember to poll task_output” in your prompt — that was the old way.

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") or wait_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=true uses a snapshot of unfinished tasks at call start and does not add tasks created while waiting.
Legacy inbox-any: calling wait_async_results without task_ids and without wait_all only waits until any inbox message arrives. That is not a wait-all barrier — use task_ids or wait_all=true when 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:
If you set a label at spawn time, you can use that instead of the agent_key:
To list active subagents: agent_list.

Persistent sessions

By default every agent_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:
When 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. With expose_to_user=true, the parent can make a subagent directly addressable by the user through the Channel:
This does two things:
  1. Registers the subagent in the Gateway as a user-addressable entry point
  2. Emits a SubagentExposedEvent into the streaming event flow, carrying a subagentId handle
The user’s client receives the SubagentExposedEvent, and can then send messages directly to the subagent — bypassing the parent agent entirely:
This is useful for “branch-off” scenarios: the parent spawns a specialist, and the user continues the conversation with that specialist independently. See Channel — Talking to exposed subagents for the full Channel-side API.

How to enable

Use agent.channel(...) — the bridge is wired automatically, zero configuration:
Without a Channel binding, 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 pass expose_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):
  1. RuntimeContext per-call override — applies to every agent_spawn in the current call
  2. SubagentDeclaration per-type policy — a static default for that subagent type
  3. The LLM’s expose_to_user tool argument
  4. false when none of the above expresses an opinion
Per-call override via RuntimeContext. Put a Boolean (or its string form) under the AgentSpawnTool.CTX_EXPOSE_TO_USER key:
Per-type policy on the declaration. Use the tri-state exposeToUserTRUE 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:
Or in a Markdown subagent spec’s front matter (also tri-state — omit the key for “no opinion”):
This lets you force or forbid exposure regardless of what the model decides, while still allowing the LLM to choose when neither code source expresses an opinion.

Across restarts and multiple replicas

By default the exposure is in-process: the subagentId 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:
The 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

The agent_generate tool (off by default) lets the LLM draft a new subagent spec and write it to workspace/subagents/<name>.md:
Useful when “halfway through, the agent realizes it needs a new kind of helper”. Use with care in production — usually you’d have the agent draft the spec and have a human review before writing the file.

Behavior notes

  • Write description well: 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.userId is 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’s Flux live (with source tags); see Subagent streaming below.

Remote subagent

Just set url + optional headers and the subagent runs through a remote HTTP service (Agent Protocol):
Same sync (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 by remoteStreamDetail, 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 submit context.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: a RequireUserConfirmEvent is forwarded into the parent’s streamEvents() stream with a non-null source tag. Resume the remote task via Agent Protocol POST /tasks/{id}/resume with decisions[{toolCallId, approved}].
  • Non-streaming parent (call) or remoteAskPolicy=DENY (default): pending confirmations are auto-denied. The tool result includes a note: remote tool confirmation(s) were auto-denied.
While awaiting confirmation, task status stays 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 to workspace/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 use streamEvents() (returns Flux<AgentEvent>). The legacy stream() family (Flux<Event>) is @Deprecated(forRemoval = true) since 2.0.0 — see Message & Event and V1 Migration Guide B.4.
When the parent calls a synchronous subagent via 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.
Distinguish parent vs child events:

SSE forwarding

Behavior boundaries

Error handling

When a child throws internally, the framework captures it and writes a TOOL_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.).
  • Channelexpose_to_user, SendOptions, direct user-to-subagent messaging
  • Workspacesubagents/ and agents/<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 & EventAgentEvent hierarchy (recommended) and the deprecated Event / EventType / StreamOptions types
  • V1 Migration Guide B.4stream()streamEvents() deprecation timeline