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: "analyze this"

Recognized input fields (in priority order):

  1. message
  2. query
  3. text
  4. content
  5. Falls back to JSON.stringify(input)

For complex inputs, the sub-agent's system prompt should explain how to interpret them.

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:

  • subagent_start - When sub-agent begins
  • All sub-agent's chunks (text_delta, tool_start, tool_end, etc.)
  • subagent_end - When sub-agent completes

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

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.