Multi-agent
Declare named agents as @Agent classes, let an orchestrator hand off to sub-agents via handoff, and run each delegation as a durable child run.
A single agent answers with its own prompt and tools. A multi-agent setup splits that into
named specialists — an orchestrator that coordinates, and sub-agents it hands off to. You
declare each one as an @Agent-decorated provider, name which agents an orchestrator may call via
handoff, and the library does the wiring: at boot it discovers the classes, synthesizes an
ask_<name> tool per handoff edge, and when the model calls it the loop runs the sub-agent as a
durable child run (or a nested in-process loop when you're not durable).
The mechanism is the library; the roster — which agents exist, what each one is for, who may call whom — is policy you supply.
Declare named agents
An agent is an ordinary Nest provider decorated with @Agent({ name, ... }). Register the classes
as providers alongside AgentModule.forRoot; a DiscoveryService sweep at boot reads their metadata
and populates the shared AgentRegistry. There is no forFeature([definitions]) and no inline
config array — being a class means each agent gets constructor DI (its retriever, a schema service, a
policy) instead of a static blob, and a dynamic prompt is a @SystemPrompt() method rather than a
string.
// src/app.module.ts
import { Module, Injectable } from '@nestjs/common';
import { Agent, AgentModule, HeaderActorResolver } from '@dudousxd/nestjs-agent';
import { GetWeatherTool } from './tools/get-weather.tool.js';
/** A focused specialist — restricted to the `getWeather` tool. */
@Agent({
name: 'weather-analyst',
systemPrompt: 'You answer weather questions using the getWeather tool.',
tools: ['getWeather'],
})
@Injectable()
class WeatherAnalystAgent {}
/** An orchestrator that hands off to the specialist (via the synthesized `ask_weather_analyst`). */
@Agent({
name: 'ops-orchestrator',
systemPrompt: 'You coordinate specialists. Delegate weather questions to weather-analyst.',
handoff: [WeatherAnalystAgent],
})
@Injectable()
class OpsOrchestratorAgent {}
@Module({
imports: [
AgentModule.forRoot({
model: myModelProvider,
store: myAgentStore,
defaultRoles: ['ADMIN'],
actorResolver: new HeaderActorResolver(),
defaultAgent: 'ops-orchestrator', // which agent an unqualified turn runs
// durable: true, // makes each handoff a durable child run — see below
}),
],
providers: [GetWeatherTool, WeatherAnalystAgent, OpsOrchestratorAgent],
})
export class AppModule {}The orchestrator has no tools of its own — its only capability is delegation. The specialist has
no handoff — it's a leaf that answers with getWeather. Nothing stops an agent from having both.
handoff takes class references, not names, so a typo is a compile error rather than a runtime
one. Set defaultAgent to pick which agent an unqualified turn runs; with more than one agent
registered there's no single-agent fallback.
Dynamic prompts and app-wide prompt sections
Replace the flat systemPrompt string with a @SystemPrompt() method on the class to build the base
prompt per turn from its PromptContext (actor, agent name, page context). A
@SystemPromptContributor() method on any provider appends a section after every agent's base
prompt app-wide — the way to inject a mentions legend or a schema hint without touching each agent.
handoff targets are validated at boot
Referencing an unregistered agent throws at startup — it no longer silently creates a handoff to an
unrestricted phantom agent. Agent-to-agent delegation depth is also capped at MAX_DELEGATION_DEPTH
(5, exported from @dudousxd/nestjs-agent-core); an orchestrator chain deeper than that fails the
same way.
The synthesized ask_<name> tool
Every handoff target becomes a real agent-kind tool in the registry. The name is
ask_ + the target agent's name with non-alphanumeric runs collapsed to _:
| Target agent name | Synthesized tool | Input schema |
|---|---|---|
weather-analyst | ask_weather_analyst | { task: string } |
billing.ops | ask_billing_ops | { task: string } |
The tool's description is generated for the model — Delegate a task to the "weather-analyst" agent and get its answer. — and, when the target's systemPrompt is a flat string, that prompt is
appended so the orchestrator's model knows what the specialist is for. (A PromptBuilder prompt is
per-request and would stringify to source, so it's skipped.)
The orchestrator's effective tool allow-list is its own tools plus these synthesized delegate
tools:
// effective allow-list for ops-orchestrator:
['ask_weather_analyst'] // tools: [] + handoff: [WeatherAnalystAgent]
// for an agent with both:
['refundOrder', 'ask_billing_ops'] // tools: ['refundOrder'] + handoff: [BillingOpsAgent]You never write the delegate tool
There is no @AiTool for delegation. The discovery service synthesizes one agent-kind entry per
edge at startup and logs registered N AI tool(s) and M delegate tool(s). The handler is never
called — delegation is resolved at the loop level, not by a tool handler.
Target an agent per request
A turn runs against exactly one agent. Pick it with the agent field in the chat body; omit it and
the module's default agent runs.
curl -N http://localhost:3000/agent/chat \
-H 'content-type: application/json' \
-H 'x-actor-id: u1' -H 'x-actor-role: ADMIN' \
-d '{ "message": "What is the weather in Lisbon?", "agent": "ops-orchestrator" }'{ "message": "…", "threadId": "…", "agent": "ops-orchestrator" }The orchestrator's model sees ask_weather_analyst, calls it with { "task": "weather in Lisbon" },
the loop runs weather-analyst (which calls getWeather), and its answer flows back to the
orchestrator as the tool result — which then streams the final reply. From the client's point of
view it's one turn on one thread; the delegation happens inside the loop.
What each agent gets
Model, store, sink, and governance are shared from forRoot. Everything on the @Agent({ ... })
decorator is per-agent and overrides the shared defaults for that agent's turns:
| Option | Type | Effect |
|---|---|---|
name | string | The identifier used in the agent body field and recorded as each message's provenance. Required. |
description | string | Human summary, shown to an orchestrator that may hand off to this agent, and surfaced in the GET /agent/agents catalog. |
systemPrompt | string | This agent's flat base prompt. For a dynamic prompt add a @SystemPrompt() method instead. A flat string is also surfaced in the ask_ tool's description. |
tools | string[] | Allow-list of tool names this agent may use (a subset of all registered tools). Omit → every tool its role allows. |
handoff | Type[] | Other @Agent classes this agent may hand off to; each becomes an ask_<name> tool. |
model | string | Accounting/label override for this agent's turns (the model provider itself is shared). |
maxSteps | number | Tool-calling step cap for this agent's loop (default 8). |
So each agent runs its own system prompt, its own model label, and its own tool allow-list — and that allow-list is further intersected with the actor's role/ability gate on each tool. An agent can only reach tools it lists and the caller is authorized for.
Delegation respects the roster, not the actor's ambition
A sub-agent can't be reached unless some orchestrator names it in handoff, and it can only use
the tools it declares — not the orchestrator's. Scoping a specialist down to one tool is how you
keep a broad orchestrator from smuggling capability into a narrow task.
Delegation is a child run
When the model calls an ask_<name> tool, the loop handles it directly (not via a handler) because
the execution mode differs by runner:
durable: true— the sub-agent runs as a durable child run. The durable runner maps it toctx.child, a ctx-level suspend point, so a sub-agent that pauses on a human-in-the-loopactiontool suspends and resumes just like a top-level run — replay-safe, surviving restarts.- inline (no durable) — the sub-agent runs as a nested in-process loop, awaited to completion before the orchestrator's turn continues.
Either way the sub-agent's result comes back as { text } and is persisted as the delegate tool's
output. A tool that needs to spawn a sub-agent itself can do so through the loop context:
async execute(input: { topic: string }, ctx: AiToolCtx) {
// ctx.runAgent runs another registered agent with delegation-aware execution
const { text } = await ctx.runAgent!('weather-analyst', input.topic);
return { summary: text };
}Wire durability before you rely on it
The durable child run needs durable: true plus AgentDurableModule and a configured
DurableModule. Without them the loop still delegates — as a nested in-process loop — but a
sub-agent's HITL pause is held open in memory, not checkpointed. See
Human-in-the-loop & Durability.
A child run dispatches its steps too
dispatchedSteps defaults to ON under durable: true, and a delegated child run is still an
agent.run workflow — so a sub-agent's own model call and tool executions dispatch through the same
AgentRunSteps.llm / .tool worker groups as a top-level run, landing on whichever worker in the
fleet picks them up rather than staying pinned to the pod that started the parent. See
Dispatched steps.
Observing delegation
Every delegation emits an aviary:agent:delegated diagnostics event on Node's
diagnostics_channel, carrying who called whom:
interface AgentDelegated {
runId: string;
fromAgent?: string; // omitted when the orchestrator is the default agent
toAgent: string;
}@dudousxd/nestjs-agent-telescope consumes it for the Agent dashboard tab; any app can subscribe to
build an orchestration graph, count delegations per specialist, or alert on a runaway fan-out — the
library instruments nothing in your code. See
Cost & Governance for the rest of the aviary:agent:*
channel.
Related
- Tools — the
@AiToolsurface a specialist delegates to,readvsaction, and the handler context - Human-in-the-loop & Durability — why a durable child run makes a sub-agent's approval pause replay-safe
- Identity & Authorization — the role/ability gate every delegated tool is still checked against
- Cost & Governance — the
aviary:agent:*diagnostics channel and the governance console
Human-in-the-loop & Durability
Action tools pause for a human decision — and under the durable runner that pause is a real durable signal, so the run suspends in the state store and resumes on approval, surviving restarts.
RAG
Ground the agent in your documents — agentic retrieval as a tool (default) or always-on prompt injection, with citations flowing through the tool-call mechanism.