A Problem One Tool Can't Solve
Imagine this task: a comprehensive refactor of a large codebase — check naming conventions across every file, find all circular dependencies, clean up interface definitions. Over a thousand files.
You could hand this to a single Agent and let it process files one by one. But the problems appear quickly:
- Limited context window: by file 300, everything from the beginning has been pushed out
- Tools have no reasoning: a tool executes fixed logic — it can't adapt its strategy based on 'which subsystem does this file belong to?'
- Tools have no persistent conversation history: a tool finishes and that's it; no state, no memory of what it saw last time
The essence of multi-Agent systems is delegating subtasks to another Agent that has its own independent conversation history. A subagent has its own Session, its own reasoning process, its own tools, and its own context window. The parent Agent only needs to say: 'process this batch of files and tell me what you find.'
The difference from calling a tool is the difference between 'handing something off to another person' and 'starting a machine.'
dsh's Subagent Mechanism
dsh implements Agent delegation via Subagents. There are two ways to launch one:
-
Model-initiated: when the model decides to delegate a subtask, it calls the built-in tool
subagent_spawn -
Code-initiated: inside a tool's
executefunction, callctx.subagents.start()directly
Launching a subagent requires a SubagentStartRequest. The key fields:
// Simplified from SubagentStartRequest in packages/subagent/subagent/src/types.ts
// The actual type has more fields; these are the most important
interface SubagentStartRequest {
// The subagent's initial prompt (ContentBlock array — supports text, images, etc.)
prompt: ContentBlock[]
// The parent Agent object (provides working directory and lineage info)
parent: Agent
// Cancellation signal (lets the parent abort the subagent)
signal: AbortSignal
// Optional: restrict which tools the subagent can use
toolFilter?: ToolRestriction
// Optional: give the subagent a custom persona (overrides the global deployment persona)
persona?: string
// Optional: structured output schema (makes the subagent return a JSON object instead of text)
outputSchema?: ObjectJsonSchema
// Optional: max delegation depth (prevents infinite recursion via nested subagents)
maxDepth?: number
}
A few fields deserve attention:
toolFilter: by default a subagent inherits all of its parent's tools, but in most cases you want to give it a restricted set. A code-analysis subagent only needs to read files — not write them.
persona: a subagent can have its own system prompt prefix. You can give it a specialized role definition so it works within that role rather than inheriting the global persona.
outputSchema: instead of returning plain text, the subagent returns a structured JSON object. The parent Agent receives a type-safe result.structured.
One-Shot vs Continuable
Subagents have two lifecycle modes, distinguished by whether the parent needs to talk to the subagent more than once.
One-shot Subagent:
Launch → subagent completes all its work → returns result → done. One round trip, suitable for independent subtasks.
Continuable Subagent:
After launch, the subagent creates a persistent Session. The parent can keep sending it messages, and the subagent continues executing each time. Suitable for multi-turn collaborative work.
The two modes compared:
One-shot:
Parent ──start()──► Child executes full task ──result──► Parent
(subagent's lifecycle ends after completion)
Continuable:
Parent ──startContinuable()──────► Child Session created, waits for input
Parent ──sendMessage('batch 1')──► Child processes batch 1 ──result──► Parent
Parent ──sendMessage('batch 2')──► Child processes batch 2 ──result──► Parent
Parent ──sendMessage('summarize')► Child summarizes ──final result──► Parent
(Child Session stays alive throughout, with a continuous conversation history)
The advantage of a continuable subagent: its conversation history is unbroken. It remembers what it processed in earlier turns, and can draw on that memory when summarizing at the end.
Communication Paths: Who Can Message Whom
In a multi-Agent system, a natural question is: can Agents message each other freely?
The answer is no. dsh enforces explicit constraints on communication paths:
| Direction | Allowed? | Notes |
|---|---|---|
| Parent → Child | ✅ Yes | Requires the child's parentSession to point to the parent |
| Child → Parent | ✅ Yes | Subagents can message their parent |
| Sibling → Sibling | ❌ No | Two children of the same parent cannot message each other |
| Grandparent → Grandchild | ❌ No | Cross-generation messaging is rejected |
Parent
/ \
Child A Child B
|
Grandchild
✅ Parent ──► Child A
✅ Child A ──► Parent
❌ Child A ──► Child B (sibling messaging rejected)
❌ Parent ──► Grandchild (cross-generation rejected)
This is a deliberate constraint. Allowing arbitrary messaging turns the Agent network into an untraceable message graph — when debugging, you'd have no idea where a message came from. A clear hierarchy means clear responsibility boundaries.
toolFilter and persona
Tool Filtering
Giving a subagent only the tools it actually needs is the most important access-control mechanism in a multi-Agent system:
// Subagent can only use these three tools.
// Everything else the parent has (write_file, execute_shell, etc.) is invisible to it.
toolFilter: {
allow: ['read_file', 'search_files', 'list_files'],
// You can also use deny to exclude specific tools:
// deny: ['write_file', 'execute_shell']
}
Why bother? It's not only about security — it's about task focus. A code-analysis subagent with write_file in its tool list might 'helpfully' modify code it thinks is wrong. That's not the behavior you want. Give it read-only tools and it can only read.
Persona
// Subagent uses a custom system prompt prefix.
// This overrides the global deployment persona,
// so the subagent operates within a specialized role.
persona: 'You are a specialized test writer. Focus only on writing unit tests for the given code. Do not modify existing source files, do not suggest refactoring.'
The point of persona: the parent Agent can be a general-purpose assistant, while the subagents it spawns are laser-focused experts. Each subagent gets a freshly defined role, unaffected by global configuration.
Structured Output (outputSchema)
Subagents return text by default. But in automated pipelines, the parent Agent usually needs data it can process directly — not a paragraph of natural language.
outputSchema solves this:
// Make the subagent return structured JSON — no text parsing needed (pseudocode)
const result = await ctx.subagents.start({
prompt: [{ type: 'text', text: 'Analyze this code and find all bugs.' }],
parent: currentAgent,
signal,
outputSchema: {
type: 'object',
properties: {
bugs: {
type: 'array',
items: {
type: 'object',
properties: {
file: { type: 'string' },
line: { type: 'number' },
severity: { type: 'string', enum: ['critical', 'major', 'minor'] },
description: { type: 'string' },
},
},
},
},
},
})
// result.text is the subagent's text output (if any)
// result.structured is a type-safe JSON object — use it directly, no parsing
console.log(result.structured.bugs)
// → [{ file: 'auth.ts', line: 42, severity: 'critical', description: '...' }, ...]
This keeps data flow clean across the multi-Agent system: the subagent's analysis becomes the parent Agent's decision input directly, with no extra parsing layer in between.
Six Subagent Providers
dsh supports multiple underlying implementations for different deployment scenarios:
| Provider | Description | Best For |
|---|---|---|
spawn-in-process |
Creates a new Agent instance in the same process | Local development, testing |
fork-in-process |
Forks the current Session; subagent inherits the prefix history | Subtasks that need to inherit context |
dsh-sdk |
Launches an isolated runtime via the dsh SDK | Subagents that need full isolation |
acp |
Communicates with remote Agents via the ACP protocol | Remote Agent clusters |
codex |
Calls a Codex Agent | Code-specific tasks |
claude-code |
Calls Claude Code | Code-specific tasks |
For most local development, spawn-in-process is sufficient. When you need isolation — say, different subagents with different filesystem permissions — consider dsh-sdk.
Experimental: Agent Teams
Subagents follow a hierarchical model — parent-child relationships are explicit, communication paths are strict. But some collaboration patterns are inherently 'flat': multiple specialist Agents working on the same task in parallel, each reporting results as they finish.
Agent Teams is dsh's work-in-progress multi-Agent collaboration framework, accessible via ctx.agentTeams (experimental service):
- roster: register team members, each with a name and role
- mailbox: members send and receive messages via their mailbox
- task board: a shared task list the team can claim and submit work against
- coordinator mode: a coordinator Agent can distribute tasks and collect results
Compared to the Subagent hierarchy, Teams is closer to actual teamwork: no strict parent-child structure, members can communicate as peers, and tasks can be assigned dynamically.
Subagent model (hierarchical): Agent Teams model (peer-to-peer):
Orchestrator ┌──────────────────┐
/ | \ │ task board │
Sub A Sub B Sub C └──────────────────┘
(strict parent-child, Agent A ◄──► Agent B
one-way delegation) │ │
└────► Agent C ◄┘
(peer messaging, dynamic collaboration)
Note: Agent Teams is currently experimental. The API may change in future releases. Confirm version stability before using in production.
Hands-On: Parent Agent Calling a Subagent
Here's a complete pattern: a parent Agent defines a tool that internally launches a subagent to perform a specialized analysis task.
// Pseudocode: parent Agent tool that delegates to a subagent for code analysis
const analyzeCodebaseTool = defineTool({
name: 'analyze_codebase',
description: 'Spawn a specialized subagent to analyze a given directory.',
parameters: {
directory: { type: 'string', required: true, description: 'Directory to analyze' },
focus: { type: 'string', required: true, description: 'Analysis focus (e.g., naming conventions, circular dependencies)' },
},
async execute(args, exec) {
// Launch the subagent (pseudocode — actual call is ctx.subagents.start())
const result = await ctx.subagents.start({
prompt: [{
type: 'text',
text: `Analyze the directory ${args.directory}. Focus on: ${args.focus}. Check each file and produce a structured issue list.`,
}],
parent: exec.agent, // pass the parent Agent to establish lineage
signal: exec.signal, // pass cancellation signal — if parent is cancelled, subagent stops too
// Subagent can only read files, not modify code
toolFilter: {
allow: ['read_file', 'list_files', 'search_files'],
},
// Specialized persona: code review expert
persona: 'You are a code review expert. Read code carefully, identify issues, and provide a structured summary. Do not modify any files.',
// Require structured JSON output
outputSchema: {
type: 'object',
properties: {
issues: {
type: 'array',
items: {
type: 'object',
properties: {
file: { type: 'string' },
issue: { type: 'string' },
severity: { type: 'string', enum: ['high', 'medium', 'low'] },
},
},
},
summary: { type: 'string' },
},
},
})
// result.structured is type-safe JSON — use it directly
const { issues, summary } = result.structured
return `Analysis complete. Found ${issues.length} issue(s).\n${summary}`
},
})
The core value of this pattern: the parent Agent doesn't need to know how the subagent analyzes code — it just receives structured results and makes its next decision.
Comparison with Other Frameworks
| Framework | Multi-Agent Model | Key Difference from dsh |
|---|---|---|
| Claude Code Agent tool | Also spawns subagents | Simpler interface, but no toolFilter / persona / outputSchema
|
| LangGraph | Graph-based workflow, fixed topology | dsh Subagents are dynamic — the model decides when to delegate at runtime, not via a predefined graph |
| AutoGen | Conversational multi-Agent, Agents message each other directly | dsh is hierarchical with explicit parent-child relationships; sibling Agents cannot communicate directly |
In short: LangGraph suits scenarios with fixed, predictable flows; dsh Subagents suit scenarios where the flow is determined dynamically by the model.
What's Next in the Series
The next article covers observability: how do you know what an Agent did while it was running? What mechanisms does dsh provide for tracing tool calls, Token usage, and subagent lineage trees — and how do you hook into a logging system in production?
Check out PrimeSkills — a curated marketplace of AI agents and skills validated in real-world, enterprise-grade workflows. Not demos — things that actually work in production.
Find more on my Homepage
Top comments (0)