Skip to content

Sub-agents & teams

toolnexus already treats every tool source as the same thing to an LLM. Sub-agents complete that axiom locally: an Agent is a Tool — a system prompt × a filtered toolkit view × the shipped client loop, invocable as a named, described, schema’d callable. One agent can delegate to another in-process: isolated context, one result back, tokens rolled up.

Define agents declaratively. does is the routing description a delegating model sees; uses scopes the child’s toolkit view (scoping is the security model); soul/soulFile is its identity (the system prompt); listing agents in team IS the sub-agent wiring — they become the only targets of the child-facing task tool. No team ⇒ no task tool (delegation, and recursion, are opt-in per definition).

import { agents } from "toolnexus"
const explore = agents.agent("explore", {
does: "read-only research",
uses: { tools: [lookup] }, // this child sees ONLY these tools
})
const coder = agents.agent("coder", {
does: "implements changes",
soulFile: "./AGENTS.md", // identity file → system prompt
team: [explore], // team = the task tool's only targets
budget: { maxTokens: 10_000 },
})
const r = await coder.run("fix the failing test", {
llm: {
baseUrl: "https://openrouter.ai/api/v1",
style: "openai",
model: "openai/gpt-4o-mini",
},
})
console.log(r.status, r.text, r.totalTokens) // "done", final text, tree-wide tokens

The registry an agent runs against is the transitive closure of its team graph — agents you didn’t wire in are simply not reachable. A task call naming an out-of-team agent is refused with the team’s names listed.

The bridge back into the classic API: an Agent is a Tool, so drop it into extraTools and any toolkit/client run can delegate to it. The agent’s own loop runs in isolation; the outer run receives exactly one tool result carrying only the final text plus metadata {agent, turns, totalTokens}.

const tk = await createToolkit({ extraTools: [explore.asTool({ llm })] })

Isolation & roll-up — what task guarantees

Section titled “Isolation & roll-up — what task guarantees”

Under the hood delegation is one built-in tool, task { agent, prompt } — spawn → wake → wait → close, fused. Its guarantees:

  • Fresh transcript. The child runs on its own conversation; none of the parent’s context leaks down, and none of the child’s reasoning floods back up.
  • One tool message back. The parent gains exactly one result per task call: the child’s final text. Parallel task calls in one turn run concurrently (the standard parallel tool-call path).
  • Usage roll-up. The child’s tokens/turns/tool calls roll up into the parent’s usage — totalTokens on the root result is the whole tree’s ledger.
  • Errors are results, never exceptions. A failed child Run crosses the boundary as a uniform isError tool result for the parent’s model to judge (reprompt / respawn / reroute / abandon). Only the root throws to the host.
  • Team-scoped advertisement. The task tool’s description lists only the caller’s team (sorted by name, composed from each agent’s does) — the same progressive-disclosure pattern as the skill tool.

A suspending child agent presents to its parent exactly as a suspending toolthe suspension layer, verbatim, no new pending type. The nearest waitFor interpreter wins, one hop at a time: child’s waitFor → else the child’s Request (plus its handle path in data.path) rides up through the parent’s task result → parent’s waitFor → … → the root returns status: "pending". Parked levels burn zero tokens.

A durable pending leaves the tree parked — the result carries the live runtime for resume:

const r = await coder.run("refund order 812", { llm })
if (r.status === "pending") {
// ... later, possibly after human approval:
const resumed = await r.runtime.resume(answer) // routes to the DEEPEST suspended handle
}

(Python: await r.runtime.resume(answer) · Go: Run returns (TaskResult, *Runtime), call rt.Resume(answer) · Java: AgentRuntime.resume(answer) · C#: AgentRuntime.ResumeAsync(answer) — in both, drive the runtime directly for durable one-shots · Elixir: Runtime.resume/2 with the runtime under r.runtime.)

On resume the deepest handle continues from its checkpoint (turns and usage grow, never reset), and each re-run parent’s task call reattaches to the existing child by task key (agent + prompt): settled ⇒ its recorded result, suspended ⇒ its pending, running ⇒ await. Never a duplicate spawn.

Budget { maxTurns, maxTokens, maxToolCalls, maxWallMs, maxChildren, maxConcurrent, maxDepth } (idiomatic casing per port). Budgets are hierarchical and live: carved at spawn as min(own, parent remaining), then enforced against the whole ancestor chain before every turn and spawn — a sibling’s spend counts.

Any limit stop is loud: the result is status: "incomplete" with the limit named — never a silent "done", never a crash. Partial work and the transcript are preserved. The full result status vocabulary is closed and byte-identical in all six ports:

"done" | "pending" | "incomplete" | "interrupted" | "closed" | "timeout" | "error"

An optional onBudget(info) → "stop" | "extend" | "suspend" hook lets the host convert a limit into an approval — "suspend" routes it through the suspension layer like any other human-in-the-loop request.

The runtime builds each agent’s client itself, so the two lifecycle seams reach an agent by being handed to it. hooks (the four lifecycle callbacks) and onMetric (the observability sink) can be set on the runtime — applying to every agent — or on an individual agent, which replaces the runtime-wide value for that agent alone.

on the runtime on an agent
hooks every agent’s turns that agent’s turns only
onMetric every agent’s turns that agent’s turns only

Four rules hold in all six ports:

  1. Agent beats runtime, replace never merge. Two transcript rewrites have no defined order, so an agent that sets hooks runs only its own. The two fields resolve independently — override one, inherit the other.
  2. Forwarded verbatim. The runtime doesn’t compose, wrap, reorder or read either value. Everything documented for a hand-built client holds identically here.
  3. They can’t reach the runtime’s own options. Neither value can change an agent’s composed soul, its escalation authority, its HTTP seam or the shared conversation store — that’s why these are two typed fields and not one “configure the client” escape hatch.
  4. Unset changes nothing. With neither set, a run is byte-identical to one on a runtime that never had the fields.

The headline use is context compaction on a long-lived agent — a compactor is a beforeLLM hook. See Persona agents → compaction for the per-agent snippets in all six languages.

agent().run() and the task tool compile down to a small runtime you can drive directly (agents.AgentRuntime / agents.Runtime per port) — a Handle state machine (idle → running → idle | suspended | closed) plus six verbs:

verb what it does
spawn(parent, def, budget?) new child handle; deterministic parent-scoped id (root/coordinator.1/explore.2); budget carve; the handle is a capability — only its holder may post/wake it
post(h, item) append to the child’s inbox (agent state, not a mailbox); bounded — a full inbox rejects loudly to the sender
wake(h, prompt?) idle → running; the turn’s input = the whole drained inbox as one coalesced block, every item labeled with provenance
wait(h, timeout?) next result or last result (a settled handle answers immediately); timeout errors, the child keeps running
interrupt(h) abort the in-flight turn → idle; drained inbox items are restored (drain is transactional). Never a kill
close(h, {force?}) graceful cascade, leaf-first; a running turn gets shutdownMs before escalating; final state stays queryable — a successor can spawn from the checkpoint

Three always-loud backpressure gates protect a tree: the bounded inbox, a per-parent maxConcurrent admission gate (atomic with wake; queued wakes are FIFO), and a global maxConcurrentTurns gate that wraps only the LLM HTTP call — never a whole run — so a parent can never deadlock against its own children.

Recipes — orchestrate & map are userland patterns

Section titled “Recipes — orchestrate & map are userland patterns”

There is deliberately no orchestrate() or map() API. Both are prompts + teams over the one task primitive (parallel task calls in one turn already run concurrently):

Orchestrate — a coordinator whose team is the workers; the model decides the fan-out:

const researcher = agents.agent("researcher", { does: "web + code research", uses: { tools } })
const writer = agents.agent("writer", { does: "drafts prose from notes" })
const checker = agents.agent("checker", { does: "adversarial fact-check" })
const lead = agents.agent("lead", {
does: "coordinates research reports",
soul: "Plan the report, delegate research and drafting to your team IN PARALLEL " +
"where independent, then have the checker verify before you answer.",
team: [researcher, writer, checker],
budget: { maxTokens: 200_000, maxConcurrent: 3 },
})
const r = await lead.run("Report on the EU AI Act's impact on medical devices", { llm })

Map — same worker, one task call per item; the budget caps the whole fan-out:

const summarizer = agents.agent("summarizer", { does: "summarizes one document" })
const mapper = agents.agent("mapper", {
does: "maps a summarizer over documents",
soul: "For EACH document in the prompt, issue one task call to summarizer " +
"(all in the same turn so they run in parallel), then merge the results.",
team: [summarizer],
budget: { maxConcurrent: 8 },
})
const r = await mapper.run(`Summarize each of: ${docPaths.join(", ")}`, { llm })

The same shapes work in every port — only the agent() spelling changes (tabs above).