Sub-agents, plan mode & todos
Delegate scoped work to child agents, and the structured plan/todo state tools.
On this page
Sub-agents
Real tasks are often too wide for one context window — "find every caller of this
function across the repo," "investigate why this test flakes," "read these five files
and summarize the relevant parts." Arcturn's subagent tool, from createSubagentTool in
@arcturn/core, lets the model delegate a self-contained piece of work to a scoped
child Agent with its own context window, tools, and model.
import { createSubagentTool } from "@arcturn/core";
const subagentTool = createSubagentTool({
factory: (task) =>
createAgent({
llm,
model: requireModel("anthropic/claude-haiku-4-5"), // cheaper model for delegated work
systemPrompt: "You are a focused research sub-agent. Be thorough but concise.",
tools: readOnlyTools, // give children a narrower tool set than the parent
cwd: agent.cwd,
permissions: { mode: "default" },
}),
});
agent.setTools([...tools, subagentTool]);The model calls it with a task (the complete instructions — a sub-agent cannot ask
follow-up questions, so state the task and the expected shape of the answer in full) and
an optional description for progress display.
What happens under the hood:
- The parent emits
subagentStart { agentId, task }. - The child's entire event stream re-publishes on the parent as
subagentEvent { agentId, event }— a UI can render nested activity (the child's own tool calls, permission asks, even its own sub-agents) without knowing anything about what a sub-agent is. - Aborting the parent's run cascades: the child is aborted too.
- When the child finishes, the parent emits
subagentEnd { agentId, resultText, isError }and the tool call resolves with the child's final assistant text as its result.
Give every sub-agent its own tool instances — bindable tools (like subagent, todo,
and plan itself) hold a reference to whichever agent bound them last, so sharing one
instance between a parent and child causes state to leak across them.
Named agents from markdown
Beyond the ad-hoc factory above, Arcturn discovers named agent specializations declaratively
from .md files, the same pattern as skills. loadAgentDefs (from
packages/cli/src/agents.ts) scans two roots in order — ~/.arcturn/agents then
<cwd>/.arcturn/agents — and a project agent silently wins a name collision with a user
agent (reported as a warning naming both files).
---
name: doc-reviewer
description: Reviews documentation for accuracy against source code
tools: read, grep, glob
model: anthropic/claude-haiku-4-5
---
You are a documentation accuracy reviewer. For every claim in the document, find the
corresponding source location and confirm it. Flag anything unverifiable or stale.Recognized frontmatter, all optional:
| Key | Meaning |
|---|---|
name |
Defaults to the filename stem, normalized to [a-z0-9-]. |
description |
One-line summary shown to the model when it's choosing which agent to delegate to. |
tools |
A single comma-separated line, e.g. tools: read, grep, glob. An unknown tool name (checked against the runtime's known tool set) is dropped with a warning rather than rejecting the whole file. |
model |
A model id, passed through uninterpreted — agents.ts itself never resolves it against the catalog. |
Everything after the frontmatter is the system prompt verbatim — no $ARGUMENTS-style
substitution, since a sub-agent's whole instructions come from the parent's task
argument at call time, not from typed arguments. An empty body is a malformed file and is
skipped with a warning, same as an empty skill body.
When the model calls subagent with an agent field, the parent resolves the name against
these loaded definitions (runtime.ts's createSubagent(task, def)). The agent parameter
is only advertised to the model at all when at least one named agent is loaded — offering a
free-text field with nothing valid to put in it just invites the model to invent a plausible
name and fail the delegation.
Precedence, narrowing, and inheritance
Three things a named agent's frontmatter controls, and exactly how each interacts with the parent:
- Tools narrow, never widen. A non-
yoloparent restricts children to an investigative tool set (read-only tools plusfetch, which still prompts through the parent) regardless of what the agent'stools:list says — delegating to a named agent can never be used to slip past a non-yoloparent's read-only restriction. Atools:list is intersected with what the permission mode already allows, so it can only shrink the set further. - Model precedence is agent → route → main. The named agent's own
model:wins if set (resolved viaresolveModelSpec); otherwise the child getsrouter.specFor("subagent")— thesubagentroute described in Model providers, which itself falls back to the main model if nosubagentroute is configured. - Permissions are inherited, not reset. The child's permission rules are a copy of the
parent's configured rules (
[...this.config.permissions]), and its mode isyoloonly if the parent's is — otherwisedefault. A deny the user configured on the parent cannot be sidestepped by delegating the same work to a sub-agent.
Everything else about a child follows the same shape as the ad-hoc case above:
maxTurns defaults to SUBAGENT_MAX_TURNS (64), overridable via subagentMaxTurns in
config; the child shares the parent's SessionStore and working directory; and its cost
folds into the parent's running total the moment its turnEnd events arrive (via
calculateCostUsd), so /cost on the parent reflects everything its sub-agents spent too.
Plan mode and todos
Two more state tools ship in @arcturn/core, both pure — they never touch the outside
world, only agent state, and both persist as a state entry in the session tree.
todo
The model sends the complete todo list on every call — the list you send replaces
the stored one, not merges with it — with at most one item inProgress at a time:
{ "todos": [
{ "text": "Add token bucket limiter", "status": "done" },
{ "text": "Write unit tests", "status": "inProgress" },
{ "text": "Update docs", "status": "pending" }
]}Every call emits a todoUpdate event with the new list, so a UI can render live
progress without polling.
plan
In any mode other than plan, calling plan just records the plan and emits
planUpdate. In plan mode specifically, it's the only way out: the tool asks the
configured permission requester directly (bypassing stored rules — a rule can't
pre-approve leaving plan mode) to show the user the plan text and get an explicit
approve/reject. On approval, the agent's permission mode switches to whatever
approvedMode was configured (default unless you override it) and execution tools
become available; on rejection, the agent stays in plan mode with the user's feedback
folded back into the conversation so it can revise.
import { createPlanTool, createTodoTool } from "@arcturn/core";
const tools = [
...createDefaultTools({ cwd }).tools,
createTodoTool(),
createPlanTool({ approvedMode: "acceptEdits" }),
];
agent.setPermissionMode("plan"); // start in plan mode — no mutation until a plan is approvedSee Permissions for exactly how plan mode's enforcement (denying
every mutating tool) interacts with the plan tool's approval gate.