- Book: AI That Plans
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Once an agent works, the obvious next move is more agents. A researcher, a
writer, a reviewer, each with its own prompt and tools.
Sometimes that is right. Often it is a tool call wearing a costume — you have
turned a function into a whole extra model loop with its own turns, its own
budget, and its own opportunity to get lost.
The question worth asking is narrow: what does a sub-agent buy that a tool
does not?
What it actually buys: a separate context window
That is the whole thing. A sub-agent has its own message history, so:
- work that produces a lot of intermediate text does not pollute the parent
- the parent receives a summary, not a transcript
- the sub-agent gets a focused system prompt and a small tool list
A tool call, by contrast, puts its entire result into the parent's context,
where it is resent on every subsequent turn.
That framing gives a usable rule. If the work produces far more intermediate
material than final answer, isolation pays. If the result is small and the
work is deterministic, it is a tool.
Three cases where it pays
Search-and-distil. Twelve documents read, one paragraph returned. Inline,
those twelve documents live in the parent's window for the rest of the run and
you pay for them on every turn.
Independent parallel work. Three investigations that do not need each
other's intermediate state. Separate contexts let them run concurrently
without interleaving into one confusing history.
A genuinely different posture. A critic that must not see the author's
reasoning, or a checker deliberately given only the claim and the source. The
isolation is the feature — it is what makes the second opinion independent.
Four cases where it does not
Deterministic work. Parsing, formatting, validating, calling an API with
known parameters. That is a function. A model in the middle adds latency, cost
and variance for nothing.
A single tool call with a wrapper prompt. If the sub-agent's job is "call
get_order and tell me the status", you have built an expensive alias.
Work needing the parent's full context. If you find yourself passing most
of the conversation into the sub-agent, isolation is not happening and you are
paying twice for the same tokens.
Anything on the latency path. A sub-agent is a full loop: several model
calls, sequential. For an interactive feature that is seconds you cannot hide.
The boundary, typed
A sub-agent should look like a tool from the parent's side. Same interface,
different implementation.
export type SubAgentSpec<S extends z.ZodType, R extends z.ZodType> = {
name: string;
description: string;
input: S;
output: R; // the contract that keeps the summary small
system: string;
tools: readonly string[]; // narrower than the parent's
limits: { maxTurns: number; maxCostUsd: number };
};
export function asTool<S extends z.ZodType, R extends z.ZodType>(
spec: SubAgentSpec<S, R>,
) {
return {
name: spec.name,
description: spec.description,
schema: spec.input,
async run(args: z.infer<S>, ctx: Ctx) {
const child = childCtx(ctx, spec);
const out = await runAgent(render(spec, args), child, spec.limits);
return spec.output.parse(out.structured); // small, typed, validated
},
};
}
output is the part that makes this work. Without a schema the sub-agent
returns prose of arbitrary length and the isolation you paid for leaks
straight back into the parent's window.
const researcher = asTool({
name: "research_topic",
description:
"Investigate a topic across internal docs and return findings. " +
"Use for open questions needing several sources. Not for a single lookup.",
input: z.object({ question: z.string().min(10) }),
output: z.object({
findings: z.array(z.object({
claim: z.string().max(300),
sourceIds: z.array(z.string()).min(1),
})).max(6),
gaps: z.array(z.string()).max(3),
}),
system: RESEARCHER_PROMPT,
tools: ["search_docs", "fetch_doc"],
limits: { maxTurns: 8, maxCostUsd: 0.20 },
});
.max(6) and .max(300) are not decoration — they are the enforcement of
"the parent gets a summary."
Inherit budget and capabilities, never widen
Two invariants stop sub-agents becoming an escape hatch:
function childCtx(parent: Ctx, spec: SubAgentSpec<any, any>): Ctx {
const caps = parent.caps.filter((c) => spec.tools.includes(capToTool(c)));
return {
...parent,
caps, // subset, never a superset
budget: parent.budget.child(spec.limits.maxCostUsd),
depth: parent.depth + 1,
runId: `${parent.runId}.${spec.name}`,
};
}
Capabilities narrow. The budget is a child of the parent's, so a sub-agent
spending 20 cents leaves the parent 20 cents poorer, not a fresh allowance:
child(limitUsd: number): Budget {
const cap = Math.min(limitUsd, this.remaining);
const b = new Budget(cap, this.maxCalls);
b.onSpend = (usd) => this.record(usd); // charges flow upward
return b;
}
Without that, three sub-agents each with "a small budget" is a run with no
ceiling at all.
And a depth cap, because a sub-agent that can spawn sub-agents will:
if (parent.depth >= 2) throw new TooDeep(parent.depth);
Two levels covers every legitimate case I have seen. Deeper is almost always a
missing tool.
Parallel, with the failure mode handled
const results = await Promise.allSettled(
topics.map((t) => limit(() => researcher.run({ question: t }, ctx))),
);
const findings = results.flatMap((r) =>
r.status === "fulfilled" ? r.value.findings : []);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed) {
parentMessages.push({ role: "user", content:
`${failed} of ${topics.length} investigations failed. Continue with what you have.` });
}
allSettled rather than all — one sub-agent hitting its turn cap should not
discard two that succeeded. And telling the parent how many failed is what
stops it presenting a partial answer as complete.
Bound the concurrency: three sub-agents at eight turns each is up to
twenty-four model calls in flight.
Observability needs the tree
logger.info("subagent finished", {
parentRunId: ctx.runId,
spec: spec.name,
turns: out.turns,
costUsd: out.costUsd,
outcome: out.status,
});
Composing runId as parent.child means a single log query reconstructs the
tree. Without it, sub-agent calls appear as unrelated runs and "why did this
request cost 40 cents" becomes unanswerable.
The metric worth watching is sub-agent cost as a share of run cost. Above
half, and the parent has become a router, which is fine if deliberate, and a
sign of over-decomposition if not.
The test before you spawn
Write the sub-agent's job as a function signature first.
research(question: string): Promise<{ findings: Finding[]; gaps: string[] }>
If you can implement that with a search call and some filtering, do that. If
it genuinely requires reading, judging, following leads, and deciding when to
stop — then it needs a loop, and a loop needs its own context.
Most "sub-agents" fail that test. The ones that pass are usually search-and-
distil, and they are worth every cent.
If this was useful
AI That Plans covers multi-agent
structure — when decomposition helps, supervisor and worker topologies, budget
and capability inheritance, and keeping the whole tree observable.
The full series is at
xgabriel.com/ai-in-typescript.



Top comments (0)