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-4o-mini') },
});

// 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-4o') },
});

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.

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:
    • Fresh runId (derived from parent's ID)
    • 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'] }

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.

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

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.

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.

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-4o-mini') },
});

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-4o') },
});

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, six companion tools are auto-injected into the parent agent (prefixed with companion__):

ToolDescription
companion__spawnAgentCreate and start a new persistent child. Args: { agent: string, initialMessage: string, name?: string }
companion__sendMessageSend a follow-up message to an active child. Args: { name: string, message: string }
companion__listChildrenList all persistent children and their current statuses
companion__getChildStatusGet detailed status of a specific child by name. Args: { name: string }
companion__waitForResultBlock until a specific child completes (only available when at least one blocking agent is configured). Args: { name: string }
companion__terminateChildTerminate a running child. Args: { name: string }

The parent's LLM decides when and how to use these tools based on the system prompt and conversation context.

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 completed, the old session is cleaned up and a new one starts fresh. The SubSessionRef is updated to point to the new session.

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' | 'terminated';
  startedAt: number;
  completedAt?: number;
  mode: 'ephemeral' | 'persistent'; // 'persistent' for companion-managed children
  name?: string; // The child's name
}

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 ID{parentSessionId}-sub-{callId}{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-4o-mini') },
});

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-4o') },
});

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

RuntimeBlockingNon-blockingNotes
JSYesYesIn-process execution
TemporalYesYesChild workflows with signal support
Cloudflare WorkflowsYesYesNested workflow instances
Cloudflare DOYesYesSibling DO instances

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

Set workspaceLifetime: 'persistent' on the persistentAgents entry to keep workspaces open across the child's lifetime. They 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 parent workspaces

Set inheritWorkspaces: true on a persistentAgents entry to share the parent's workspaces with the child. Same semantics as the ephemeral createSubAgentTool({ inheritWorkspaces: true }) flag — the child sees the parent's workspaces under their parent-side names AND can declare its own, which are layered on top via addEntries() (collisions throw).

typescript
persistentAgents: [
  {
    agent: ResearcherAgent,
    mode: 'blocking',
    inheritWorkspaces: true, // child shares the parent's WorkspaceRegistry
  },
],

When inheritWorkspaces: 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' — workspaces open at sub-agent spawn/resume and close at sub-agent exit. The 'persistent' lifetime (workspaces stay 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.

addEntries persistence semantics (round-5 D2)

When a sub-agent inherits AND declares its own additional workspaces, the framework calls registry.addEntries() to layer those workspaces onto the parent's registry. The added entries' persistRef callback is the SAME one bound to the parent's registry — so refs for the child's "own" workspaces are persisted under the PARENT's session ID, NOT the child's.

This matters when sub-agents need their workspace state isolated from the parent's session. A sub-agent inheriting a parent's registry and adding its own workspaces leaves refs in the parent's session state — visible to anyone with parent-session access regardless of which child created them.

Practical guidance. Use inheritWorkspaces: true for shared workspaces that are part of the parent's session (a working notes workspace, a shared cache). For sub-agent operations whose workspace state must be isolated from the parent's session, prefer fully-isolated workspaces declared on the child (no inheritance) so 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 inheritWorkspaces: 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 inheritWorkspaces: 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 inheritWorkspaces: true on either; declare each sub-agent's workspaces 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. See Sub-Agent Execution Internals for implementation details.

Next Steps

  • Remote Agents - Delegate to agents on separate HTTP services
  • Streaming - Handle sub-agent stream events
  • Interrupt and Resume - How interrupts propagate through sub-agent hierarchies
  • Runtimes - How different runtimes handle sub-agents
  • Examples - Real-world orchestration examples
  • Hooks - Observe sub-agent execution with beforeSubAgent and afterSubAgent hooks

Released under the MIT License.