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.
Level 1 — agent(), a team, run()
Section titled “Level 1 — agent(), a team, run()”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 tokensfrom toolnexus.agents import agent, Budget
explore = agent("explore", does="read-only research", uses={"tools": [lookup]})coder = agent( "coder", does="implements changes", soul_file="./AGENTS.md", team=[explore], budget=Budget(max_tokens=10_000),)
r = await coder.run( "fix the failing test", llm={ "base_url": "https://openrouter.ai/api/v1", "style": "openai", "model": "openai/gpt-4o-mini", },)print(r.status, r.text, r.total_tokens)import "github.com/muthuishere/toolnexus/golang/agents"
explore := agents.New("explore", agents.Spec{ Does: "read-only research", Tools: []toolnexus.Tool{lookup},})coder := agents.New("coder", agents.Spec{ Does: "implements changes", SoulFile: "./AGENTS.md", Team: []*agents.Agent{explore}, Budget: &agents.Budget{MaxTokens: 10_000},})
r, _ := coder.Run(agents.Options{ LLM: &agents.LLMOptions{ BaseURL: "https://openrouter.ai/api/v1", Style: toolnexus.StyleOpenAI, Model: "openai/gpt-4o-mini", },}, "fix the failing test")fmt.Println(r.Status, r.Text, r.TotalTokens)import static io.github.muthuishere.toolnexus.agents.Agents.agent;import io.github.muthuishere.toolnexus.agents.*;
Agents.Agent explore = agent("explore", new Agents.AgentSpec() .does("read-only research") .tools(lookup));Agents.Agent coder = agent("coder", new Agents.AgentSpec() .does("implements changes") .soulFile(Path.of("AGENTS.md")) .team(explore) .budget(new Budget().maxTokens(10_000)));
TaskResult r = coder.run(new RuntimeOptions() .baseUrl("https://openrouter.ai/api/v1") .style("openai") .defaultModel("openai/gpt-4o-mini"), "fix the failing test");System.out.println(r.status() + " " + r.text() + " " + r.totalTokens());using Toolnexus.Agents;
var explore = new Agent("explore", new AgentSpec{ Does = "read-only research", Uses = new List<ITool> { lookup },});var coder = new Agent("coder", new AgentSpec{ Does = "implements changes", SoulFile = "./AGENTS.md", Team = new List<Agent> { explore }, Budget = new Budget { MaxTokens = 10_000 },});
var r = await coder.RunAsync(new RuntimeOptions{ BaseUrl = "https://openrouter.ai/api/v1", Style = "openai", Model = "openai/gpt-4o-mini",}, "fix the failing test");Console.WriteLine($"{r.Status} {r.Text} {r.TotalTokens}");alias Toolnexus.Agents
explore = Agents.agent("explore", does: "read-only research", uses: %{tools: [lookup]})
coder = Agents.agent("coder", does: "implements changes", soul_file: "AGENTS.md", team: [explore], budget: %{max_tokens: 10_000} )
r = Agents.run( coder, [llm: %{base_url: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini"}], "fix the failing test" )
IO.puts("#{r.status} #{r.text} #{r.total_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 other direction — asTool()
Section titled “The other direction — asTool()”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 })] })tk = await create_toolkit(extra_tools=[explore.as_tool(llm=llm)])tk, _ := toolnexus.CreateToolkit(ctx, toolnexus.Options{ ExtraTools: []toolnexus.Tool{explore.AsTool(agents.Options{LLM: llm})},})Toolkit tk = Toolkit.create(new Toolkit.Options().extraTools(explore.asTool(rtOpts)));toolkit.Register(explore.AsTool(rtOpts));tools = [Agents.as_tool(explore, rt_opts)]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
taskcall: the child’s final text. Paralleltaskcalls 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 —
totalTokenson the root result is the whole tree’s ledger. - Errors are results, never exceptions. A failed child Run crosses the boundary as a
uniform
isErrortool result for the parent’s model to judge (reprompt / respawn / reroute / abandon). Only the root throws to the host. - Team-scoped advertisement. The
tasktool’s description lists only the caller’s team (sorted by name, composed from each agent’sdoes) — the same progressive-disclosure pattern as theskilltool.
Suspension escalation & durable resume
Section titled “Suspension escalation & durable resume”A suspending child agent presents to its parent exactly as a suspending tool —
the 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.
Budgets — and the "incomplete" status
Section titled “Budgets — and the "incomplete" status”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.
Hooks & metrics on an agent run
Section titled “Hooks & metrics on an agent run”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:
- Agent beats runtime, replace never merge. Two transcript rewrites have no defined
order, so an agent that sets
hooksruns only its own. The two fields resolve independently — override one, inherit the other. - Forwarded verbatim. The runtime doesn’t compose, wrap, reorder or read either value. Everything documented for a hand-built client holds identically here.
- 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.
- 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.
Advanced — the six host verbs
Section titled “Advanced — the six host verbs”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).
See also
Section titled “See also”- Suspension & the human loop — the machinery escalation rides on.
- Persona agents — keep a high-tool-volume worker under its context budget with the
compactorbeforeLLMhook. - A2A — remote agents — the same axiom across the network;
serve(agent)is unchanged. - SPEC.md §7D — the full cross-language contract (state machine, gates, cancellation table).