Sub-Agent Orchestration
Sub-agents enable hierarchical agent systems where a parent agent can delegate specialized tasks to child agents. This is powerful for complex workflows that benefit from separation of concerns.
What Are Sub-Agents?
A sub-agent is an agent invoked by another agent as if it were a tool. The parent agent decides when to delegate, the sub-agent executes to completion, and the result flows back to the parent.
graph TB
Parent["Parent Agent"]
Parent --> Tools["Uses regular tools"]
Parent --> SubAgent["Delegates to Sub-Agent"]
SubAgent --> Run["Sub-agent runs to completion"]
SubAgent --> Result["Result returned to parent"]Creating Sub-Agent Tools
Use createSubAgentTool() to turn an agent into a tool:
import { defineAgent, createSubAgentTool } from '@helix-agents/sdk';
import { z } from 'zod';
// Define a specialized agent
const AnalyzerAgent = defineAgent({
name: 'text-analyzer',
description: 'Analyzes text for sentiment and topics',
systemPrompt: 'You analyze text. Determine sentiment and extract key topics.',
outputSchema: z.object({
sentiment: z.enum(['positive', 'negative', 'neutral']),
confidence: z.number(),
topics: z.array(z.string()),
}),
llmConfig: { model: openai('gpt-6-luna') },
});
// Create a tool that invokes this agent
const analyzeTool = createSubAgentTool(
AnalyzerAgent,
z.object({
text: z.string().describe('Text to analyze'),
}),
{
description: 'Analyze text for sentiment and key topics',
timeoutMs: 60_000, // Optional: per-tool timeout in ms
}
);
// Use in parent agent
const OrchestratorAgent = defineAgent({
name: 'orchestrator',
systemPrompt: 'You coordinate research. Use the analyzer for sentiment analysis.',
tools: [searchTool, analyzeTool], // Mix regular tools and sub-agents
llmConfig: { model: openai('gpt-6.1-sol') },
});Requirements
Sub-agents must have outputSchema:
// ✓ Valid sub-agent - has outputSchema
const ValidSubAgent = defineAgent({
name: 'valid',
outputSchema: z.object({ result: z.string() }), // Required!
// ...
});
// ✗ Invalid sub-agent - no outputSchema
const InvalidSubAgent = defineAgent({
name: 'invalid',
// No outputSchema - will throw error when used as sub-agent
// ...
});The outputSchema defines the contract between parent and child - what the parent receives as the tool result.
Cloudflare Durable Objects Runtime
In the DO runtime, createSubAgentTool() works transparently — no changes to your agent definitions are needed. Internally, each sub-agent is routed to a sibling DO instance with its own isolated SQLite state. You only need to add subAgentNamespace to your createAgentServer config, and register all sub-agents in the same AgentRegistry. See Sub-Agents in the DO Runtime for setup details.
HITL cascade: when a sub-agent suspends (v7)
If a sub-agent calls a client-executed tool or hits an approval gate, the v7 stateless-suspension model cascades the suspension up to the parent:
- The child's run loop yields
RunOutcome.suspended_client_tooland persists its pending entries to its ownSessionState.pendingClientToolCalls. - The parent's run loop observes that a child is incomplete and yields
RunOutcome.suspended_awaiting_childrenwith aSuspendedChildWait[]payload listing each pending child. - The parent state writes
suspendedAwaitingChildrento itssuspensionContextdurably. - The root-session
clientToolCallOwnershipmap records thattoolCallIdis owned by the child's session — the framework persists this mapping atomically with the child's pending-entry write.
When the client submits a result, it always submits against the root sessionId (not the sub-agent's). The framework looks up clientToolCallOwnership[toolCallId] to find the owning sub-agent session, writes the response into that child's pendingClientToolCalls, and the next continue resume of the root cascades down. On runtime-js (CP-10) it works like this (on the CF Durable Object a sub-agent always runs in its own DO, a remote child, so this in-process cascade does not apply there; CF Workflows re-spawns its children):
- The batch gate covers the tree. While an awaited in-process child's batch is still partial, the root's resume is refused before anything is written, as for the root's own calls: a retryable
ResumeRefusedError(state_not_resumable) whoseawaitingToolCallIdsnames the child's unanswered calls. - The parent's run starts first. After its own run-start commit, the parent resumes each awaited in-process (ephemeral) child through the child's own entry, all in parallel. Each child drains its answers and runs on. The child's entry carries what the in-loop dispatch gives a child: the parent's abort signal (cancelling or stopping the parent reaches the child's awaited work, with the stop's reason), a child-scoped usage context from the parent's (the child's usage records under its own session), and, for a child declared
inheritWorkspace: true, the parent's workspace. - Results land in call order. Each sub-agent call is paired with its child's result in the parent's
toolCallsorder, never in the order the children finish. The parent's next model call then sees every call paired. - A child that suspends again re-suspends the parent, with no model call. The children that did finish are paired, and the next submit-and-resume continues the rest. A stop of the parent recorded or signalled while its children resumed ends the parent by its stop instead (the awaited call paired
run_stopped, the stop cascaded to the still-paused child). - A child stopped on its own (an interrupt aimed at the child while the parent waits on it) is paired with a failed sub-agent result, as the in-loop dispatch pairs it; a child another entry took over is driven elsewhere and stays awaited (the parent re-suspends).
- A child that cannot start is classified by core
classifyEntryFailure, never by the error's text:- A definite failure (a refused or fatal entry) is paired with a typed failed result:
helixToolFailed: true, with the child'sErrorDetailunderhelixErrorDetail. The parent continues. - A transient failure ends the parent
failedwith the child's retryable detail, and nothing is paired. - A lost entry (a live run is driving the child) leaves the child awaited, and the parent re-suspends with no model call. A child left
activeby a crashed owner is taken over by the first resume after its run lease lapses (runLease.ttlMs, 30 s by default), so a re-suspension never repeats past that bound.
- A definite failure (a refused or fatal entry) is paired with a typed failed result:
A child that completes or fails inside its own run is paired with its committed outcome. A with_message resume over a suspension that also awaits children does not cascade (FU-WITH-MESSAGE-MIXED-CHILDREN-ORDER).
This means consumers never need to know about sub-agent sessions when submitting — they always submit against the root. The cascade is internal to the runtime.
Persistent sub-agents on Cloudflare Durable Objects
As of v7.0 (commit fb3180f6b), the Cloudflare DO runtime supports persistent sub-agents via DO-stub dispatch. Each persistent child runs in its own DO instance addressed by stable sessionId ({parent}-agent-{name}); the parent's auto-injected companion__* tools (five always, plus companion__waitForResult when a child is mode: 'blocking') translate into subAgentNamespace stub calls against the existing sub-agent endpoints. Ephemeral sub-agents (createSubAgentTool) work fully on every runtime that supports HITL. On the DO they need subAgentNamespace too (each child runs in its own DO): an agent with sub-agent tools and no namespace is refused typed (framework_not_supported, CP-144).
How Sub-Agent Execution Works
When the parent LLM calls a sub-agent tool:
- Tool Call Detection - Framework identifies the tool as a sub-agent (prefixed with
subagent__) - Sub-Agent Initialization - New agent run created with:
- Its own session and run. The child's entry is a run-start commit on the child session, the same gate a root entry uses (see Distributed coordination): it creates the child's run record (
getCurrentRun(childSessionId)/listRuns) together with the child's input row. When two entries race for one child (a retried activity or step, a concurrent consult), exactly one commits and the loser writes nothing. - Same
streamIdas parent (for unified streaming) - Input converted to user message
- Its own session and run. The child's entry is a run-start commit on the child session, the same gate a root entry uses (see Distributed coordination): it creates the child's run record (
- Execution - Sub-agent runs its full loop until completion
- Result Return - Sub-agent's
outputbecomes the tool result
// Parent LLM calls:
{
name: 'subagent__text-analyzer',
arguments: { text: 'This product is amazing!' }
}
// Framework:
// 1. Creates sub-agent run
// 2. Converts input to message: "This product is amazing!"
// 3. Runs AnalyzerAgent loop
// 4. Returns output: { sentiment: 'positive', confidence: 0.95, topics: ['product'] }Child identity
A sub-agent's session id is minted, never derived from its parent. Every child-starting call (a subagent__ tool, a remote sub-agent tool, or an adapter-supplied sub-agent call) gets two opaque ids:
- a dispatch id,
d_<uuid>, minted as the parent step's assistant row is built and persisted on the call (ToolCallRequest.dispatchId). It names the delegation, and keys a remote child's/start(its request key); - a child session id,
child_<uuid>. Nothing parses either id.
Read a child's id; never rebuild it:
- the
subagent_startchunk'ssubSessionId(andcallId, the parent's tool call); - the parent's refs,
stateStore.getSubSessionRefs(parentSessionId): each ref carriessubSessionId,parentToolCallIdanddispatchId; - the
afterSubAgenthook payload'ssubSessionId.
The binding. A child session is created with its delegation's binding (ChildBinding: parentSessionId, parentRunId, parentToolCallId, dispatchId, kind: 'subagent' or 'remote'), written once. Only its parent enters a child: every run-start on a bound child must carry a binding for the same delegation, else the store refuses it with RunStartRejectedError (child_binding_mismatch), writing nothing, and a session id already bound to another delegation (or a root session) cannot be created as a child (ChildSessionTakenError, state_child_session_taken). The one exception is a runtime's own continuation of the child's existing work (a submit's continuation, a recovery reset): it adopts the stored binding, pinned to the run it continues. Use isChildSession(state) / readChildBinding(state) to tell a child from a root.
Remote children. The protocol's /start and /resume carry the binding; the producer stores it on the child session and refuses an entry for another delegation. Client and server must deploy together.
Per runtime. runtime-js and the CF DO mint with crypto.randomUUID(); CF Workflows inside the register step (journaled); Temporal with wf.uuid4() in workflow code and in the runLLMStep activity; DBOS with DBOS.randomUUID() (checkpointed). A replay or recovery reads the same ids back, so it re-enters the same child.
Companions (persistent sub-agents) keep their name-keyed ids ({parentSessionId}-agent-{name}) and no binding until the companion half of this design.
The CF DO and inheritWorkspace
The CF DO runs every sub-agent in a sibling Durable Object (core toRemoteSubAgentTool), which cannot share the parent's in-process workspace. A sub-agent tool with inheritWorkspace: true is refused when the agent is materialized, before any run or model call, with a typed framework_not_supported. Every other sub-agent option is carried to the remote tool.
Input Mapping
The input schema defines what arguments the parent provides. These are converted to a user message for the sub-agent:
const subAgentTool = createSubAgentTool(
SubAgent,
z.object({
query: z.string(), // Common field names are recognized
context: z.string().optional(),
})
);
// When called with { query: "analyze this", context: "..." }
// Sub-agent receives user message: '{"query":"analyze this","context":"..."}'The full tool input is JSON-serialized and sent as the user message. The remote agent server is responsible for parsing the JSON and constructing the appropriate context for its agent.
Streaming Integration
Sub-agent events stream alongside parent events on the same stream:
for await (const chunk of stream) {
switch (chunk.type) {
case 'text_delta':
// Could be from parent or sub-agent
console.log(`[${chunk.agentType}]`, chunk.delta);
break;
case 'subagent_start':
console.log(`Starting sub-agent: ${chunk.subAgentType}`);
break;
case 'subagent_end':
console.log(`Sub-agent ${chunk.subAgentType} result:`, chunk.result);
break;
case 'tool_start':
// Includes sub-agent tool calls from within sub-agents
console.log(`[${chunk.agentType}] Tool: ${chunk.toolName}`);
break;
}
}Sub-agent streaming events:
tool_startfor the parent'ssubagent__<name>tool callsubagent_startwhen the sub-agent begins- All proxied sub-agent chunks (
text_delta,tool_start/tool_endfor inner tools, etc.) subagent_endwhen the sub-agent completestool_endfor the parent'ssubagent__<name>tool call
The tool_start/tool_end pair on the parent is what closes the parent's dynamic-tool UI part for AI SDK consumers (transitions 'input-available' → 'output-available'). See Sub-agent chunk ordering for the full semantics, including failure-path behavior and the subagent_end → tool_end ordering invariant.
This enables real-time visibility into nested execution.
A child that writes into its parent's stream never changes that stream's liveness: it does not claim it, reopen it, end it or fail it. Liveness belongs to the parent's run.
A child can outlive its parent's turn: a non-blocking companion is still running when the parent's turn ends (the stream is ended or failed) or suspends on a client tool or approval (the stream is paused). A companion's lifetime is its session's, not its parent run's: only an explicit Stop of a run that is still live (running, or suspended mid-turn) stops it with the run. A parent run that fails, or a Stop of a session with no live run, leaves it running (CP-199 on CF Workflows). An ephemeral sub-agent child has no next turn to deliver to, so a parent run that fails stops it, on runtime-js, the CF Durable Object and CF Workflows (CP-201). Then, on every runtime and stream manager:
- Its live chunks to that stream are dropped, and the child keeps running. Before, the write threw and failed the child (on Temporal it was swallowed). A paused stream drops them too, because readers treat it as terminal: its last chunk is
run_paused, so nothing may land after it. - Nothing is lost except the live view. The child's work is committed in its own session. The parent learns the outcome through the completion notifier on its next turn. A reconnect cannot replay the frames that were dropped while the parent was suspended.
- Its chunks land again once the parent's next run makes the stream live. A companion from an earlier turn then renders in the new turn's stream, like one spawned in that turn.
- Each drop is logged at debug (
stream.guest_chunk_dropped, rate-limited per stream, child session and status).
The stream manager decides this inside its own write (core decideStreamWrite). A chunk counts as the stream's own when its agentId is the stream's session or it carries the stream's owner run, and a chunk of another session with no runId is never dropped. So the parent's own writes to a non-active stream still throw: only a child's chunks are dropped.
State Isolation
Sub-agents have completely isolated state:
const ParentAgent = defineAgent({
name: 'parent',
stateSchema: z.object({
parentCounter: z.number().default(0),
}),
// ...
});
const SubAgent = defineAgent({
name: 'child',
stateSchema: z.object({
childCounter: z.number().default(0), // Separate from parent
}),
// ...
});Key points:
- Sub-agent cannot read parent's custom state
- Parent cannot read sub-agent's custom state
- Each has its own
messages,stepCount, etc. - Sub-agent output is the only communication channel
To share data, pass it through the input and receive it in the output.
Error Handling
Sub-Agent Failures
If a sub-agent fails, the error becomes the tool result:
// Sub-agent throws error
throw new Error('Analysis failed: text too short');
// Parent receives tool result:
{
success: false,
error: 'Analysis failed: text too short'
}The parent LLM sees the error and can decide how to proceed (retry, try different approach, etc.).
DBOS reports some failed sub-agents as successes (DI-19)
On DBOS, an ephemeral sub-agent that ends failed without throwing (for example after forced-completion exhaustion) is reported to the parent as a success with undefined output: from source, the parent's tool result and subagent_end say the delegation succeeded (inferred, not reproduced by a test). Only a thrown child error becomes a failed tool result there. See DI-19 in the conformance findings.
An outputSchema child whose model has no completion strategy
A sub-agent or persistent companion with an outputSchema is checked with its own config and adapter when it starts (the same early refusal as a root run). On runtime-js a refused child never gets a session: the parent sees a failed delegation (a tool error naming framework_completion_strategy_unavailable or framework_completion_schema_unsupported). Cloudflare Workflows refuses inside the child's own instance and the Cloudflare DO's child /start answers 400, so the parent sees the child fail. Temporal refuses in the child workflow's own initializeAgentState activity, so the parent sees the child fail; a refused companion re-consult reports delivered: false with the refusal's code and errorDetail. A subagent_start chunk may precede the refusal on runtime-js. DBOS does not check children yet.
Handling Errors
Check for failures in parent's tools or logic:
const processResultTool = defineTool({
name: 'process_analysis',
execute: async (input, context) => {
// The sub-agent result may have succeeded or failed
if (!input.analysisResult.success) {
// Handle sub-agent failure
return {
processed: false,
reason: input.analysisResult.error,
};
}
// Process successful result
const analysis = input.analysisResult.result;
// ...
},
});Nested Sub-Agents
Sub-agents can themselves have sub-agents:
// Level 3: Leaf agent
const SentimentAnalyzer = defineAgent({
name: 'sentiment',
outputSchema: z.object({ sentiment: z.string() }),
// ...
});
// Level 2: Uses sentiment analyzer
const TextProcessor = defineAgent({
name: 'processor',
tools: [createSubAgentTool(SentimentAnalyzer /* ... */)],
outputSchema: z.object({ processed: z.string() }),
// ...
});
// Level 1: Uses text processor
const Orchestrator = defineAgent({
name: 'orchestrator',
tools: [createSubAgentTool(TextProcessor /* ... */)],
// ...
});Stream events include all levels:
[orchestrator] text_delta: "Let me analyze..."
[orchestrator] subagent_start: processor
[processor] text_delta: "Processing..."
[processor] subagent_start: sentiment
[sentiment] text_delta: "Analyzing..."
[sentiment] output: { sentiment: "positive" }
[processor] subagent_end: sentiment
[processor] output: { processed: "..." }
[orchestrator] subagent_end: processor
[orchestrator] text_delta: "Based on the analysis..."Patterns
Specialist Pattern
Delegate specific tasks to specialists:
const ResearchAgent = defineAgent({
name: 'researcher',
tools: [
searchTool,
createSubAgentTool(FactCheckerAgent /* ... */),
createSubAgentTool(SummarizerAgent /* ... */),
],
systemPrompt: `You are a research coordinator.
1. Search for information
2. Send claims to the fact-checker
3. Send findings to the summarizer
4. Compile final report`,
});Pipeline Pattern
Chain agents in a processing pipeline:
// Each agent processes and passes to next
const ExtractorAgent = defineAgent({
name: 'extractor',
outputSchema: z.object({ entities: z.array(z.string()) }),
});
const EnricherAgent = defineAgent({
name: 'enricher',
outputSchema: z.object({
enrichedEntities: z.array(
z.object({
/* ... */
})
),
}),
});
const FormatterAgent = defineAgent({
name: 'formatter',
outputSchema: z.object({ formatted: z.string() }),
});
// Coordinator runs the pipeline
const PipelineAgent = defineAgent({
name: 'pipeline',
tools: [
createSubAgentTool(ExtractorAgent, z.object({ text: z.string() })),
createSubAgentTool(EnricherAgent, z.object({ entities: z.array(z.string()) })),
createSubAgentTool(FormatterAgent, z.object({ data: z.unknown() })),
],
systemPrompt: `Process text through the pipeline:
1. Extract entities
2. Enrich each entity
3. Format the output`,
});Parallel Delegation Pattern
Delegate multiple tasks simultaneously:
const MultiAnalyzerAgent = defineAgent({
name: 'multi-analyzer',
tools: [
createSubAgentTool(SentimentAgent, z.object({ text: z.string() })),
createSubAgentTool(TopicAgent, z.object({ text: z.string() })),
createSubAgentTool(EntityAgent, z.object({ text: z.string() })),
],
systemPrompt: `Analyze text from multiple angles.
You can run multiple analyses in parallel.
Combine results into a comprehensive report.`,
});The framework executes parallel tool calls concurrently when the LLM requests them.
Folding parallel results into customState
A common pattern is to fold each child's result into the parent's customState from an afterSubAgent hook — for example, marking one entry of a notes/tasks array as done per child:
const Orchestrator = defineAgent({
name: 'orchestrator',
stateSchema: z.object({
notes: z.array(z.object({ id: z.number(), status: z.string() })).default(() => []),
}),
tools: [createSubAgentTool(ResearchAgent, z.object({ threadId: z.number() }))],
hooks: {
afterSubAgent: (payload, ctx) => {
const threadId = (payload.call.input as { threadId: number }).threadId;
ctx.updateState((draft) => {
draft.notes[threadId].status = 'covered';
});
},
},
});Composition guarantee. When many children are folded in parallel, their folds compose at element granularity — disjoint edits (notes[3] and notes[5], or two draft.results.push(...) appends) all survive. This holds on every runtime (JS, Cloudflare Workflows, Cloudflare Durable Objects, Temporal, DBOS): each runtime folds the settled wave into a single in-memory customState and persists the composed document once at the step boundary — there is no lock and no per-fold whole-document overwrite.
Caveats:
- Same-exact-path concurrent edits (two children writing the same scalar or the same array element) resolve last-writer-within-the-wave — the framework does not coordinate genuinely-conflicting writes to one datum. Give each child a disjoint slice (its own array element/key) and folds always compose.
- Additions, in-place mutations, and removals all compose. An
afterSubAgentfold that mutates, adds, or removes (draft.notes[i].status = …,draft.results.push(…),draft.queue.splice(…),delete draft.someKey) composes correctly with disjoint tool/sibling writes. The one residual edge: if a regular tool and the fold both restructure the same array in the same step (e.g. the tool appends while the fold splices the middle), the result is best-effort — give each writer a disjoint slice to stay exact. - Cloudflare Workflows timing:
afterSubAgentfires at wave settlement (batched), once per child with the correct payload, rather than staggered as each child completes. The live sub-agent activity overlay (subagent_end/tool_end) remains per-child/timely. - JS runtime + a step that also suspends: if the same step folds some completed children and suspends on another child (HITL / awaiting-children), the completed children's folds may not be durably persisted on the suspending turn — they land once the wave fully settles. (Other post-snapshot hook mutations behave the same way on the suspension path.) For fully-settled waves — the common case — the fold persists immediately at the step boundary.
Per-runtime hook caveats
The afterSubAgent customState fold persists and composes on every runtime. A few secondary hook behaviors still differ per runtime and are not part of the fold guarantee:
beforeSubAgentfires on the JS, Cloudflare Durable Object, Cloudflare Workflows, and DBOS runtimes. It is not fired on Temporal. Don't depend onbeforeSubAgentfor Temporal portability.onMessagefor a sub-agent's result message fires on JS / CF-DO / CF-Workflows / DBOS, and on Temporal's remote and respawn paths, but not on Temporal's ephemeral sub-agent path (afterSubAgentstill folds there).- DBOS
beforeSubAgent+afterSubAgentfolding the same top-level key in one wave resolve last-writer-wins (theafterSubAgentfold is staged from the committed base and doesn't see thebeforeSubAgentrow). On the JS runtime they compose. Disjoint keys compose everywhere. (beforeSubAgentfolds are uncommon — it's usually an observability fire.) - Temporal suspend-on-children → resume drain: children that were still suspended when the parent suspended, then settle on resume, are drained without re-firing
afterSubAgent(mirrors the JS suspension caveat above). The re-spawn path (parent-suspended children re-dispatched on resume) does fold.
Conditional Delegation Pattern
Delegate based on input characteristics:
const RouterAgent = defineAgent({
name: 'router',
tools: [
createSubAgentTool(SimpleQAAgent, z.object({ question: z.string() }), {
description: 'For simple factual questions',
}),
createSubAgentTool(ResearchAgent, z.object({ topic: z.string() }), {
description: 'For topics requiring deep research',
}),
createSubAgentTool(MathAgent, z.object({ problem: z.string() }), {
description: 'For mathematical calculations',
}),
],
systemPrompt: `Route questions to the appropriate specialist:
- Simple facts → SimpleQA
- Complex topics → Research
- Math problems → Math
Choose the best agent for each request.`,
});Remote Sub-Agents
For agents running on a separate HTTP service, use createRemoteSubAgentTool() instead of createSubAgentTool(). This enables cross-service and cross-runtime delegation via HTTP + SSE.
import {
defineAgent,
createRemoteSubAgentTool,
HttpRemoteAgentTransport,
} from '@helix-agents/core';
import { z } from 'zod';
const transport = new HttpRemoteAgentTransport({
url: 'http://localhost:4000',
});
const researcherTool = createRemoteSubAgentTool('researcher', {
description: 'Delegate research to a remote specialist agent',
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({
findings: z.array(z.object({ title: z.string(), snippet: z.string() })),
}),
transport,
remoteAgentType: 'researcher',
timeoutMs: 120_000,
});
const OrchestratorAgent = defineAgent({
name: 'orchestrator',
tools: [researcherTool], // Works like any other tool
// ...
});Remote sub-agents stream events using the same subagent_start/subagent_end protocol as local sub-agents, so frontends don't need to distinguish between them.
Client-executed and approval-gated tools inside a remote sub-agent
On runtime-js, the CF Durable Object and CF Workflows, a remote sub-agent that pauses on a client-executed or approval-gated call the root can answer (the producer lists it in its /statuspendingCalls, untruncated, and the transport implements submitToolResult) parks the parent: the call renders and is answered in the root UI, and the parent continues with the child's real result (CP-157; the cross-service guide). On Temporal and DBOS, or when the root cannot answer it, the parent fails fast with RemoteSubAgentClientToolUnsupportedError (dispatch failureReason: 'client-tool-unsupported') or CP-45's typed drop. GitLab #107.
For the full guide — including server setup, transport configuration, and production considerations — see Remote Agents.
Persistent Sub-Agents
Overview
Persistent sub-agents are long-lived child agents that maintain state across multiple interactions. Unlike ephemeral sub-agents (created with createSubAgentTool()), persistent children can receive follow-up messages and be managed throughout the parent's lifecycle.
Configure persistent sub-agents via the persistentAgents field on AgentConfig:
import { defineAgent } from '@helix-agents/core';
import { z } from 'zod';
const ResearcherAgent = defineAgent({
name: 'researcher',
systemPrompt: 'You research topics.',
outputSchema: z.object({ findings: z.string() }),
llmConfig: { model: openai('gpt-6-luna') },
});
const OrchestratorAgent = defineAgent({
name: 'orchestrator',
systemPrompt: 'You coordinate research tasks using your persistent children.',
outputSchema: z.object({ summary: z.string() }),
persistentAgents: [{ agent: ResearcherAgent, mode: 'blocking' }],
llmConfig: { model: openai('gpt-6.1-sol') },
});Two Modes
Blocking (mode: 'blocking'): Parent waits for the child to complete before continuing. Use when you need the child's result before making further decisions.
Non-blocking (mode: 'non-blocking'): Parent continues immediately after spawning. Child runs concurrently and the parent receives a completion notification later. Use for fire-and-forget background tasks.
persistentAgents: [
{ agent: ResearcherAgent, mode: 'blocking' }, // Parent waits
{ agent: BackgroundWorker, mode: 'non-blocking' }, // Fire-and-forget
],Note: Each agent type can only appear once in
persistentAgents.defineAgent()will throw if the same agent type appears multiple times. To use the same agent logic in both modes, create two separate agent definitions with distinct names.
Companion Tools
When persistentAgents is configured, companion tools are auto-injected into the parent agent (prefixed with companion__). Five are always injected; companion__waitForResult is added only when at least one persistent child is configured mode: 'blocking'. The parent's LLM decides when and how to call them based on the system prompt and conversation context — you never wire them by hand.
| Tool | Injected when | Description |
|---|---|---|
companion__spawnAgent | always | Create and start a new persistent child. |
companion__sendMessage | always | Send a follow-up message to an active child. On all five runtimes a completed child is continued on its preserved session (memory retained) instead of erroring. |
companion__listChildren | always | List all persistent children and their current statuses. |
companion__getChildStatus | always | Get detailed status (and last output) of one child by name. |
companion__terminateChild | always | Terminate a running child. |
companion__waitForResult | only if a child blocks | Block until a child completes and return its result, or a typed timeout answer when its timeout (ms) passes first. |
On runtime-js and the CF Durable Object, a companion call runs only after the step that made it is committed (CP-106, upgrade guide). A step a stop refuses leaves no child and no ref, and a stop that lands while the call runs stops the child it started; the call's result is then the stop's run_stopped row. Calls of one step start one at a time, in the order the model made them.
Tool reference (arguments → result)
The argument and result schemas below are the exact Zod shapes the framework injects (packages/core/src/tools/companion/*.ts). Identical on every runtime.
companion__spawnAgent — start a new persistent child.
Args:
{ agent: string, initialMessage: string, name?: string }—agentis one of the parent's configuredpersistentAgentstypes (the LLM sees it as an enum).nameis optional; omit it for auto-naming. When spawning multiple children of the same type in a single step, pass an explicit uniquenamefor each — concurrent auto-naming can collide.Result: core
CompanionSpawnResult(one schema on runtime-js, the CF Durable Object and CF Workflows, for every spawn — a first spawn, a respawn and a continue;parseCompanionSpawnResultis the one parse at a runtime boundary). For a non-blocking child the answer is the child's ENTRY outcome: the call returns once the child's run-start has committed,{ name, status: 'spawned', delivered: true }, else{ name, status: 'not_spawned', delivered: false, reason, errorDetail? }— a successful tool result; the model readsreasonand may spawn again.spawnedmeans the child's run-start committed (CP-161): on runtime-js and CF Workflows a first spawn and a respawn used to answerspawnedbefore the child's start, so a start that then failed surfaced only later as an untypedSub-agent '<name>' failednotice. They now wait for it, which costs about 4–6 store / stream round-trips before the answer (runtime-js: the completion check,createSession, the run-start commit's reads and write,run_started; CF Workflows: the entry poll's probes, from 100 ms); the child's run itself is not waited for. A blocking spawn whose child does not start is answerednot_spawnedtoo, before any wait. The seven reasons (COMPANION_NOT_SPAWNED_REASONS) are the four ENTRY reasons below plus three refusals made before any entry (CP-40 (C2)):child_not_supported(the CF Durable Object refuses a companion that can call a client-executed or approval-gated tool; see A companion paused on client calls),child_interrupted(thenameis held by an interrupted child: resume it withcompanion__sendMessage) andchild_awaiting_client(thenameis held by a child paused on calls; itserrorDetailnames them). Anameheld by a running child ischild_busy. The four entry reasons:child_busy(the child's run start lost to another entry: a continue whose reopen another consult won, a child DO executing a run),child_start_unconfirmed(no entry outcome within the runtime's bound — on the CF DO the 60 s/startbound — or a refusal the child sent after its run-start commit; the start is re-probed, never respawned),child_entry_failed(the child's entry failed before its run-start commit;errorDetailsays how) andchild_terminated(the parent terminated the child first). A first spawn's definite non-start leaves the name re-spawnable (its ref readsfailedwith the detail, and on runtime-js the never-run child session readsfailedwith it too, as does a respawn's fresh one); a first spawn answeredchild_busywrites no ref (another entry of the child won, so the ref is that entry's: two parallel same-name spawns in one step share it, CP-161), and neither does one the parent's own stop cancelled; the winner's run always carries its owninitialMessage. The parent's ref is the claim of the name: when two spawns of one name race (in one step, or across two runs of the parent), the one whose ref write lost answersnot_spawned/child_busybefore it touches the child at all, and a respawn's reset of a finished child lets exactly one respawn through (CP-161 fix 2). Every write a child's outcome drives is tied to the generation of the ref it serves, so an older child (or a late end of a continued one) never overwrites a newer one's (CP-161 fix 3). A failed continue never wipes the completed child. On runtime-js a spawn re-executed with the same tool-call id while the child's run for that call is still live attaches to it (spawned) instead of starting a second one. A blocking attach then waits for that run's committed word, bounded by the run's own lease: while its owner keeps renewing the lease (every TTL / 3, 30 s TTL by default) it waits; once the lease has not moved for a whole TTL with no takeover, the owner is gone and the spawn answers{ name, status: 'failed' }with the ref writtenfailed, delivered — exactly whatcompanion__waitForResult's orphan detection answers — so the name can be re-spawned. A non-blocking attach answersspawnedand its ref staysrunninguntil the run is superseded orcompanion__waitForResult's orphan detection settles it. For a blocking child, the spawn waits for the child to reach a terminal status and answers its committed word:{ name, status: 'completed', output? },{ name, status: 'failed', errorDetail? },terminated,interrupted,suspended(the child is paused on client / approval calls:{ name, status: 'suspended', pendingCalls, message }, below) orunreachable(below). Aninterruptedanswer carriesmessage, the resume guidance (use companion__sendMessage to continue it; coreinterruptedCompanionGuidance, every runtime that parks through core). On the CF Durable Object a child that was interrupted on its own is answeredinterruptedafter three consecutive observations (the same debounce ascompanion__waitForResult), and a blocking spawn whose PARENT is stopped answers{ name, status: 'parent_stopped', kind }(kind:interruptorabort). That answer is never committed (the parent's step ends by its stop) and writes no ref: the child's outcome is unknown, so the ref staysrunninguntil the child's own committed word reaches it. The stop is owed on the ref and retried until the child confirms it, withinowedStopSchedule.windowMs(default 24 h), then abandoned with the ref left live (the DO's stop). Every arm may carrypriorResult, an earlier result of the same child that the model had not seen yet (see A re-consult never discards an unseen result). Temporal's model sees core's spawn handoff (FU-COMPANION-SPAWN-HANDOFF-TYPED) and DBOS answers{ name, status: 'spawned' }, both before the child's run-start commit (FU-SPAWN-CONTINUE-ENTRY-OUTCOME-PARITY). On CF Workflows the blocking park (a first spawn, a respawn or a continuation) answers the same typed arms as runtime-js and the CF DO (completed,failedwitherrorDetail,interruptedwith its guidance), a successful tool result; it used to answer an error row for a failed or interrupted child (CP-161). The parent's own stop also ends the entry wait before the park, at its next probe. The wait is bounded bysubAgentEventTimeout: a blocking spawn (or continuation) still waiting at that bound answers the errorTimeout waiting for child agentand leaves the child running and undelivered, so its later completion reaches the parent through the per-turn completion notifier. An ephemeral sub-agent still running at that bound is stopped, and its true outcome is recorded (failedwithframework_subagent_wait_boundwhen the stop ended it); one that has not started yet has its workflow instance terminated. When the stop is not confirmed, the child's ref staysrunningand is reconciled to the child's true terminal status later by the CF Workflows readers (a handle's state load, every workflow step's state load). Each wait ends exactly at its bound (a replay of the workflow never restarts it) and keeps listening for the child's completion until then. A blocking companion park also listens for the parent's own stop, so each 30 s slice of the wait costs the instance four workflow steps. Under Cloudflare's per-instance step limit that bounds the longest supported park at about 2 500 slices: ~20.8 h with a 10 000-step limit, ~2.1 h with a 1 024-step limit. The defaultsubAgentEventTimeout(60 s) is far below either; raise it only within that bound. On the CF Durable Object a blocking spawn whose child it cannot read at all forcompanionPollFailureWindowMs(30 s by default; every verified read of the child DO's/statusfailed) answers a successful tool result instead of an error (CP-125, D1):ts{ name, status: 'unreachable', message } // message (core companionUnreachableMessage): // "Child agent '<name>' could not be reached for <windowMs>ms. Its outcome is unknown: // it may still be running, and its result will be delivered when it reaches this // agent. Call companion__waitForResult again, or companion__terminateChild to stop it."Nothing is written and nothing is stopped: the child's ref stays
running, so the name is not re-spawnable until the child's own outcome arrives (its notice records it, and the completion notifier delivers it on a later turn) or the model callscompanion__terminateChild. It used to record the childfailedand abort it, which left a live child running with its result never delivered.Errors (
{ error }tool result, not a thrown exception): unknown agent type (Unknown persistent agent type), or a spawn failure (the orphaned ref is compensated tofailedso the name stays re-spawnable). Anamealready in use by a live child is no longer an error: it is the typednot_spawnedanswer above (child_busy,child_interrupted,child_awaiting_client; CP-40 (C2)), on every runtime except DBOS, which still answers thealready running/is interruptederror (FU-DBOS-COMPANION-TYPED-NAME-REFUSAL).
companion__sendMessage — queue a follow-up message for a running child.
Args:
{ name: string, message: string }.Result: core
CompanionDeliveryOutcome—{ delivered: true }or{ delivered: false, reason, errorDetail? }, either with an optionalpriorResult(an earlier, not-yet-delivered result of acompletedchild this message continues; see A re-consult never discards an unseen result). Delivery is reported truthfully:delivered: falsealways carries areason, is a successful tool result (not an error), and means no run will process the message; the model may send again. The eight reasons (COMPANION_NOT_DELIVERED_REASONS):child_busy: the child's run-start commit for this message lost to another run (for example a concurrent consult continuing the same child, or a directresume()of the child), or the child's live run has a recorded stop (WA MR2 §7.4: it answered a stop with 202stopping, or a stop is recorded on its suspended run). A stopping child is ending, so the message is not sent to it; once it endsinterrupted, a latersendMessageresumes it. Checked before the "unresolved client-tool calls" error, which a stopping child's stop makes moot. On runtime-js, CF Workflows and Temporal core'shandleSendMessagereads the child's stop record (and guards the append itself); the CF Durable Object reads the child DO's/status(stopping: true); DBOS's own inbox delivery reads the child's stop record in a recorded step before it sends anything (Ruling 59).child_terminated: the parent terminated the child before this resume's run-start commit (the store refused itresumed_companion_ref_terminated); re-sending cannot help.child_start_unconfirmed: no run-start outcome arrived within the runtime's bound, so whether the child took the message is unknown (checkgetChildStatusbefore sending again).child_entry_failed: the child's entry failed before its run-start commit;errorDetailsays how (for example a refused completion strategy, orresumed_companion_ref_missing).child_not_running: the child has no live run and is not interrupted, so no loop could consume the message.child_exited: the child's session is already terminal (its loop exited between the parent reading its status and the send), or its live run ended or was superseded before the message was appended. Nothing is written. It is also the answer in a re-consult's window (CP-155): between the continue's run-start commit and the child session's reopen (or after a crash there) the session still reads the previous round'scompleted; the message is not delivered and may be sent again.no_resume_config(runtime-js): the interrupted child's agent type is not among the parent'spersistentAgents.child_awaiting_client(CP-40 (C2)): the child is paused on client-executed or approval-gated calls; nothing is appended.errorDetail(corecompanionAwaitingClientDetail) names the awaited calls and what to do: answer them where the child's client can, orcompanion__terminateChild. A child whose calls were all answered meanwhile (it may be running again) ischild_busyinstead, never told to be terminated.
Per runtime:
- CF Workflows (W1 MR2b): a resume of an interrupted child and a detached continue of a completed one report
delivered: trueonly once the child's run exists: the parent polls the child's exact run id in its own workflow body, backing off 100 ms → 5 s, bounded bycompanionEntryOutcomeTimeoutMs(aWorkflowOptionsfield, default 60 s; about 34 engine steps per send at worst, 2–6 for a normal delivery). A child instance that closed with no run ischild_busywhen its entry lost the run-start commit, elsechild_entry_failedwith itserrorDetail; no outcome within the bound ischild_start_unconfirmed. - CF Durable Object (with
subAgentNamespace; the child is a sibling DO): the child's/resumeanswer decides. A 409ALREADY_RUNNING(the child DO is executing a run) ischild_busy; no answer within 60 s, a rejected fetch or an unreadable body ischild_start_unconfirmed; any other refusal ischild_entry_failedwith itserrorDetail(W1 MR2b; it used to be a tool error); the I1 race ischild_exited. A 2xx that is not aRemoteStartResponseis still a typed tool error (RemoteProtocolValidationError). The/resumecarries the call's id as its consult, which the parent records on the ref before it sends; the ref turnsrunning(with the child's run) only after a delivered/resume. Otherwise the ref's status is left as it was, and its previous consult is restored, except afterchild_start_unconfirmed, which keeps the new consult unconfirmed. See the CF DO companion delivery contract and the upgrade guide. - DBOS waits for the continuation's run-start outcome (60 s);
child_entry_failedcarries the entry'serrorDetail. - runtime-js maps
resumeChildAgent's typed answer exhaustively (coreparseCompanionSendMessageResult; a value outside the union is a non-retryableframework_internal_error, never passed to the model). - Temporal still reports
delivered: trueonce a continuation is started, before the child's run-start commit, and answerschild_start_failed(outside the core union) when the continuation's child workflow did not start (FU-COMPANION-BUSY-DELIVERED-TEMPORAL-CFW).
Errors: unknown child (
No child agent found). A child paused on unresolved client-tool calls answers a successful, not-delivered result with reasonchild_awaiting_client(CP-40 (C2), above) on runtime-js, Temporal, CF Workflows and the CF Durable Object (whose calls come from the child's/statuslisting); DBOS still answers theunresolved client-tool callserror. For a child in a terminal status (is not active): acompletedchild no longer errors on any of the five runtimes — it is continued on its preserved session (memory retained, fresh output). Afailedorterminatedchild still errors (usespawnAgentto re-spawn). Sending to aninterruptedchild resumes it on its preserved session with its complete history plus the new message (see Interrupted companions).Resuming an
interruptedchild: the parent's ref turnsrunning(withcompletionDelivered: false, so the resumed run's new outcome is delivered) only when the resumed child's run starts, never before (CP-103). On runtime-js, Temporal and CF Workflows the ref is written atomically inside the resumed child's run-start commit (StartRun.resumedCompanionRef, CP-103 A1): a committed resume always carries itsrunningref, and a refused or lost start writes none. A resume that cannot start leaves the ref as it was:interruptedon runtime-js and CF Workflows, socompanion__waitForResultstill reports it resumable; on Temporal, which never recordsinterruptedon a ref, its prior status (the stalerunningof CP-73, whichwaitForResultstill reportsinterruptedthrough its session check). A resume lost to another entry reports{ delivered: false, reason: 'child_busy' }. A resume refused because the child's model cannot finish itsoutputSchemais a tool error carrying the refusal. A parent ref that has disappeared fails the child's run-start typed on every store (resumed_companion_ref_missing, nothing written); on runtime-js that is a typed tool error, neverchild_busy. A child the parent terminated before the resume's run start is refused the same way (resumed_companion_ref_terminated, the ref staysterminated); runtime-js, CF Workflows and DBOS (a continuation of a completed child whose run-start lost its claim) report{ delivered: false, reason: 'child_terminated' }, so the model does not resend to a terminated child. On Temporal and CF Workflows, a refusal is caught in the parent (Temporal's companion activity, CF Workflows' companion step) before any child starts. On CF Workflows a resume that loses its run-start reportschild_busy(a resume that met the child's stop,expected_run_stopped, included), one whose parent ref is missing reportschild_entry_failed(witherrorDetail.cause: 'resumed_companion_ref_missing'), and one refused because the parent terminated the child first (resumed_companion_ref_terminated) reportschild_terminated(WA MR2 §7.4); the resume writes no ref and the ref stays as it was (CP-105, W1 MR2b). Temporal still reportsdelivered: truethere (the resume writes no ref, so its prior status, the stalerunningof CP-73, stays; the child workflow endsfailed/state_run_start_conflictwithdetails.cause;FU-COMPANION-BUSY-DELIVERED-TEMPORAL-CFW). The CF Durable Object's companions always run in their own DOs (subAgentNamespace; without it, persistent agents are refused). The parent's ref and the child's run are in different Durable Objects, so the DO's own tools call the child DO's/resumefirst and write the ref only after it succeeds. That is not atomic: a crash between the two leaves the refinterrupted. DBOS writes the ref only after a delivered send.Rolling deploy. A child worker that predates A1 does not send the field. While that child runs, the resume writes no ref (it keeps its prior status: the stale
runningof CP-73 on Temporal), and a ref withcompletionDelivered: truekeeps it, so the resumed outcome is not delivered. Roll every Temporal worker that polls the parent and child task queues to the new core and runtime-temporal together. On CF Workflows one deployment replaces parent and child together; only a resume instance created before the deploy runs without the field. The CF Workflows instance param was renamedcompanionResume→companionRef(W1 MR2b), so a resume instance created before that deploy whose run-start had not run yet writes no ref either.
companion__listChildren — {} → Array<{ name, agent, status }> (persistent children only; ephemeral sub-agent refs are filtered out). Stale live refs (running, and interrupted, e.g. one whose resume answer was lost while the resumed run went on to finish) whose underlying child has actually finished are lazily synced before returning, here and in getChildStatus, on every runtime (the CF Durable Object read only a running ref until GL #174). "Finished" is read from the child's current RUN (CP-155): while that run is live and its own terminal commit has not landed, the ref stays running whatever the child session says (a re-consult's new run whose session has not been reopened yet still reads the previous round's completed). A store error while reading a child's session or writing its synced ref (here, in getChildStatus and in waitForResult) fails the call as a typed tool error, never a guessed status; the sync write is idempotent, so the model can retry the call. A child whose session no longer exists is skipped.
companion__getChildStatus — { name: string } → { name, agent, status, lastOutput? }. lastOutput is the child's output once it has completed. Unknown name → { error: 'No child agent found...' }.
companion__waitForResult — block until a named child is terminal.
Args:
{ name: string, timeout?: number }(corewaitForResultArgsSchema()).timeoutis in milliseconds and must be a positive whole number:0, negatives and fractions are rejected as invalid arguments on every runtime. Omit it to wait until the child finishes (or the call is cancelled). On durable runtimes the wait is durable (Temporal activity /DBOS.sleep) and survives crashes.Result: one of these shapes (core
companionWaitResultSchema(), discriminated onstatus):ts{ name, status: 'completed', result? } // result = the child's output { name, status: 'failed', result?, message? } { name, status: 'terminated', result? } { name, status: 'interrupted', message } // resume it with companion__sendMessage { name, status: 'suspended', pendingCalls, message } // paused on client / approval calls (CP-40 (C2)) { name, status: 'timeout', currentStatus } // the deadline passed first { name, status: 'unreachable', currentStatus, message } // CF Durable Object only: the child could not be readunreachable(CP-125, D1; corewaitForResultUnreachable) is answered on the CF Durable Object when every read of the child DO's/statusfailed forcompanionPollFailureWindowMs(30 s by default).currentStatusis the parent's ref status (stillrunning: nothing was written), andmessageis the same guidance as the blocking spawn's (companionUnreachableMessage): the outcome is unknown, the child may still be running, its result is delivered when it reaches the parent, and the model can callwaitForResultagain orterminateChild. The other runtimes share one store with the child and never answer it.On
timeout,currentStatusis the child's ref status read at the deadline (running,interruptedorpaused_awaiting_client, theSubSessionRefstatuses). The child keeps running, so the model can callwaitForResultagain. The wait makes one deadline decision per poll, after it reads the child, and clamps its sleep to the time left, so the timeout comes at the deadline, not up to a poll later (corewaitForResultTimeoutbuilds it on every runtime).An
interruptedchild returnsstatus: 'interrupted'with amessagetelling the model to resume it withcompanion__sendMessage, instead of waiting until the timeout (JS, Temporal, CF Workflows and CF Durable Objects). On CF Workflows a repeat wait on a child already reportedinterruptedalso returns after three polls instead of parking. The state is confirmed over three consecutive polls (about 2 s), so asendMessageresume issued in the same step is normally not misreported. A deadline that passes before the third poll answers the typedtimeout(withcurrentStatus: 'interrupted', orrunningfor a Temporal ref, CP-73): the confirmation never runs a wait past its deadline. A child with a live run is never reportedinterrupted, so the window is only until the resumed child's run-start commit. Known bug on Temporal (FU-TEMPORAL-WAITFORRESULT-RESUME-RACE): the three-poll check is a timing heuristic, so if that commit has not landed within those polls (the ref turnsrunningonly once the run starts), the wait can return a falseinterruptedand the model may send again. The fix is to wait on "run-start committed or refused" instead of counting polls.Per runtime:
- runtime-js, Temporal (core dispatcher), CF Durable Objects and DBOS: a wait with no
timeoutwaits until the child finishes. On the CF Durable Object it also ends atunreachable(above). It answers from the outcome the parent recorded for the child's consult, reading the child DO every 5 s until then; the child's notice wakes it sooner. - CF Workflows: the park waits at most
min(timeout, subAgentEventTimeout). On CF Workflows an untimed wait answers{ status: 'timeout', currentStatus }aftersubAgentEventTimeout, and the model should callwaitForResultagain. Every answer, includinginterruptedand afailedorterminatedoutcome that arrives while the wait is parked, is the typed object above, as a successful tool result (before, those reached the model as a tool error{ error }). Afailedanswer carries no error text, as on the other runtimes. A companion that suspends (a client tool or its own children) is still answeredfailedthere (FU-CFW-COMPANION-WAIT-SUSPENDED-CHILD-AS-FAILED), and a child whose instance closed without notifying answerstimeouton every repeat wait (FU-CFW-WAITFORRESULT-CLOSED-CHILD-TIMEOUT).
- runtime-js, Temporal (core dispatcher), CF Durable Objects and DBOS: a wait with no
Returning a terminal result here marks the child's ref
completionDeliveredso the per-turn completion notifier does not also re-deliver it (see below).
companion__terminateChild — { name: string } → { name, terminated: boolean, status }. Terminate-truth: terminating an already-terminal child returns terminated: false and preserves its terminal ref (it does not lie by reporting a kill or clobber a completed result to terminated). Terminating a live child records an abort stop on the child's current run (stopPersistentChild: the child ends failed with framework_cancelled), marks the ref terminated, and flags it completionDelivered so it isn't re-scanned. A resume racing the terminate is refused by the store (resumed_companion_ref_terminated).
Known companion gaps on Temporal, Cloudflare Workflows and DBOS
sendMessageto a running child (CP-41). Since WA MR2 the delivery is not a stop: core appends the message to the child's live run (anappendcommit) and records nothing else, and Temporal and Cloudflare Workflows load the history at every step, so the child takes it at its next step; DBOS delivers it to the child's inbox and the child takes it as its next turn. The CP-41 record stays open until its conformance cells are re-run on those runtimes.- Two
sendMessagecalls to one interrupted child in the same step (CP-72). On Temporal, both resume starts use the same child workflow id. If they overlap, the second start fails and is only logged, and its message can go unprocessed although it reporteddelivered: true. - An interrupted child's ref on Temporal (CP-73). Temporal never records the child's ref as
interruptedwhen the child ends; it keeps readingrunninguntil a consult reads it. Since CP-184 the companion read tools (listChildren,getChildStatus,waitForResult) fold a live ref over a child whose run endedinterruptedwith its own terminal commit tointerrupted(core's lazy sync, on every runtime that uses core's companion dispatch), so they reportinterruptedfrom that consult on. The untimedwaitForResulthang this used to cause is fixed: without a liveness probe, the wait also checks the child's own session (and that it has no live run), so it returnsinterruptedafter three polls instead of polling until the companion activity times out.
See the conformance findings.
sendMessage to a child that is still starting (CP-75)
On runtime-js, Temporal and Cloudflare Workflows (the runtimes that route sendMessage through the shared core dispatcher), a child's ref reads running before its session exists: right after a first spawn, right after a re-spawn of a failed or terminated child, and on Temporal and Cloudflare Workflows until the child workflow has created its session. A sendMessage in that window is not reported as "not started yet": every store's append throws, so the model gets a transient tool error (Session not found on memory, Postgres and Redis, State not found for sessionId on D1) for a child that is starting normally, and has to retry the send. Nothing is written to the child. (Before the RM-50 fix, Redis instead reported delivered: true and wrote the message into the not-yet-created session; that no longer happens.) The DBOS and Cloudflare Durable Object runtimes use their own sendMessage handlers and are not affected. See CP-75 in the conformance findings.
All companion tools validate their arguments at dispatch; a malformed call returns a clean
{ error }tool result rather than throwing. The validated constraints are:namemust be non-empty and ≤128 characters; the companion message (initialMessageonspawnAgent,messageonsendMessage) must be non-empty (an empty message is rejected); andtimeout(onwaitForResult, in milliseconds) must be a positive whole number.
Child Naming
Children can be named explicitly via the name argument in companion__spawnAgent, or auto-named using the pattern {agentType}-{counter} (e.g., researcher-1, researcher-2).
// Explicit naming
spawnAgent({
agent: 'researcher',
initialMessage: 'Research AI safety',
name: 'safety-researcher',
});
// Auto-naming (uses counter)
spawnAgent({ agent: 'researcher', initialMessage: 'Research quantum computing' });
// -> named 'researcher-1'
spawnAgent({ agent: 'researcher', initialMessage: 'Research fusion energy' });
// -> named 'researcher-2'Session IDs
Each persistent child gets a deterministic session ID: {parentSessionId}-agent-{name}. This enables:
- Stable references across parent restarts
- Predictable state store lookups
- Clean cleanup on parent completion
Re-spawning
If a child with the same name is spawned after a previous one reached a terminal status, behavior depends on the prior status and the runtime:
completed(all five runtimes): the spawn continues on the preserved session — the old session is not deleted, so the child's memory is retained — and theinitialMessageis appended as the next turn's input. TheSubSessionRefis reset torunningand itscompletionDeliveredflag is cleared so the new run's completion is delivered. On durable runtimes the continuation is replay-safe: Temporal does the store-side reopen inside an activity, as one version-pinned write, so exactly one concurrent consult wins (the other getschild_busy). That write records a pending-continuation marker in the child session'smetadataunder the key__helix.companionContinue, cleared when the continuation's run commits or the consult is abandoned. It is ordinary session metadata, solistSessionsmetadata filters see it; do not use that key for your own metadata. DBOS starts a fresh persistent restart workflow with a deterministic id derived from thetoolCall.id(so a workflow-body replay never double-starts it) and CAS-gates the reopen so the consult is appended exactly once. On Cloudflare Workflows the continuation runs as a fresh workflow instance with a unique-but-deterministic id (agent__<type>__<childSession>__continue__<parent-run suffix>-<stepCount>-<toolCallId>— a Cloudflare Workflows instance id is write-once globally, so the completed child's earlier instance id can't be recreated); the consult is carried as the new instance'snewMessagesand appended exactly once by the instance's!isResumablecontinuation branch (the same path as root multi-turn continuation), so the child session stayscompleteduntil the new instance reopens it. ItsSubSessionRefturnsrunning(undelivered) inside that instance's run-start commit (companionRef, W1 MR2b), never before, so a continuation that loses its start writes nothing. The session reopens in the instance'swriteEntryState, in the samestep.doafter that commit: an instance that dies in between is re-run by the engine, whose re-executed step finds its commit landed and completes the reopen. runtime-js (and so the in-process companions of any runtime-js host) does the same in one step (CP-155): the consult is the input of the child's new run-start commit, which gates on the child'scompletedsession and turns theSubSessionRefrunninginside itself; the session is reopened right after, fenced on the new run. A re-consult that cannot start (a concurrent consult won, the child was terminated, the store refused the commit, or a read failed) writes nothing and is answered typed —not_spawned/delivered: falsewithchild_busy,child_terminated,child_entry_failedorchild_start_unconfirmed— so the companion keeps its memory and the next re-consult continues it. Between the commit and the reopen (or across a crash there) the child session still reads the previous round'scompletedunder a live run. Every reader that syncs a ref from the child, answers its outcome, or notifies it, reads the child once, anchored on its current RUN (corereadChildNotice): the companion read tools (getChildStatus,listChildren,waitForResult), CF Workflows' and runtime-js's per-turn notifiers and core's Temporal / DBOS notifier (deliverPendingChildCompletions), so none reports the previous round's output as the child's status or notifies it again, and a whole child round that lands between two reads is re-read, never notified as the old round's text under the new run. A child whose session will never hold its outcome (its start failed, or its round failed before its terminal commit) is still notified, with the ref's word alone. Acompanion__sendMessagein that window answerschild_exited(not delivered). On runtime-js a crash there leaves the new run with nothing driving it: readers reportrunning, andcompanion__waitForResult's orphan detection settles the reffailedso the name can be re-spawned (fresh) — the in-process crash path (CP-164).failed/terminated(all runtimes): the child starts on a fresh session under its name: the old session's history, state and stream chunks are gone. TheSubSessionRefis reset torunningandcompletionDeliveredis cleared. Most runtimes delete the old session first (deleteSession, which cascades, so it also drops the companion's own descendants); a delete that fails fails the spawn and leaves the ref terminal. On the Cloudflare Durable Object (CP-142) the child lives in its own DO, which the parent's store cannot delete: the re-spawn's/startasks the child DO to start fresh, and the child wipes its own session inside its run start. It first stops the old generation's live companions; if one cannot be confirmed stopped, nothing is wiped and the spawn answersnot_spawned(child_entry_failed, a retryabletransport_error), so the model can try again. Its stream keeps a monotonic sequence and carriesstream_resync { reason: 'respawn' }right after the new run'srun_started, so a client showing the old generation re-anchors. The DO's usage records are not reset by a re-spawn. A companion that is itself re-spawned keeps its own children's names: the child DO of a name is the same Durable Object across the parent's incarnations, so the re-spawned parent's first spawn of a name it used before is claimed above every generation the old incarnation used (the parent DO's generation floor, CP-161), and the child accepts it; a late start of the old incarnation is still refused, before the child wipes anything. That first spawn starts the child fresh (CP-205, below).- A first spawn of a name starts fresh too (all runtimes). A parent that holds no ref for the name gets a new child, even when an earlier incarnation of the parent left one under the same name-derived id (a companion parent that was itself re-spawned). Most runtimes deleted that child with the parent (
deleteSessioncascades). On the Cloudflare DO (CP-205) a re-spawned parent wipes only its own session, which cannot reach its companions' DOs, so every spawn's/startasks the child DO to start fresh, and the child decides as for a re-spawn: no session starts plainly; the earlier incarnation's session is wiped after its live companions are stopped, with therespawnresync; a live run answers busy (not_spawned), never an attach to it. An eviction replay of the same call attaches only to a child run serving that call; a run serving another consult (the earlier incarnation's) is started fresh the same way.
Custom LLM adapters and scripted tests: tool-call ids must be unique per call. A reused id weakens the late-release guard: a refused child of a parent's earlier incarnation releases only a claim its own call made, and a claim by another call that reuses its id looks like its own. On the Cloudflare DO an eviction replay also attaches to a child run that serves its call id (CP-205), so a reused id can attach to an earlier incarnation's run.
You cannot re-use the name of a still-active child: that answers a successful not_spawned result with reason child_busy (CP-40 (C2)). You also cannot re-spawn an interrupted child: that answers reason child_interrupted with resume guidance (use companion__sendMessage to resume it), because its session and memory are still resumable. A companion paused on calls nothing can answer answers reason child_awaiting_client. DBOS still answers the first two as errors with the same guidance (FU-DBOS-COMPANION-TYPED-NAME-REFUSAL).
A companion paused on client or approval calls
A companion whose run calls a client-executed or approval-gated tool pauses until that call is answered. Its parent's conversation cannot answer it, so the parent is told, typed, instead of waiting forever (CP-40 (C2)):
- The answer. A blocking spawn (or continue) answers
{ name, status: 'suspended', pendingCalls, message }, andcompanion__waitForResultanswers the same arm, on the first read, without a wait.pendingCallslists each awaited call as{ toolCallId, toolName, kind: 'client' | 'approval' }(never its input).message(coresuspendedCompanionGuidance) says only the user can answer the calls and the child continues by itself once they are answered, offerscompanion__waitForResult(its result) andcompanion__terminateChild(to stop it), then names the calls. The text is bounded (whole entries, then… and K more);pendingCallsis complete. - The user's answer wakes the companion (CP-250). The user answers through the ROOT, as for any client call. When no live ancestor awaits the companion (its parent's turn ended or moved on), the submit continues the COMPANION itself:
{ accepted, continuation: { kind: 'new_run', runId, sessionId } }(the companion's session), and a chat route answers it by closing the turn (data-tool-output-elsewhere). The companion's outcome lands on the parent's ref, undelivered, and the parent's NEXT turn gets the usual completion notice; no parent turn starts by itself (pull). runtime-js needs the companion's config in this process (itsagentRegistry, by type or through the parent'spersistentAgents); CF Workflows starts the companion's own__submit__instance; DBOS wakes its workflow. A runtime that cannot continue it (Temporal until CP-246, or runtime-js without the config) refuses the submit before storing anything:{ status: 'unknown_tool_call', reason: 'owner_not_continuable' }(a chat route: 409state_not_resumablewith that cause). - The ref. The parent's ref reads
paused_awaiting_client: not terminal (nocompletedAt), never marked delivered, so the child's later outcome still reaches the parent. It used to readcompletedon runtime-js (CP-127) andfailedon CF Workflows. companion__sendMessageanswerschild_awaiting_client, and a same-namecompanion__spawnAgentanswersnot_spawned/child_awaiting_client, each naming the calls.- The order of
pendingCalls. On runtime-js and CF Workflows it is the model's call order (the child's own log); on the CF Durable Object it is the child's/statuslisting order (oldest first, then by call id), which is the same on every read. - A pause whose calls were all answered meanwhile (the child may be running again) is not reported as a suspension: the wait goes on,
sendMessageand a same-name spawn answerchild_busy, and nothing tells the model to terminate a working child. Every wait re-reads the child, so the calls it names are the ones still awaited; a child resumed after its calls were answered and paused on a new call is answered with the new call. On runtime-js, where nothing resumes such a child by itself, a blocking spawn bounds that wait at one child lease TTL and answerssuspendedwith an emptypendingCalls(its message says to wait withcompanion__waitForResult).
The CF Durable Object refuses such a companion. Nothing on the DO can answer a companion's client or approval call (its child DO has no route to a client), so a spawn of a persistent agent whose tree can call one is refused before anything is written or started:
{
name: 'critic',
status: 'not_spawned',
delivered: false,
reason: 'child_not_supported',
errorDetail: {
code: 'framework_companion_not_supported',
category: 'framework',
retryable: false,
message:
"Agent 'critic' cannot be a companion here: nothing can answer its client or approval tools, so spawning it " +
'again cannot help. Tools: ask_user (client); researcher → pay (approval).',
},
}The check (core companionNotSupportedDetail) walks the agent's own tools, every sub-agent tool's agent and every persistentAgents entry, at any depth (each agent once, so a cycle ends); a remote sub-agent tool is opaque. Each offending tool is named by its path below the companion. The server logs the complete list once (companion.spawn.child_not_supported, core companionNotSupportedOffenders). The child DO checks again at its own entry (a resolver that answers differently per DO is refused there too, typed, before anything is written; a re-spawn's old session is not wiped). The same agent definition runs as a companion on runtime-js and CF Workflows, where the shared store lets a client answer its calls through the root.
Temporal and DBOS do not answer the suspended arm yet (FU-TEMPORAL-DBOS-COMPANION-PARK-TYPED-ARMS).
Interrupted companions are resumable
When a parent is interrupted while a persistent child is running (for example handle.interrupt() on the parent), the child's run ends with a soft interrupt: its session is interrupted and its SubSessionRef status is 'interrupted', with no completedAt. It is not failed. The next companion__sendMessage to it resumes the child on its preserved session, and the child's model sees its full prior history plus the new message. companion__waitForResult reports 'interrupted' with resume guidance rather than blocking; it confirms the state over three consecutive polls, so a sendMessage resume issued in the same step is not misreported. companion__terminateChild still ends the child terminated.
This applies to JS and Cloudflare Workflows. On Cloudflare Workflows the resumed child runs as a new workflow instance agent__<type>__<childSession>__resume__<parent-run suffix>-<stepCount>-<toolCallId> (instance ids are write-once) and streams to its own stream, not the parent's. Temporal and DBOS do not yet record 'interrupted' on the companion's ref.
A remote child whose parent step was lost
A remote sub-agent's SubSessionRef records the call's outcome for the PARENT. When the parent's run ends before the step that called the child commits (a crash, a superseding entry, a stop), the next entry stops the child and settles its ref failed with cause parent_step_lost, even if the child's own session finished completed: the parent never received that result. The child's session still reports its real status. See Remote agents ("A step lost with its run").
Deleting a parent session
stateStore.deleteSession(parentId) deletes the parent's whole child tree: every sub-agent and companion session, their descendants, and their messages, runs, checkpoints and refs. A child behind a remote agent tool (SubSessionRef.location === 'remote') is never deleted. Executing the same parent session id again, with the same tool-call ids and companion names, therefore starts the children fresh on runtime-js, the Cloudflare Workflows runtime and Temporal: no turn of the deleted conversation reaches a child's model. Delete a tree only when its run has ended; a live run is undefined. Details: Deleting a session.
DBOS differs. DBOS derives a child's workflow id from the child session id alone and treats it as an idempotency key, so a re-executed parent gets the old recorded result of both its sub-agent and its companion, whatever the store holds. The store's cascade still deletes the rows. Do not rely on delete-then-re-execute to reset children on DBOS (FU-DBOS-CHILD-WORKFLOW-ID-REUSE; see the upgrade guide).
Re-consulting a persistent companion (the critic loop)
A persistent companion's defining feature is that it retains memory across rounds. The canonical use is a maker → critic loop: a parent produces an artifact, spawns a critic that returns a typed verdict, reads the verdict, fixes the artifact, then re-consults the SAME critic — which still remembers the prior round and can comment on what changed.
const CriticAgent = defineAgent({
name: 'critic',
systemPrompt: `You review the maker's artifact and return a verdict.
You remember prior rounds, so call out what changed since last time.`,
outputSchema: z.object({
verdict: z.enum(['pass', 'revise']),
notes: z.string(),
}),
tools: [loadArtifactTool], // loads the current artifact BY REFERENCE (see below)
llmConfig: { model: openai('gpt-6-luna') },
});
const MakerAgent = defineAgent({
name: 'maker',
systemPrompt: `Produce an artifact, then consult the 'critic' companion.
Re-consult the SAME critic ('reviewer') after each fix until it returns
verdict: 'pass', or you hit your own round cap.`,
outputSchema: z.object({ final: z.string() }),
// `PersistentAgentConfig` has no `name` field — `agent` is the registered
// agent TYPE. The stable instance name ('reviewer') is pinned at SPAWN time
// via companion__spawnAgent({ name: 'reviewer', ... }); that name is what you
// re-consult on later rounds.
persistentAgents: [{ agent: CriticAgent, mode: 'blocking' }],
// companion__spawnAgent / sendMessage / waitForResult / ... auto-injected.
});A single round of the loop, as the maker's LLM drives it:
// Round 1 — blocking spawn returns the typed verdict INLINE on `.output`.
const r1 = companion__spawnAgent({
agent: 'critic',
name: 'reviewer',
initialMessage: 'Review artifact v1.',
});
// r1 === { name: 'reviewer', status: 'completed', output: { verdict: 'revise', notes: '...' } }
if (r1.output.verdict === 'revise') {
// ...fix the artifact (v2)...
// Round 2 — re-consult the SAME critic by spawning with the SAME name.
// The session is continued (not recreated): the critic still remembers v1.
const r2 = companion__spawnAgent({
agent: 'critic',
name: 'reviewer',
initialMessage: 'Review artifact v2 — I addressed your notes.',
});
// r2.output.verdict is the FRESH verdict; loop until 'pass' or your cap.
}Ergonomics — prefer blocking spawnAgent for a typed re-consult. A blocking spawnAgent re-consult returns the fresh typed verdict inline as the clean .output of the tool result — this is the recommended path. companion__sendMessage re-consult returns only { delivered: true } (NOT the output); to read the verdict you must follow it with a companion__waitForResult({ name }) call in a later step. A minor cross-runtime nuance: on Temporal, a blocking child's sendMessage-continue may enrich the tool result with the output inline, but do not rely on that — for a portable typed re-consult, use blocking spawnAgent (or sendMessage + waitForResult).
Re-consulting with a revised artifact's bytes. The companion message (initialMessage on spawnAgent, message on sendMessage) is text-only — you cannot attach bytes (a revised image, a binary, etc.) to it. To re-consult over a non-text artifact, give the critic a tool that loads the artifact by reference (loadArtifactTool above, keyed by an id/path/URL the maker passes in the text message); the critic fetches the current bytes itself each round.
Multiple terminal calls in one step. If a step emits more than one terminal call — several finishWith calls, several __finish__ calls, or a regular/sub-agent call co-emitted alongside __finish__ — one call wins and keeps its real result: among finishWith calls, the first one (in emission order) that succeeds; among __finish__ calls, the first one; every other call in that step is not executed and gets a typed synthetic result instead of being left without one, so persisted history is always a valid transcript (see Finishing Agents → Parallel Execution: First Wins). This applies the same way whether the winner is a root agent, a sub-agent, or a persistent companion. In the companion's persisted messages (getMessages / the state store), a "not executed" result carries a metadata.helixSynthetic marker ({ kind: 'not_executed', reason }); check it with isNotExecutedToolResult(message) (exported from @helix-agents/core) rather than treating every failed result the same. The live stream does not carry the marker (no tool_end chunk is emitted for a synthetic result), and neither do AI SDK UI parts converted from history.
A re-consult never discards an unseen result (runtime-js, Cloudflare Durable Objects, CF Workflows). A non-blocking critic can finish while the parent's model call is in flight, before the completion message reaches the model. A same-name companion__spawnAgent (or a companion__sendMessage) issued then delivers that first result AND starts the new round: the result rides the call's own answer as priorResult, on every arm (a not_spawned / delivered: false answer included), so it is never lost and the model never has to re-send:
// spawnAgent (non-blocking): the new round started, the first result beside it
{ name: 'reviewer', status: 'spawned', delivered: true,
priorResult: { status: 'completed', output: { verdict: 'fix', notes: '...' } } }
// spawnAgent (blocking): the new round's verdict, the first result beside it
{ name: 'reviewer', status: 'completed', output: { verdict: 'pass' },
priorResult: { status: 'completed', output: { verdict: 'fix', notes: '...' } } }
// sendMessage
{ delivered: true, priorResult: { status: 'completed', output: { ... } } }A failed earlier round carries errorDetail instead of output. "Unseen" means "not marked delivered" (the completion notifier, a blocking park and companion__waitForResult mark it): a model that read the output through companion__getChildStatus, which does not mark it, gets it once more as priorResult. Exactly once, crash-safe: the re-consult HOLDS the outcome on the child's ref (a store CAS, SessionStateStore.holdPriorResult), the parent's step commit carries the answer, and only then is it marked delivered (releasePriorResult; on the CF DO by the next iteration's notifier pass). A crash before the commit leaves it held and the notifier delivers it once as a completion message; a crash after the commit lets the notifier find the committed answer and release without re-injecting. Temporal and DBOS still start the new round over the unseen result (FU-SPAWN-CONTINUE-ENTRY-OUTCOME-PARITY, parked).
Known limitations:
- Same-turn
waitForResult+sendMessagerace. Callingcompanion__waitForResultandcompanion__sendMessageon the same completed child within one assistant message can race and return the stale prior output. Re-consult and read the result in separate turns (e.g.sendMessage/spawnAgentin one turn, read the verdict in the next). - A companion cut off by
maxStepsis not continuable. A persistent companion withoutoutputSchemathat runs out ofmaxStepsmid-tool-loop endsfailed(framework_max_steps_exhausted; see WhenmaxStepsruns out), notcompleted. Only acompletedcompanion is continued:companion__sendMessageto a failed one throwsnot active (status: failed), and the nextcompanion__spawnAgentwith its name re-spawns it fresh, so it loses its memory of earlier rounds. A companion withoutputSchema(like the critic above) enters forced completion at the cap instead, so it stays continuable. If a companion withoutoutputSchemamust stay continuable, give it astopWhenthat ends its tool loop successfully, or amaxStepslarge enough for one round. Whether a cut-off companion should be continuable like a completed one is an open question (FU-MAX-STEPS-COMPANION-CONTINUATION).
Performance & resource considerations:
- Per-round token cost grows with history. Each re-consult is a continuation on the preserved child session: the critic remembers every prior round (intended — that's what makes the loop converge). But the child's transcript accumulates, so the token cost of each round grows with the number of rounds. Cap your re-consult rounds in your loop policy (the critic pattern above does this with a bounded
forloop / a max-rounds guard), rather than looping until convergence unbounded. - Each re-consult round gets a fresh per-turn
maxStepsbudget. A re-consult is a new turn on the child session, so the child's step count restarts and the fullmaxStepsbudget applies again each round —maxStepsbounds a single round, not the critic's lifetime across rounds. (Same per-turn budget model as root continuation; seemaxSteps.) - Durable runtimes start a fresh child workflow/instance per round. On Temporal, DBOS, and Cloudflare Workflows, each re-consult begins a brand-new child workflow/instance for that round (the durability trade-off — every round is independently replayable/recoverable). For very high-frequency critic loops, the in-process JS runtime has the lowest per-round overhead (no workflow/instance spin-up).
- No built-in per-parent child cap. There is no framework limit on the number of distinct persistent children a parent may spawn. Re-consulting the same named child reuses its session (no new ref), but spawning many distinctly-named children grows the parent's
SubSessionReflist unboundedly — bound this in your own agent design if a parent can fan out widely.
How the parent learns a child finished
There are two ways the parent observes a child's outcome, and they cooperate so a completion is delivered exactly once:
- Pull (blocking spawn or
companion__waitForResult) — the result is returned inline as the tool result, in the same turn. The child's ref is flaggedcompletionDelivered: trueat that moment. - Push (deliver-on-next-turn) — for a non-blocking child that finishes on its own, a per-turn completion notifier injects a hidden message at the start of the parent's next turn (e.g.
Sub-agent 'researcher-1' completed with result: …, orSub-agent 'researcher-1' failed: …if it failed). This is failure-aware (a failed child yields a failure notification, not silence) and skipsterminatedchildren (the parent killed those deliberately).
On the CF Durable Object the child runs in another DO, so the push needs a channel back: the child's settle posts a durable child-settled notice to its parent, and the parent's verified read of the child's /status records the outcome on the ref (retried with backoff until the parent answers; see How a parent learns its companion's outcome). A notice never starts a turn on an idle parent: the result is delivered on its next turn, as on every other runtime. That holds even when the next turn starts before the notice reached the parent (the child ends its stream before it rings): the turn's first notifier pass reads each outstanding companion itself, waiting at most 2 s per child (CP-204). A same-name spawnAgent then continues the completed child (CP-39).
A child's failure text reaches the parent sanitized (DI-70). Every copy of a child's failure text built for the parent goes through core's client preset (sanitizeErrorDetail / sanitizeErrorMessage: credentialed connection strings (their host included), credentials, tokens, key=value pairs such as host= / password=, emails, IPs and absolute paths redacted, one line, capped at 256 characters; a bare hostname elsewhere in the text is not redacted): the failure notice (Sub-agent '<name>' failed: <sanitized text>), a blocking park's or spawnAgent's { status: 'failed', errorDetail } answer, and every answer's priorResult.errorDetail. Machine fields (code, category, retryable) are kept. It holds on every runtime (runtime-js, the CF DO, CF Workflows, Temporal and DBOS share core's notice builder); the parked Temporal blocking rows and DBOS's own spawn path are FU-TEMPORAL-DBOS-COMPANION-FAILURE-ROWS-SANITIZE. The child session's own error / errorDetail stays raw: it is operator data (read it in-process or from the store, as getStatus and onAgentFail do), never parent-visible. Because the notice text is capped, a long failure no longer makes a failure notice too large (below).
A companion's lifetime is its session's, not its parent's run's. A parent's Stop while it is idle, or a parent run that fails, does not stop its companions: they run on, and their results are delivered on the parent's next turn like any other push (a failed session is continuable). Only a Stop of a live run (running or paused) stops its live children with it; that includes a run whose record still reads running after its owner died. On CF Workflows the child keeps its outcome on its own session, and the next turn syncs the parent's ref from it. That holds too when a CF Workflows reader settles a parent whose instance closed mid-run with nothing committed: the settle aborts the run's ephemeral and remote children but leaves its persistent companions running (CP-199).
The completionDelivered flag on the SubSessionRef is the durable dedup: once a child's outcome has been delivered by either path, the notifier won't re-inject it — even across a process restart, a parent re-entry, or a step retry. This is why a child waited-on via waitForResult doesn't also produce a duplicate push notification on the next turn.
A notice too large to record (MA-37, MA-38). The pushed notice embeds the child's output as JSON, and the store binds the row as JSON again, so the stored row is about twice the output (a ~0.9 MB output of quotes makes a ~1.8 MB row). The notifier measures the built row exactly as the store binds it (core messageRowBytes) against the store's maxMessageBytes, and on CF Workflows also against the 1 MiB step-result cap, all of one turn's notices together. One over the limit is delivered as its typed replacement, still one hidden completion notification, so the child is marked delivered and the notice is never re-injected: Sub-agent 'researcher-1' completed, but its result was too large to record: the recorded row would be N bytes, over the M-byte message limit. Return large data by reference … (a failed child's notice carries capped text since DI-70, so it fits; the … failed, but its error was too large to record … replacement remains for a store whose limit is below a capped notice). Before, the store's refusal was swallowed and retried on every iteration on runtime-js and the CF DO, and failed every later turn on CF Workflows; the model never learnt the child finished. A child that returns large data should return it by reference.
State Tracking
Persistent children are tracked via SubSessionRef entries with mode: 'persistent':
interface SubSessionRef {
subSessionId: string;
agentType: string;
parentToolCallId: string;
status:
| 'running'
| 'completed'
| 'failed'
| 'interrupted'
| 'terminated'
| 'paused_awaiting_client'; // child paused on client-executed / approval calls (not terminal, never delivered)
startedAt: number;
completedAt?: number;
mode: 'ephemeral' | 'persistent'; // 'persistent' for companion-managed children
name?: string; // required when mode === 'persistent'
completionDelivered?: boolean; // durable dedup: outcome already delivered to the parent
}Usage tracking
A persistent companion records token, tool, and custom usage (ctx.recordUsage) under its own child session — exactly like an ephemeral sub-agent — and the parent records a discoverable kind:'subagent' entry, so getUsageRollup({ includeSubAgents: true }) on the parent aggregates the companion's cost. A companion's usage accumulates across re-consults on its preserved session and is counted exactly once. The parent-side entry is a discovery pointer (provisional duration/success); read the child-session rollup for a companion's true outcome/duration. See Usage Tracking → Persistent companions. (Exception: the Cloudflare Durable Object runtime stores usage per-DO and can't aggregate cross-DO; the CFW Workflows runtime requires a usageStore in its workflow dependencies — see that guide.)
Ephemeral vs Persistent Comparison
| Feature | Ephemeral (createSubAgentTool) | Persistent (persistentAgents) |
|---|---|---|
| Created by | Parent's tool call to subagent__ | companion__spawnAgent tool |
| Lifecycle | Runs to completion, result returned | Long-lived, can receive messages |
| Follow-up messages | Not supported | Via companion__sendMessage |
| Result access | Immediate (tool result) | Via companion__getChildStatus or companion__waitForResult |
| Naming | Auto-generated | Explicit or auto-incremented |
| Mode | Always blocking | Blocking or non-blocking |
| Session ID | child_<uuid> (minted) | {parentSessionId}-agent-{name} |
| SubSessionRef mode | 'ephemeral' | 'persistent' |
Example: Research Coordinator
const ResearcherAgent = defineAgent({
name: 'researcher',
systemPrompt: 'You research topics thoroughly and return findings.',
outputSchema: z.object({
findings: z.string(),
sources: z.array(z.string()),
}),
tools: [searchTool],
llmConfig: { model: openai('gpt-6-luna') },
});
const CoordinatorAgent = defineAgent({
name: 'coordinator',
systemPrompt: `You coordinate research tasks.
You have persistent researcher children that you can spawn, send messages to, and check results.
1. Spawn researchers for different topics
2. Wait for their results
3. Compile a final summary`,
outputSchema: z.object({ summary: z.string() }),
persistentAgents: [
{
agent: ResearcherAgent,
mode: 'blocking',
description: 'Spawns researcher agents for deep dives',
},
],
llmConfig: { model: openai('gpt-6.1-sol') },
});The coordinator can then:
- Spawn
researcher-1for topic A - Spawn
researcher-2for topic B - Check status or wait for results
- Terminate if needed
- Compile final output
Runtime Support
Persistent sub-agents work across all five runtimes. The companion-tool surface is identical everywhere — the same tools (5 always, plus companion__waitForResult when a child is mode: 'blocking'), the same arguments, the same terminate-truth / completion-dedup semantics — because every runtime routes through the shared core dispatcher (executeCompanionToolDispatch). The differences below are in how a runtime starts and waits on a child, not in the LLM-facing behavior. (Exceptions: sendMessage to a running child and waitForResult on an interrupted child behave differently on Temporal, Cloudflare Workflows and DBOS; see Known companion gaps above: CP-41, CP-72, CP-73.)
| Runtime | Blocking | Non-blocking | Workspaces on children | Notes |
|---|---|---|---|---|
| JS | Yes | Yes | Yes | In-process execution; the parent dispatcher parks blocking spawns on the child loop. |
| Temporal | Yes | Yes | No (fail-fast) | Child workflows; blocking spawn returns the child's output inline. HITL inside children supported. |
| Cloudflare Workflows | Yes | Yes | No (fail-fast) | Nested workflow instances; HITL inside children supported via cascade. |
| Cloudflare DO | Yes | Yes | Yes | Children are sibling DOs dispatched via subAgentNamespace (v7.0, commit fb3180f6b). |
| DBOS | Yes¹ | Yes | No (fail-fast) | Durable child workflows; waitForResult uses durable DBOS.sleep; completion notifier is a @DBOS.step. |
¹ Since CP-69, a DBOS blocking spawn returns when the child's turn ends, as on every other runtime (each persistent turn writes its terminal to the child's session while its loop lives on to its idle timeout).
Workspaces on persistent children are supported only on the JS and Cloudflare DO runtimes. On Temporal, Cloudflare Workflows, and DBOS, a persistent child that declares a
workspace(and is notinheritWorkspace: true) fails fast at spawn with a clear error (the "C8" guard) — those orchestration models can't host the stateful workspace lifecycle. See Workspaces below.
Workspaces
Persistent sub-agents can use workspaces in two modes:
Per-invocation (default)
Each companion__spawnAgent and companion__sendMessage cycle opens the child's workspaces fresh and closes them when the child exits. This is the safe default, but cost-bearing providers (Cloudflare sandboxes, R2 namespaces) pay the open() cost N times for N sends.
State persistence across invocations depends on the provider:
InMemoryWorkspaceProvider— state is LOST between invocations (in-memory state has no backing store).CloudflareFileStoreWorkspace/CloudflareSandboxWorkspace— state persists via the underlying Durable Object storage; close+reopen cycles reattach to the same R2 prefix / sandbox container.LocalBashWorkspace— state is LOST (per-cycle tmpdir).
Persistent
Not yet honored in v1 — reserved
workspaceLifetime: 'persistent' is a reserved type field. Only 'per-invocation' is currently honored by the JS runtime; 'persistent' is documented as the intended option but executor support is pending (per the workspaceLifetime JSDoc on packages/core/src/types/agent.ts). Until support lands, a config setting 'persistent' behaves as 'per-invocation'. To minimize per-invocation open-cost in the meantime, use providers whose resolve() reattaches efficiently (CloudflareFileStoreWorkspace, CloudflareSandboxWorkspace).
Set workspaceLifetime: 'persistent' on the persistentAgents entry to (once supported) keep workspaces open across the child's lifetime. They would close only when the child is terminated (via companion__terminateChild) or the parent shuts down.
persistentAgents: [
{
agent: ResearcherAgent,
mode: 'blocking',
workspaceLifetime: 'persistent', // workspaces open once, reused across sends
},
],Use 'persistent' when:
- The child's workspaces have meaningful open-cost (sandboxes, network-backed FS).
- The child receives many
sendMessagecalls in quick succession.
Use 'per-invocation' (default) when:
- The child is short-lived or rarely re-invoked.
- Workspace state needs to be reset between invocations.
- The provider already handles open-cost cheaply (in-memory).
Inheriting the parent workspace
Set inheritWorkspace: true on a persistentAgents entry to share the parent's workspace with the child. Same semantics as the ephemeral createSubAgentTool({ inheritWorkspace: true }) flag — the child runs against the parent's WorkspaceRegistry directly. An inheriting child must NOT also declare its own workspace; doing so throws a clear, named error at sub-agent execution time.
persistentAgents: [
{
agent: ResearcherAgent,
mode: 'blocking',
inheritWorkspace: true, // child shares the parent's WorkspaceRegistry
},
],When inheritWorkspace: true, the workspaceLifetime field has no effect — the child uses the parent's registry, whose lifetime is bounded by the parent's runLoop.
Status of
workspaceLifetime(round-5 D10). TheworkspaceLifetimefield on apersistentAgentsentry is reserved for future use. As of v1, all values behave as'per-invocation'— the workspace opens at sub-agent spawn/resume and closes at sub-agent exit. The'persistent'lifetime (the workspace stays open across multiplecompanion__sendMessagecalls) is filed as a known follow-up. Until support lands, prefer providers whoseresolve()reattaches efficiently (CloudflareFileStoreWorkspace,CloudflareSandboxWorkspace) to minimize per-invocation cost.
Practical guidance. Use inheritWorkspace: true when the child should operate on the parent's workspace (a working notes workspace, a shared cache). For sub-agent operations whose workspace state must be isolated from the parent's session, leave inheritWorkspace unset and declare a workspace on the child config — the child's runLoop owns its own registry and persistRef writes to the child's session state.
Sibling workspace visibility (round-5 D17)
When TWO sibling sub-agents both opt into inheritWorkspace: true, they share the SAME physical workspace storage via the parent's registry. Concretely:
- Sibling A writes
/notes/idea.md. Sibling B can read it back. - Sibling B's writes are visible to Sibling A and to the parent.
- All three (parent + A + B) operate against the same
WorkspaceRegistryinstance.
This is the natural consequence of registry sharing — the registry is a per-session singleton, and inheritWorkspace: true means "use the parent's registry directly." There is no per-sub-agent isolation when inheriting.
If sibling sub-agents need workspace isolation from each other, do NOT set inheritWorkspace: true on either; declare each sub-agent's workspace on the child config so each gets its own isolated registry.
Reserved tool prefixes
The companion__ prefix is reserved by the framework for the auto-injected companion tools. User-defined tools whose name starts with companion__ cause defineAgent() to throw at build time, regardless of whether the agent declares any persistentAgents. This is enforced unconditionally so the prefix's reserved status is a stable contract — your agent code keeps working when you add a persistent sub-agent later. Use any other naming pattern (e.g. helper__listChildren, myCompanion) for your own tools.
The workspace_ prefix is similarly reserved (see Workspaces — reserved prefix).
Best Practices
1. Clear Output Schemas
Define precise output schemas for clear contracts:
// Good: Specific schema
const agent = defineAgent({
outputSchema: z.object({
sentiment: z.enum(['positive', 'negative', 'neutral']),
confidence: z.number().min(0).max(1),
reasoning: z.string(),
}),
});
// Avoid: Vague schema
const agent = defineAgent({
outputSchema: z.object({
result: z.unknown(), // What is this?
}),
});2. Descriptive Sub-Agent Tools
Help the parent LLM choose correctly:
const tool = createSubAgentTool(AnalyzerAgent, z.object({ text: z.string() }), {
description: `Analyze text for sentiment and extract key topics.
Use when you need:
- Sentiment classification (positive/negative/neutral)
- Topic extraction from text
- Confidence scores for analysis
Returns: { sentiment, confidence, topics }`,
});3. Appropriate Granularity
Balance specialization vs. overhead:
// Good: Meaningful specialization
const FactCheckerAgent = defineAgent({
/* verifies claims */
});
const SummarizerAgent = defineAgent({
/* creates summaries */
});
// Avoid: Over-granular
const CapitalizerAgent = defineAgent({
/* just capitalizes text */
});
// ^ This is better as a regular tool or string method4. Handle Sub-Agent Limits
Set appropriate maxSteps for sub-agents, and use timeoutMs to enforce wall-clock limits:
const SubAgent = defineAgent({
name: 'focused-task',
maxSteps: 5, // Sub-agents should complete quickly
// ...
});
const subAgentTool = createSubAgentTool(SubAgent, inputSchema, {
timeoutMs: 30_000, // 30-second wall-clock limit (important in DO runtime)
});5. Test Sub-Agents Independently
Sub-agents are full agents - test them alone first:
// Test sub-agent directly
const subHandle = await executor.execute(AnalyzerAgent, 'Test text');
const subResult = await subHandle.result();
expect(subResult.status).toBe('completed');
// Then test in orchestration
const parentHandle = await executor.execute(OrchestratorAgent, 'Analyze this');
const parentResult = await parentHandle.result();Limitations
No Shared State
Sub-agents cannot access parent state. Design inputs/outputs to carry needed context:
// Pass context through input
const tool = createSubAgentTool(
SubAgent,
z.object({
query: z.string(),
context: z.object({
previousFindings: z.array(z.string()),
constraints: z.array(z.string()),
}),
})
);Sequential by Default
Multiple sub-agent calls in one LLM response may execute in parallel, but the parent waits for all before continuing.
Overhead
Each sub-agent invocation includes:
- State initialization
- Full agent loop (potentially multiple LLM calls)
- State persistence
For simple transformations, prefer regular tools.
Lifecycle Hook Guarantees
Sub-agents fire their own lifecycle hooks (onAgentStart, onAgentComplete, onAgentFail) independently from the parent agent. This is important for tracing integrations that need to emit spans for each sub-agent. The parent's stream is not closed when a sub-agent completes — only its hooks fire. This behavior is consistent across all runtimes. A child run that is superseded fires onAgentSuperseded, never onAgentFail (and onAgentComplete only when the supersede landed after it fired and before the child's terminal commit; the last hook is authoritative), and a child entry that fails or loses at its entry (its run-start commit) fires no lifecycle hook; see Hooks. See Sub-Agent Execution Internals for implementation details.
Next Steps
- Remote Agents - Delegate to agents on separate HTTP services
- Streaming - Handle sub-agent stream events
- Interrupt and Resume - How interrupts propagate through sub-agent hierarchies
- Runtimes - How different runtimes handle sub-agents
- Persistent Sub-Agents example - A runnable, offline (mock-LLM) companion-tool demo
- Examples - Real-world orchestration examples
- Hooks - Observe sub-agent execution with
beforeSubAgentandafterSubAgenthooks