Single-agent loops are fine for short conversations. Once an agent has to research, plan, draft, and review, you want specialisation: separate agents for separate jobs, each with its own context window so deep work doesn’t pollute the parent’s.

agentlib ships three dispatch modes that cover the common patterns.

The three modes

Sync. “Do this one thing and tell me the result.” Parent blocks until the subagent finishes.

final result = await SubagentRunner.runOne(
  parent: ctx,
  def: researcherDef,
  prompt: 'Research the latest on Vision Pro UX patterns.',
);

Parallel. “Do these N things at once; come back when all are done.” Each subagent runs in its own context, the parent gathers results once they’re all in.

final results = await SubagentRunner.runParallel(
  parent: ctx,
  defs: [researcherDef, drafterDef],
  prompts: ['research X', 'draft article about X'],
);

Background. “Start this; I’ll come back later.” Useful for indexing, long-running ingestion, or any work that shouldn’t block the user’s current interaction.

final handle = SubagentRunner.dispatch(
  parent: ctx,
  def: indexerDef,
  prompt: 'Index the user\'s notes folder.',
);
// ... user does other things ...
final result = await handle.await();
// or: handle.onComplete((result) { ... });

Why separate context windows

Two reasons. First, cost: subagent context doesn’t multiply into the parent. A research subagent can read 200 documents and the parent never sees them — only the subagent’s final structured summary. Second, clarity: the parent’s prompt stays focused on its job (“orchestrate the writing”), and each subagent’s prompt stays focused on its own.

Defining a subagent

A subagent is described by an AgentDefinition:

final researcherDef = AgentDefinition(
  name: 'researcher',
  description: 'Researches a topic deeply from the workspace.',
  instructions: '''
You are a research assistant. Given a topic, search the workspace for relevant
notes, read the top 5, and emit a JSON summary with key points and citations.
''',
  tools: ['fs.read', 'fs.grep', 'fs.glob'],   // allow-list — researcher can only read
  modelOverride: AnthropicProvider(apiKey: '<KEY>'), // use a stronger model for research
);

Key fields:

  • tools is an allow-list. Researcher gets fs read access; not write.
  • modelOverride lets you pick a different (often cheaper or stronger) model.
  • instructions is the subagent’s system prompt. Independent from the parent’s.
  • permissionMode can be ask, allow, or deny — fine-grained capability gating.

Dispatch from a model turn

Most subagent calls happen via the Task tool that the orchestrator agent has access to:

Sh("task dispatch researcher --prompt 'Vision Pro UX best practices' --mode parallel")

The model emits this; agentlib looks up the researcher definition; spawns it; waits (or returns immediately if mode is background); injects the result back as a ToolResult.

A research-and-draft example

final orchestrator = AgentSpec(
  name: 'writer',
  instructions: '''
You orchestrate writing. When a draft is requested:
1. Dispatch `researcher` in parallel with `outline`.
2. Wait for both to finish.
3. Dispatch `drafter` with the outline + research as input.
4. Return the draft.
''',
  model: AnthropicProvider(apiKey: '<KEY>'),
  subagents: [researcherDef, outlineDef, drafterDef],
);

The orchestrator’s system prompt explains the workflow. The subagent definitions live alongside.

Background work

Background dispatch is useful when:

  • Indexing: walk the user’s documents, build an embedding index. Don’t block the chat.
  • Periodic refresh: every hour, summarise new emails. The summary lives in /memory; the chat agent reads from it instantly.
  • Speculative work: while the user is typing the next prompt, prefetch a likely response.

Background subagents survive parent suspension. If the app gets backgrounded while a subagent is running, the subagent’s state is checkpointed; on OnResume, agentlib resumes it. If the OS kills the process entirely, the subagent picks up from its last snapshot.

Hooks for subagents

Three hooks fire around subagents:

  • SubagentStart — before dispatch. Log it, deny it, mutate the prompt.
  • SubagentMessage — each message the subagent emits. Useful for live progress UI in the parent.
  • SubagentStop — when the subagent finishes. Log the duration; check for errors.
hooks.register(SubagentStart((event, ctx) async {
  await analytics.log('subagent.start', { 'def': event.def.name });
  return HookOutcome.carryOn();
}));

When subagents are wrong

Subagents are heavyweight. Each one is a full agent loop. If a piece of work is procedural (do this then this then this), a skill is lighter. If it’s a one-shot tool call, just call the tool directly.

Reach for a subagent when:

  • The work needs its own model (cheaper, smaller, specialised).
  • The work needs its own context (deep reading, lots of intermediate state).
  • The work needs to run in the background.
  • The work is parallel with other work the orchestrator is doing.