Skip to content

Subagents ​

The idea ​

A subagent is a run. Not a special object, not a second engine — the same loop, the same table, the same persistence, with depth > 0 and a parentRunId.

That one decision buys everything else: a subagent can use tools, can park for a human, can be resumed after an approval, and shows up in the operational store next to every other run. Nothing had to be built twice.

Turning it on ​

Delegation is opt-in:

ts
export const chat = runtime.createStreamTextAgent({
  name: 'chat',
  model: 'gpt-4o',
  subagents: true,
});

The platform injects a scoped spawnSubagent tool. The model calls it with a name and instructions.

Giving children tools ​

ts
runtime.createStreamTextAgent({
  name: 'chat',
  model: 'gpt-4o',
  subagents: { tools: { sendEmail } },
  tools: { sendEmail },
});

Tools listed under subagents are merged into every child and wrapped exactly as the parent's are — so a child that calls a marked tool parks for approval, and is resumed where it stopped rather than restarted.

The built-in web tools work in children too: pass runtime.builtinTools([...]) under subagents.tools, and a researcher child searches and reads pages on its own, billed to the parent's run.

Named profiles ​

The default child is a generalist with one persona and the shared tools. When delegation should go to specialists — a page manager, a copywriter, an analyst — name them:

ts
subagents: {
  profiles: {
    'page-manager': {
      description: 'edits and reorders the pages of the site',
      systemFn: pageManagerPrompt,
      model: 'gpt-4o-mini',
      tools: pageTools,
      maxSteps: 20,
    },
    copywriter: { description: 'writes headings and body copy', system: 'You write clean copy.' },
  },
},
go
Subagents: &agentenkit.SubagentsConfig{
	Profiles: map[string]agentenkit.SubagentProfile{
		"page-manager": {
			Description: "edits and reorders the pages of the site",
			SystemFn:    pageManagerPrompt,
			Model:       "gpt-4o-mini",
			Tools:       pageTools,
			MaxSteps:    20,
		},
		"copywriter": {Description: "writes headings and body copy", System: "You write clean copy."},
	},
},

With profiles set, spawnSubagent's description lists the names and what each one does, and name must be one of them; an unknown name comes back to the model as a tool error, not a crash. The child takes the profile's persona (systemFn wins over system), model, tools and step cap. Everything else is unchanged: it is still a run, it still parks for approval, and it is re-entered under the same profile after one.

What a child gets ​

A child receives its brief, not the parent's transcript. Its turns are persisted in the same thread under its own agentId, so they are its transcript, not the conversation's. A UI that renders the main thread filters to agentId === null.

The result handed back to the parent is capped at subagentResultCapChars (default 8000).

Limits ​

SettingDefaultMeaning
subagentMaxDepth2How deep nesting may go
subagentMaxConcurrent3Children running at once per run, at each depth
subagentMaxSteps10Model round trips per child
subagentResultCapChars8000Characters returned to the parent

Depth 2 means a child may spawn a grandchild, and there it stops. Exceeding the cap is reported to the caller as a tool result, not raised as a crash.

The concurrency cap belongs to one run: another run's children never take its slots. And each depth has slots of its own. A parent keeps its slot while its child runs, so a single pool for every depth would let three children each wait for a slot only a finished child can free, and the run would never end. Waiting for a slot ends at once when the run is stopped.

Failure is a result, not a crash ​

A child that fails reports back to its parent instead of killing the run. A child whose model stream ended with no finish counts as failed too: its partial text is not a result. A child cut off by a user stop is recorded CANCELLED, and the stop ends the whole run. The parent's model sees a failed tool result and decides what to do — retry differently, work around it, or tell the user. That is usually what you want; an agent whose helper failed is not an agent that should stop existing.

The failure carries the reason, which matters more than it sounds: a model that invents a model name gets a clear "unknown model" rather than a silent disappearance.

The token ledger ​

A child's spend counts against the run's budget the moment it happens, through a ledger shared by reference. Attribution still records which agent spent what, so you can see the split — but the cap is enforced across the whole tree.

Watching them ​

Three run stream events describe a child's life: SUBAGENT_STARTED (subagentId, name, depth), SUBAGENT_EVENT (one of the child's own stream events, wrapped, with its subagentId) and SUBAGENT_FINISHED (status is completed, failed or cancelled, with an error when it has one). They travel on the parent run's stream. use-agentenkit turns them into a subagents array:

tsx
{subagents.map((s) => (
  <div key={s.agentId} data-depth={s.depth}>
    <strong>{s.name}</strong> — {s.status}
    {s.error && <span>{s.error}</span>}
    <pre>{s.text}</pre>
  </div>
))}

Those events live only as long as the run stream (streamGraceMs after the segment ends). On a finished thread the hook rebuilds each card from the durable run rows and the child's persisted turns instead, so a reload does not lose a subagent's output.