Aviary
Guides

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 nameSynthesized toolInput schema
weather-analystask_weather_analyst{ task: string }
billing.opsask_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:

OptionTypeEffect
namestringThe identifier used in the agent body field and recorded as each message's provenance. Required.
descriptionstringHuman summary, shown to an orchestrator that may hand off to this agent, and surfaced in the GET /agent/agents catalog.
systemPromptstringThis 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.
toolsstring[]Allow-list of tool names this agent may use (a subset of all registered tools). Omit → every tool its role allows.
handoffType[]Other @Agent classes this agent may hand off to; each becomes an ask_<name> tool.
modelstringAccounting/label override for this agent's turns (the model provider itself is shared).
maxStepsnumberTool-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 to ctx.child, a ctx-level suspend point, so a sub-agent that pauses on a human-in-the-loop action tool 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.


  • Tools — the @AiTool surface a specialist delegates to, read vs action, 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

On this page