Skip to content

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.

mermaid
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:

typescript
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:

typescript
// ✓ 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:

  1. The child's run loop yields RunOutcome.suspended_client_tool and persists its pending entries to its own SessionState.pendingClientToolCalls.
  2. The parent's run loop observes that a child is incomplete and yields RunOutcome.suspended_awaiting_children with a SuspendedChildWait[] payload listing each pending child.
  3. The parent state writes suspendedAwaitingChildren to its suspensionContext durably.
  4. The root-session clientToolCallOwnership map records that toolCallId is 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) whose awaitingToolCallIds names 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 toolCalls order, 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's ErrorDetail under helixErrorDetail. The parent continues.
    • A transient failure ends the parent failed with 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 active by 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 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:

  1. Tool Call Detection - Framework identifies the tool as a sub-agent (prefixed with subagent__)
  2. 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 streamId as parent (for unified streaming)
    • Input converted to user message
  3. Execution - Sub-agent runs its full loop until completion
  4. Result Return - Sub-agent's output becomes the tool result
typescript
// 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_start chunk's subSessionId (and callId, the parent's tool call);
  • the parent's refs, stateStore.getSubSessionRefs(parentSessionId): each ref carries subSessionId, parentToolCallId and dispatchId;
  • the afterSubAgent hook payload's subSessionId.

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:

typescript
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:

typescript
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_start for the parent's subagent__<name> tool call
  • subagent_start when the sub-agent begins
  • All proxied sub-agent chunks (text_delta, tool_start/tool_end for inner tools, etc.)
  • subagent_end when the sub-agent completes
  • tool_end for the parent's subagent__<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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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 afterSubAgent fold 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: afterSubAgent fires 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:

  • beforeSubAgent fires on the JS, Cloudflare Durable Object, Cloudflare Workflows, and DBOS runtimes. It is not fired on Temporal. Don't depend on beforeSubAgent for Temporal portability.
  • onMessage for 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 (afterSubAgent still folds there).
  • DBOS beforeSubAgent + afterSubAgent folding the same top-level key in one wave resolve last-writer-wins (the afterSubAgent fold is staged from the committed base and doesn't see the beforeSubAgent row). On the JS runtime they compose. Disjoint keys compose everywhere. (beforeSubAgent folds 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:

typescript
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.

typescript
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:

typescript
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.

typescript
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.

ToolInjected whenDescription
companion__spawnAgentalwaysCreate and start a new persistent child.
companion__sendMessagealwaysSend 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__listChildrenalwaysList all persistent children and their current statuses.
companion__getChildStatusalwaysGet detailed status (and last output) of one child by name.
companion__terminateChildalwaysTerminate a running child.
companion__waitForResultonly if a child blocksBlock 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 } — agent is one of the parent's configured persistentAgents types (the LLM sees it as an enum). name is optional; omit it for auto-naming. When spawning multiple children of the same type in a single step, pass an explicit unique name for 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; parseCompanionSpawnResult is 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 reads reason and may spawn again. spawned means the child's run-start committed (CP-161): on runtime-js and CF Workflows a first spawn and a respawn used to answer spawned before the child's start, so a start that then failed surfaced only later as an untyped Sub-agent '<name>' failed notice. 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 answered not_spawned too, 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 (the name is held by an interrupted child: resume it with companion__sendMessage) and child_awaiting_client (the name is held by a child paused on calls; its errorDetail names them). A name held by a running child is child_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 /start bound — 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; errorDetail says how) and child_terminated (the parent terminated the child first). A first spawn's definite non-start leaves the name re-spawnable (its ref reads failed with the detail, and on runtime-js the never-run child session reads failed with it too, as does a respawn's fresh one); a first spawn answered child_busy writes 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 own initialMessage. 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 answers not_spawned / child_busy before 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 written failed, delivered — exactly what companion__waitForResult's orphan detection answers — so the name can be re-spawned. A non-blocking attach answers spawned and its ref stays running until the run is superseded or companion__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) or unreachable (below). An interrupted answer carries message, the resume guidance (use companion__sendMessage to continue it; core interruptedCompanionGuidance, every runtime that parks through core). On the CF Durable Object a child that was interrupted on its own is answered interrupted after three consecutive observations (the same debounce as companion__waitForResult), and a blocking spawn whose PARENT is stopped answers { name, status: 'parent_stopped', kind } (kind: interrupt or abort). 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 stays running until the child's own committed word reaches it. The stop is owed on the ref and retried until the child confirms it, within owedStopSchedule.windowMs (default 24 h), then abandoned with the ref left live (the DO's stop). Every arm may carry priorResult, 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, failed with errorDetail, interrupted with 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 by subAgentEventTimeout: a blocking spawn (or continuation) still waiting at that bound answers the error Timeout waiting for child agent and 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 (failed with framework_subagent_wait_bound when 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 stays running and 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 default subAgentEventTimeout (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 for companionPollFailureWindowMs (30 s by default; every verified read of the child DO's /status failed) 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 calls companion__terminateChild. It used to record the child failed and 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 to failed so the name stays re-spawnable). A name already in use by a live child is no longer an error: it is the typed not_spawned answer above (child_busy, child_interrupted, child_awaiting_client; CP-40 (C2)), on every runtime except DBOS, which still answers the already running / is interrupted error (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 optional priorResult (an earlier, not-yet-delivered result of a completed child this message continues; see A re-consult never discards an unseen result). Delivery is reported truthfully: delivered: false always carries a reason, 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 direct resume() of the child), or the child's live run has a recorded stop (WA MR2 §7.4: it answered a stop with 202 stopping, or a stop is recorded on its suspended run). A stopping child is ending, so the message is not sent to it; once it ends interrupted, a later sendMessage resumes 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's handleSendMessage reads 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 it resumed_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 (check getChildStatus before sending again).
    • child_entry_failed: the child's entry failed before its run-start commit; errorDetail says how (for example a refused completion strategy, or resumed_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's completed; 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's persistentAgents.
    • child_awaiting_client (CP-40 (C2)): the child is paused on client-executed or approval-gated calls; nothing is appended. errorDetail (core companionAwaitingClientDetail) names the awaited calls and what to do: answer them where the child's client can, or companion__terminateChild. A child whose calls were all answered meanwhile (it may be running again) is child_busy instead, 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: true only 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 by companionEntryOutcomeTimeoutMs (a WorkflowOptions field, 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 is child_busy when its entry lost the run-start commit, else child_entry_failed with its errorDetail; no outcome within the bound is child_start_unconfirmed.
    • CF Durable Object (with subAgentNamespace; the child is a sibling DO): the child's /resume answer decides. A 409 ALREADY_RUNNING (the child DO is executing a run) is child_busy; no answer within 60 s, a rejected fetch or an unreadable body is child_start_unconfirmed; any other refusal is child_entry_failed with its errorDetail (W1 MR2b; it used to be a tool error); the I1 race is child_exited. A 2xx that is not a RemoteStartResponse is still a typed tool error (RemoteProtocolValidationError). The /resume carries the call's id as its consult, which the parent records on the ref before it sends; the ref turns running (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 after child_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_failed carries the entry's errorDetail.
    • runtime-js maps resumeChildAgent's typed answer exhaustively (core parseCompanionSendMessageResult; a value outside the union is a non-retryable framework_internal_error, never passed to the model).
    • Temporal still reports delivered: true once a continuation is started, before the child's run-start commit, and answers child_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 reason child_awaiting_client (CP-40 (C2), above) on runtime-js, Temporal, CF Workflows and the CF Durable Object (whose calls come from the child's /status listing); DBOS still answers the unresolved client-tool calls error. For a child in a terminal status (is not active): a completed child no longer errors on any of the five runtimes — it is continued on its preserved session (memory retained, fresh output). A failed or terminated child still errors (use spawnAgent to re-spawn). Sending to an interrupted child resumes it on its preserved session with its complete history plus the new message (see Interrupted companions).

  • Resuming an interrupted child: the parent's ref turns running (with completionDelivered: 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 its running ref, and a refused or lost start writes none. A resume that cannot start leaves the ref as it was: interrupted on runtime-js and CF Workflows, so companion__waitForResult still reports it resumable; on Temporal, which never records interrupted on a ref, its prior status (the stale running of CP-73, which waitForResult still reports interrupted through 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 its outputSchema is 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, never child_busy. A child the parent terminated before the resume's run start is refused the same way (resumed_companion_ref_terminated, the ref stays terminated); 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 reports child_busy (a resume that met the child's stop, expected_run_stopped, included), one whose parent ref is missing reports child_entry_failed (with errorDetail.cause: 'resumed_companion_ref_missing'), and one refused because the parent terminated the child first (resumed_companion_ref_terminated) reports child_terminated (WA MR2 §7.4); the resume writes no ref and the ref stays as it was (CP-105, W1 MR2b). Temporal still reports delivered: true there (the resume writes no ref, so its prior status, the stale running of CP-73, stays; the child workflow ends failed / state_run_start_conflict with details.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 /resume first and write the ref only after it succeeds. That is not atomic: a crash between the two leaves the ref interrupted. 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 running of CP-73 on Temporal), and a ref with completionDelivered: true keeps 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 renamed companionResume → 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 } (core waitForResultArgsSchema()). timeout is 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 on status):

    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 read

    unreachable (CP-125, D1; core waitForResultUnreachable) is answered on the CF Durable Object when every read of the child DO's /status failed for companionPollFailureWindowMs (30 s by default). currentStatus is the parent's ref status (still running: nothing was written), and message is 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 call waitForResult again or terminateChild. The other runtimes share one store with the child and never answer it.

    On timeout, currentStatus is the child's ref status read at the deadline (running, interrupted or paused_awaiting_client, the SubSessionRef statuses). The child keeps running, so the model can call waitForResult again. 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 (core waitForResultTimeout builds it on every runtime).

  • An interrupted child returns status: 'interrupted' with a message telling the model to resume it with companion__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 reported interrupted also returns after three polls instead of parking. The state is confirmed over three consecutive polls (about 2 s), so a sendMessage resume issued in the same step is normally not misreported. A deadline that passes before the third poll answers the typed timeout (with currentStatus: 'interrupted', or running for a Temporal ref, CP-73): the confirmation never runs a wait past its deadline. A child with a live run is never reported interrupted, 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 turns running only once the run starts), the wait can return a false interrupted and 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 timeout waits until the child finishes. On the CF Durable Object it also ends at unreachable (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 } after subAgentEventTimeout, and the model should call waitForResult again. Every answer, including interrupted and a failed or terminated outcome 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 }). A failed answer carries no error text, as on the other runtimes. A companion that suspends (a client tool or its own children) is still answered failed there (FU-CFW-COMPANION-WAIT-SUSPENDED-CHILD-AS-FAILED), and a child whose instance closed without notifying answers timeout on every repeat wait (FU-CFW-WAITFORRESULT-CLOSED-CHILD-TIMEOUT).
  • Returning a terminal result here marks the child's ref completionDelivered so 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

  • sendMessage to a running child (CP-41). Since WA MR2 the delivery is not a stop: core appends the message to the child's live run (an append commit) 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 sendMessage calls 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 reported delivered: true.
  • An interrupted child's ref on Temporal (CP-73). Temporal never records the child's ref as interrupted when the child ends; it keeps reading running until a consult reads it. Since CP-184 the companion read tools (listChildren, getChildStatus, waitForResult) fold a live ref over a child whose run ended interrupted with its own terminal commit to interrupted (core's lazy sync, on every runtime that uses core's companion dispatch), so they report interrupted from that consult on. The untimed waitForResult hang 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 returns interrupted after 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: name must be non-empty and ≤128 characters; the companion message (initialMessage on spawnAgent, message on sendMessage) must be non-empty (an empty message is rejected); and timeout (on waitForResult, 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).

typescript
// 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 the initialMessage is appended as the next turn's input. The SubSessionRef is reset to running and its completionDelivered flag 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 gets child_busy). That write records a pending-continuation marker in the child session's metadata under the key __helix.companionContinue, cleared when the continuation's run commits or the consult is abandoned. It is ordinary session metadata, so listSessions metadata 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 the toolCall.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's newMessages and appended exactly once by the instance's !isResumable continuation branch (the same path as root multi-turn continuation), so the child session stays completed until the new instance reopens it. Its SubSessionRef turns running (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's writeEntryState, in the same step.do after 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's completed session and turns the SubSessionRef running inside 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: false with child_busy, child_terminated, child_entry_failed or child_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's completed under 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 (core readChildNotice): 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. A companion__sendMessage in that window answers child_exited (not delivered). On runtime-js a crash there leaves the new run with nothing driving it: readers report running, and companion__waitForResult's orphan detection settles the ref failed so 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. The SubSessionRef is reset to running and completionDelivered is 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 /start asks 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 answers not_spawned (child_entry_failed, a retryable transport_error), so the model can try again. Its stream keeps a monotonic sequence and carries stream_resync { reason: 'respawn' } right after the new run's run_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 (deleteSession cascades). 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 /start asks 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 the respawn resync; 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 }, and companion__waitForResult answers the same arm, on the first read, without a wait. pendingCalls lists each awaited call as { toolCallId, toolName, kind: 'client' | 'approval' } (never its input). message (core suspendedCompanionGuidance) says only the user can answer the calls and the child continues by itself once they are answered, offers companion__waitForResult (its result) and companion__terminateChild (to stop it), then names the calls. The text is bounded (whole entries, then … and K more); pendingCalls is 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 (its agentRegistry, by type or through the parent's persistentAgents); 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: 409 state_not_resumable with that cause).
  • The ref. The parent's ref reads paused_awaiting_client: not terminal (no completedAt), never marked delivered, so the child's later outcome still reaches the parent. It used to read completed on runtime-js (CP-127) and failed on CF Workflows.
  • companion__sendMessage answers child_awaiting_client, and a same-name companion__spawnAgent answers not_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 /status listing 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, sendMessage and a same-name spawn answer child_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 answers suspended with an empty pendingCalls (its message says to wait with companion__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:

ts
{
  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.

typescript
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:

typescript
// 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:

typescript
// 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 + sendMessage race. Calling companion__waitForResult and companion__sendMessage on 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/spawnAgent in one turn, read the verdict in the next).
  • A companion cut off by maxSteps is not continuable. A persistent companion without outputSchema that runs out of maxSteps mid-tool-loop ends failed (framework_max_steps_exhausted; see When maxSteps runs out), not completed. Only a completed companion is continued: companion__sendMessage to a failed one throws not active (status: failed), and the next companion__spawnAgent with its name re-spawns it fresh, so it loses its memory of earlier rounds. A companion with outputSchema (like the critic above) enters forced completion at the cap instead, so it stays continuable. If a companion without outputSchema must stay continuable, give it a stopWhen that ends its tool loop successfully, or a maxSteps large 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 for loop / a max-rounds guard), rather than looping until convergence unbounded.
  • Each re-consult round gets a fresh per-turn maxSteps budget. A re-consult is a new turn on the child session, so the child's step count restarts and the full maxSteps budget applies again each round — maxSteps bounds a single round, not the critic's lifetime across rounds. (Same per-turn budget model as root continuation; see maxSteps.)
  • 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 SubSessionRef list 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:

  1. Pull (blocking spawn or companion__waitForResult) — the result is returned inline as the tool result, in the same turn. The child's ref is flagged completionDelivered: true at that moment.
  2. 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: …, or Sub-agent 'researcher-1' failed: … if it failed). This is failure-aware (a failed child yields a failure notification, not silence) and skips terminated children (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':

typescript
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 ​

FeatureEphemeral (createSubAgentTool)Persistent (persistentAgents)
Created byParent's tool call to subagent__companion__spawnAgent tool
LifecycleRuns to completion, result returnedLong-lived, can receive messages
Follow-up messagesNot supportedVia companion__sendMessage
Result accessImmediate (tool result)Via companion__getChildStatus or companion__waitForResult
NamingAuto-generatedExplicit or auto-incremented
ModeAlways blockingBlocking or non-blocking
Session IDchild_<uuid> (minted){parentSessionId}-agent-{name}
SubSessionRef mode'ephemeral''persistent'

Example: Research Coordinator ​

typescript
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:

  1. Spawn researcher-1 for topic A
  2. Spawn researcher-2 for topic B
  3. Check status or wait for results
  4. Terminate if needed
  5. 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.)

RuntimeBlockingNon-blockingWorkspaces on childrenNotes
JSYesYesYesIn-process execution; the parent dispatcher parks blocking spawns on the child loop.
TemporalYesYesNo (fail-fast)Child workflows; blocking spawn returns the child's output inline. HITL inside children supported.
Cloudflare WorkflowsYesYesNo (fail-fast)Nested workflow instances; HITL inside children supported via cascade.
Cloudflare DOYesYesYesChildren are sibling DOs dispatched via subAgentNamespace (v7.0, commit fb3180f6b).
DBOSYes¹YesNo (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 not inheritWorkspace: 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.

typescript
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 sendMessage calls 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.

typescript
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). The workspaceLifetime field on a persistentAgents entry 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 multiple companion__sendMessage calls) is filed as a known follow-up. Until support lands, prefer providers whose resolve() 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 WorkspaceRegistry instance.

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:

typescript
// 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:

typescript
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:

typescript
// 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 method

4. Handle Sub-Agent Limits ​

Set appropriate maxSteps for sub-agents, and use timeoutMs to enforce wall-clock limits:

typescript
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:

typescript
// 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:

typescript
// 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 ​

Released under the MIT License.