DEV Community

Cover image for Sub-Agents in TypeScript: When to Spawn One, When to Inline It
Gabriel Anhaia
Gabriel Anhaia

Posted on

Sub-Agents in TypeScript: When to Spawn One, When to Inline It


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.

A sub-agent absorbing twelve documents and returning one paragraph, versus a<br>
tool result entering the parent's<br>
window.

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
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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 },
});
Enter fullscreen mode Exit fullscreen mode

.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}`,
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Two levels covers every legitimate case I have seen. Deeper is almost always a
missing tool.

Child budgets drawing from the parent's remaining allowance rather than<br>
resetting<br>
it.

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.` });
}
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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[] }>
Enter fullscreen mode Exit fullscreen mode

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.

AI That Plans — Stateful AI Agents with LangGraph.js

The full series is at
xgabriel.com/ai-in-typescript.

Top comments (0)