Sub-Agent Orchestration
Sub-agents enable hierarchical agent systems where a parent agent can delegate specialized tasks to child agents. This is powerful for complex workflows that benefit from separation of concerns.
What Are Sub-Agents?
A sub-agent is an agent invoked by another agent as if it were a tool. The parent agent decides when to delegate, the sub-agent executes to completion, and the result flows back to the parent.
graph TB
Parent["Parent Agent"]
Parent --> Tools["Uses regular tools"]
Parent --> SubAgent["Delegates to Sub-Agent"]
SubAgent --> Run["Sub-agent runs to completion"]
SubAgent --> Result["Result returned to parent"]Creating Sub-Agent Tools
Use createSubAgentTool() to turn an agent into a tool:
import { defineAgent, createSubAgentTool } from '@helix-agents/sdk';
import { z } from 'zod';
// Define a specialized agent
const AnalyzerAgent = defineAgent({
name: 'text-analyzer',
description: 'Analyzes text for sentiment and topics',
systemPrompt: 'You analyze text. Determine sentiment and extract key topics.',
outputSchema: z.object({
sentiment: z.enum(['positive', 'negative', 'neutral']),
confidence: z.number(),
topics: z.array(z.string()),
}),
llmConfig: { model: openai('gpt-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:
// ✓ 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:
- Tool Call Detection - Framework identifies the tool as a sub-agent (prefixed with
subagent__) - Sub-Agent Initialization - New agent run created with:
- Fresh
runId(derived from parent's ID) - Same
streamIdas parent (for unified streaming) - Input converted to user message
- Fresh
- Execution - Sub-agent runs its full loop until completion
- Result Return - Sub-agent's
outputbecomes the tool result
// Parent LLM calls:
{
name: 'subagent__text-analyzer',
arguments: { text: 'This product is amazing!' }
}
// Framework:
// 1. Creates sub-agent run
// 2. Converts input to message: "This product is amazing!"
// 3. Runs AnalyzerAgent loop
// 4. Returns output: { sentiment: 'positive', confidence: 0.95, topics: ['product'] }Input Mapping
The input schema defines what arguments the parent provides. These are converted to a user message for the sub-agent:
const subAgentTool = createSubAgentTool(
SubAgent,
z.object({
query: z.string(), // Common field names are recognized
context: z.string().optional(),
})
);
// When called with { query: "analyze this", context: "..." }
// Sub-agent receives user message: "analyze this"Recognized input fields (in priority order):
messagequerytextcontent- 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:
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:
const ParentAgent = defineAgent({
name: 'parent',
stateSchema: z.object({
parentCounter: z.number().default(0),
}),
// ...
});
const SubAgent = defineAgent({
name: 'child',
stateSchema: z.object({
childCounter: z.number().default(0), // Separate from parent
}),
// ...
});Key points:
- Sub-agent cannot read parent's custom state
- Parent cannot read sub-agent's custom state
- Each has its own
messages,stepCount, etc. - Sub-agent output is the only communication channel
To share data, pass it through the input and receive it in the output.
Error Handling
Sub-Agent Failures
If a sub-agent fails, the error becomes the tool result:
// Sub-agent throws error
throw new Error('Analysis failed: text too short');
// Parent receives tool result:
{
success: false,
error: 'Analysis failed: text too short'
}The parent LLM sees the error and can decide how to proceed (retry, try different approach, etc.).
Handling Errors
Check for failures in parent's tools or logic:
const processResultTool = defineTool({
name: 'process_analysis',
execute: async (input, context) => {
// The sub-agent result may have succeeded or failed
if (!input.analysisResult.success) {
// Handle sub-agent failure
return {
processed: false,
reason: input.analysisResult.error,
};
}
// Process successful result
const analysis = input.analysisResult.result;
// ...
},
});Nested Sub-Agents
Sub-agents can themselves have sub-agents:
// Level 3: Leaf agent
const SentimentAnalyzer = defineAgent({
name: 'sentiment',
outputSchema: z.object({ sentiment: z.string() }),
// ...
});
// Level 2: Uses sentiment analyzer
const TextProcessor = defineAgent({
name: 'processor',
tools: [createSubAgentTool(SentimentAnalyzer /* ... */)],
outputSchema: z.object({ processed: z.string() }),
// ...
});
// Level 1: Uses text processor
const Orchestrator = defineAgent({
name: 'orchestrator',
tools: [createSubAgentTool(TextProcessor /* ... */)],
// ...
});Stream events include all levels:
[orchestrator] text_delta: "Let me analyze..."
[orchestrator] subagent_start: processor
[processor] text_delta: "Processing..."
[processor] subagent_start: sentiment
[sentiment] text_delta: "Analyzing..."
[sentiment] output: { sentiment: "positive" }
[processor] subagent_end: sentiment
[processor] output: { processed: "..." }
[orchestrator] subagent_end: processor
[orchestrator] text_delta: "Based on the analysis..."Patterns
Specialist Pattern
Delegate specific tasks to specialists:
const ResearchAgent = defineAgent({
name: 'researcher',
tools: [
searchTool,
createSubAgentTool(FactCheckerAgent /* ... */),
createSubAgentTool(SummarizerAgent /* ... */),
],
systemPrompt: `You are a research coordinator.
1. Search for information
2. Send claims to the fact-checker
3. Send findings to the summarizer
4. Compile final report`,
});Pipeline Pattern
Chain agents in a processing pipeline:
// Each agent processes and passes to next
const ExtractorAgent = defineAgent({
name: 'extractor',
outputSchema: z.object({ entities: z.array(z.string()) }),
});
const EnricherAgent = defineAgent({
name: 'enricher',
outputSchema: z.object({
enrichedEntities: z.array(
z.object({
/* ... */
})
),
}),
});
const FormatterAgent = defineAgent({
name: 'formatter',
outputSchema: z.object({ formatted: z.string() }),
});
// Coordinator runs the pipeline
const PipelineAgent = defineAgent({
name: 'pipeline',
tools: [
createSubAgentTool(ExtractorAgent, z.object({ text: z.string() })),
createSubAgentTool(EnricherAgent, z.object({ entities: z.array(z.string()) })),
createSubAgentTool(FormatterAgent, z.object({ data: z.unknown() })),
],
systemPrompt: `Process text through the pipeline:
1. Extract entities
2. Enrich each entity
3. Format the output`,
});Parallel Delegation Pattern
Delegate multiple tasks simultaneously:
const MultiAnalyzerAgent = defineAgent({
name: 'multi-analyzer',
tools: [
createSubAgentTool(SentimentAgent, z.object({ text: z.string() })),
createSubAgentTool(TopicAgent, z.object({ text: z.string() })),
createSubAgentTool(EntityAgent, z.object({ text: z.string() })),
],
systemPrompt: `Analyze text from multiple angles.
You can run multiple analyses in parallel.
Combine results into a comprehensive report.`,
});The framework executes parallel tool calls concurrently when the LLM requests them.
Conditional Delegation Pattern
Delegate based on input characteristics:
const RouterAgent = defineAgent({
name: 'router',
tools: [
createSubAgentTool(SimpleQAAgent, z.object({ question: z.string() }), {
description: 'For simple factual questions',
}),
createSubAgentTool(ResearchAgent, z.object({ topic: z.string() }), {
description: 'For topics requiring deep research',
}),
createSubAgentTool(MathAgent, z.object({ problem: z.string() }), {
description: 'For mathematical calculations',
}),
],
systemPrompt: `Route questions to the appropriate specialist:
- Simple facts → SimpleQA
- Complex topics → Research
- Math problems → Math
Choose the best agent for each request.`,
});Remote Sub-Agents
For agents running on a separate HTTP service, use createRemoteSubAgentTool() instead of createSubAgentTool(). This enables cross-service and cross-runtime delegation via HTTP + SSE.
import {
defineAgent,
createRemoteSubAgentTool,
HttpRemoteAgentTransport,
} from '@helix-agents/core';
import { z } from 'zod';
const transport = new HttpRemoteAgentTransport({
url: 'http://localhost:4000',
});
const researcherTool = createRemoteSubAgentTool('researcher', {
description: 'Delegate research to a remote specialist agent',
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({
findings: z.array(z.object({ title: z.string(), snippet: z.string() })),
}),
transport,
remoteAgentType: 'researcher',
timeoutMs: 120_000,
});
const OrchestratorAgent = defineAgent({
name: 'orchestrator',
tools: [researcherTool], // Works like any other tool
// ...
});Remote sub-agents stream events using the same subagent_start/subagent_end protocol as local sub-agents, so frontends don't need to distinguish between them.
For the full guide — including server setup, transport configuration, and production considerations — see Remote Agents.
Persistent Sub-Agents
Overview
Persistent sub-agents are long-lived child agents that maintain state across multiple interactions. Unlike ephemeral sub-agents (created with createSubAgentTool()), persistent children can receive follow-up messages and be managed throughout the parent's lifecycle.
Configure persistent sub-agents via the persistentAgents field on AgentConfig:
import { defineAgent } from '@helix-agents/core';
import { z } from 'zod';
const ResearcherAgent = defineAgent({
name: 'researcher',
systemPrompt: 'You research topics.',
outputSchema: z.object({ findings: z.string() }),
llmConfig: { model: openai('gpt-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.
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__):
| Tool | Description |
|---|---|
companion__spawnAgent | Create and start a new persistent child. Args: { agent: string, initialMessage: string, name?: string } |
companion__sendMessage | Send a follow-up message to an active child. Args: { name: string, message: string } |
companion__listChildren | List all persistent children and their current statuses |
companion__getChildStatus | Get detailed status of a specific child by name. Args: { name: string } |
companion__waitForResult | Block until a specific child completes (only available when at least one blocking agent is configured). Args: { name: string } |
companion__terminateChild | Terminate 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).
// 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':
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
| Feature | Ephemeral (createSubAgentTool) | Persistent (persistentAgents) |
|---|---|---|
| Created by | Parent's tool call to subagent__ | companion__spawnAgent tool |
| Lifecycle | Runs to completion, result returned | Long-lived, can receive messages |
| Follow-up messages | Not supported | Via companion__sendMessage |
| Result access | Immediate (tool result) | Via companion__getChildStatus or companion__waitForResult |
| Naming | Auto-generated | Explicit or auto-incremented |
| Mode | Always blocking | Blocking or non-blocking |
| Session ID | {parentSessionId}-sub-{callId} | {parentSessionId}-agent-{name} |
| SubSessionRef mode | 'ephemeral' | 'persistent' |
Example: Research Coordinator
const ResearcherAgent = defineAgent({
name: 'researcher',
systemPrompt: 'You research topics thoroughly and return findings.',
outputSchema: z.object({
findings: z.string(),
sources: z.array(z.string()),
}),
tools: [searchTool],
llmConfig: { model: openai('gpt-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:
- Spawn
researcher-1for topic A - Spawn
researcher-2for topic B - Check status or wait for results
- Terminate if needed
- Compile final output
Runtime Support
Persistent sub-agents work across all runtimes:
| Runtime | Blocking | Non-blocking | Notes |
|---|---|---|---|
| JS | Yes | Yes | In-process execution |
| Temporal | Yes | Yes | Child workflows with signal support |
| Cloudflare Workflows | Yes | Yes | Nested workflow instances |
| Cloudflare DO | Yes | Yes | Sibling DO instances |
Best Practices
1. Clear Output Schemas
Define precise output schemas for clear contracts:
// Good: Specific schema
const agent = defineAgent({
outputSchema: z.object({
sentiment: z.enum(['positive', 'negative', 'neutral']),
confidence: z.number().min(0).max(1),
reasoning: z.string(),
}),
});
// Avoid: Vague schema
const agent = defineAgent({
outputSchema: z.object({
result: z.unknown(), // What is this?
}),
});2. Descriptive Sub-Agent Tools
Help the parent LLM choose correctly:
const tool = createSubAgentTool(AnalyzerAgent, z.object({ text: z.string() }), {
description: `Analyze text for sentiment and extract key topics.
Use when you need:
- Sentiment classification (positive/negative/neutral)
- Topic extraction from text
- Confidence scores for analysis
Returns: { sentiment, confidence, topics }`,
});3. Appropriate Granularity
Balance specialization vs. overhead:
// Good: Meaningful specialization
const FactCheckerAgent = defineAgent({
/* verifies claims */
});
const SummarizerAgent = defineAgent({
/* creates summaries */
});
// Avoid: Over-granular
const CapitalizerAgent = defineAgent({
/* just capitalizes text */
});
// ^ This is better as a regular tool or string method4. Handle Sub-Agent Limits
Set appropriate maxSteps for sub-agents, and use timeoutMs to enforce wall-clock limits:
const SubAgent = defineAgent({
name: 'focused-task',
maxSteps: 5, // Sub-agents should complete quickly
// ...
});
const subAgentTool = createSubAgentTool(SubAgent, inputSchema, {
timeoutMs: 30_000, // 30-second wall-clock limit (important in DO runtime)
});5. Test Sub-Agents Independently
Sub-agents are full agents - test them alone first:
// Test sub-agent directly
const subHandle = await executor.execute(AnalyzerAgent, 'Test text');
const subResult = await subHandle.result();
expect(subResult.status).toBe('completed');
// Then test in orchestration
const parentHandle = await executor.execute(OrchestratorAgent, 'Analyze this');
const parentResult = await parentHandle.result();Limitations
No Shared State
Sub-agents cannot access parent state. Design inputs/outputs to carry needed context:
// Pass context through input
const tool = createSubAgentTool(
SubAgent,
z.object({
query: z.string(),
context: z.object({
previousFindings: z.array(z.string()),
constraints: z.array(z.string()),
}),
})
);Sequential by Default
Multiple sub-agent calls in one LLM response may execute in parallel, but the parent waits for all before continuing.
Overhead
Each sub-agent invocation includes:
- State initialization
- Full agent loop (potentially multiple LLM calls)
- State persistence
For simple transformations, prefer regular tools.
Lifecycle Hook Guarantees
Sub-agents fire their own lifecycle hooks (onAgentStart, onAgentComplete, onAgentFail) independently from the parent agent. This is important for tracing integrations that need to emit spans for each sub-agent. The parent's stream is not closed when a sub-agent completes — only its hooks fire. This behavior is consistent across all runtimes. 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
beforeSubAgentandafterSubAgenthooks