Aviary
Guides

Tools

Declaring agent tools with @AiTool — the decorator surface, read vs action, the ToolHandler interface, and the per-invocation context.

A tool is how the model reaches into your app. You declare one as an ordinary NestJS injectable decorated with @AiTool; at boot the agent discovers it, registers its schema, and offers it to the model. The decorator is the contract — name, input shape, who may call it, and whether it runs on its own; the class body is your domain.


Declaring a tool

@AiTool marks a provider class as a tool. The class implements ToolHandler — a single execute(input, ctx) method. The input schema is validated before execute runs, so input is already parsed and typed by the time you touch it.

// src/tools/get-weather.tool.ts
import { AiTool, type ToolHandler, type AiToolCtx } from '@dudousxd/nestjs-agent';
import { z } from 'zod';

@AiTool({
  name: 'getWeather',
  kind: 'read', // auto-executes; see read vs action below
  description: 'Current weather for a city.',
  input: z.object({ city: z.string() }),
})
export class GetWeatherTool implements ToolHandler<{ city: string }> {
  async execute(input: { city: string }, ctx: AiToolCtx) {
    return { tempC: 21, summary: 'partly cloudy' };
  }
}

Then list it in the module that imports AgentModule — the same place you'd list any provider:

@Module({
  imports: [AgentModule.forRoot({ /* … */ })],
  providers: [GetWeatherTool],
})
export class AppModule {}

That's the whole registration story. AiToolDiscoveryService walks every provider at boot, reads the @AiTool metadata, and registers each into the shared ToolRegistry. There is no separate tools: [...] array to keep in sync — if it's a provider and it's decorated, it's a tool.


Tools are ordinary injectables

Because a tool is a normal provider, it can inject anything else in your app — a repository, an HTTP client, a queue, another service. Constructor DI works exactly as it does anywhere in NestJS.

@AiTool({
  name: 'countOpenOrders',
  kind: 'read',
  description: 'How many orders are currently open for a customer.',
  input: z.object({ customerId: z.string() }),
})
export class CountOpenOrdersTool implements ToolHandler<{ customerId: string }> {
  constructor(private readonly orders: OrdersService) {}

  async execute(input: { customerId: string }, ctx: AiToolCtx) {
    return { count: await this.orders.countOpen(input.customerId) };
  }
}

Discovered, not registered by hand

You never wire a tool into a registry yourself. The agent instruments nothing in your code — it only reads @AiTool metadata off providers you already declared. A decorated class with no execute() method is skipped with a warning rather than silently offered to the model.


The decorator surface

Every option lives on the @AiTool({ … }) object.

OptionTypeRequiredDescription
namestringyesThe tool name the model calls. Unique across the registry.
kind'read' | 'action'yesread auto-executes; action pauses for approval. See below.
descriptionstringyesWhat the model reads to decide when to call it. Write it for the model.
inputStandard SchemayesZod, Valibot, or ArkType. Validated before execute runs.
rolesstring[]noRoles allowed to invoke. Omit to inherit the module's defaultRoles.
abilitystringnoAn authz ability checked by an ability-aware policy instead of roles.

Standard Schema, not just Zod

input is a Standard Schema, so Zod, Valibot, and ArkType all work. The parsed, typed value is what lands in your handler's first argument — the I in ToolHandler<I>. Keep that generic in sync with the schema and input is fully typed inside execute.

Give the model the real parameter shapes

The model only calls a tool well when it can see the tool's parameters. Zod (any version), Valibot, and ArkType all convey their shape to the model — Zod by its vendor tag, the others through the Standard JSON Schema extension — so with any of them the model sees city, units, and which are required. A hand-rolled Standard Schema that implements neither still validates the input, but the model receives only a generic object and has to guess the arguments. If you write your own schema, implement the ~standard.jsonSchema converter so the model sees the shape too.

There's a third kind, agent, but you never author it. The library synthesizes an agent-kind delegate tool for each handoff edge in a multi-agent setup; the loop handles it directly and your handler is never called. See Multi-agent.


read vs action — the crucial distinction

kind decides whether a tool runs on its own or waits for a human. This is the single most important field on the decorator.

kindBehaviorUse for
readAuto-executes. The loop calls execute as soon as the model asks, feeds the result back, and keeps streaming.Lookups, queries, anything side-effect-free and safe to run unattended.
actionNever auto-executes. The loop suspends and waits for an explicit approve/reject before execute is ever called.Anything that mutates, spends, sends, or deletes.
@AiTool({
  name: 'purgeCache',
  kind: 'action', // the run pauses here — execute() does not fire until approval
  description: 'Purge a cache key.',
  input: z.object({ key: z.string() }),
})
export class PurgeCacheTool implements ToolHandler<{ key: string }> {
  constructor(private readonly cache: CacheService) {}

  async execute(input: { key: string }, ctx: AiToolCtx) {
    await this.cache.purge(input.key);
    return { purged: input.key };
  }
}

When the model calls an action tool, the run pauses and surfaces a pending tool-call over the wire; a human hits POST /agent/tool-call/approve (or /reject) to continue. Under durable: true that pause is a real durable suspend — checkpointed to the state store and resumable across restarts. Either way, execute runs only after approval.

Where a tool actually runs under durable: true

By default under durable: true, every tool call — read or action — executes as a dispatched AgentRunSteps.tool step rather than in-process: whichever worker in the fleet picks the step up rebuilds the AiToolCtx and re-resolves your tool from its own DI, then calls execute. This is transparent to the handler — the same ToolHandler interface, the same ctx shape — but it does mean a tool's dependencies (a DB pool, an HTTP client) need to be resolvable from every worker in the fleet, not just the one that received the original chat request. toolTimeoutMs is applied inside the handler either way (withToolTimeout), not as a separate durable step timeout. Opt out fleet-wide with dispatchedSteps: false; see Human-in-the-loop & Durability.

Pick the kind by side effects, not by convenience

read tools run without a human in the loop, so a tool that mutates or spends must be action. The gate is the kind, not the name — a read tool that quietly writes is a governance hole. The full approval and durability mechanics live in Human-in-the-loop & Durability.


The ToolHandler interface

The contract is one method:

export interface ToolHandler<I = unknown> {
  execute(input: I, ctx: AiToolCtx): Promise<unknown>;
}
  • input — the parsed, schema-validated input (the I generic).
  • ctx — the per-invocation AiToolCtx (below).
  • return — anything JSON-serializable; it's fed back to the model as the tool result.

Throwing from execute surfaces the error to the loop rather than crashing the turn — return a structured error shape if you want the model to reason about the failure.


Per-tool timeout

By default a tool call runs until it resolves — nothing bounds it. Set AgentModule.forRoot({ toolTimeoutMs }) to cap every tool call's wall-clock time: a call that runs longer is aborted and recorded as a failed tool call, with the timeout fed back to the model as the result, so it can adapt (retry narrower input, try a different tool, or tell the user) instead of the turn hanging indefinitely.

AgentModule.forRoot({
  model: myModelProvider,
  actorResolver: new HeaderActorResolver(),
  toolTimeoutMs: 30_000, // no tool call may run longer than 30s
});

See Configuration for the full option reference.


The tool context (AiToolCtx)

The second argument gives you everything about the invocation without reaching for request-scoped globals. It's the same context whether the turn runs inline or as a durable workflow.

FieldTypeWhat it is
actorActorThe full caller — { id, roles?, tenantRef? }. Your source of truth for scoping and identity.
threadIdstringThe conversation thread this turn belongs to.
runIdstringThis turn's run id.
requestIdstringThe originating request id.
agentNamestring?The name of the agent running this turn — provenance a tool can scope on (e.g. capability sets).
pageContextPageContext?Host-supplied context about where the turn was started.
hostunknown?An optional host handle (e.g. an ORM EntityManager) the app threads through.

The actor is the load-bearing field, and the only source of identity: read ctx.actor.id and ctx.actor.tenantRef rather than any top-level shorthand. Never derive scope from tool input — derive it from ctx.actor.

async execute(input: { customerId: string }, ctx: AiToolCtx) {
  // Scope to the caller's tenant — from the context, not the input.
  return this.orders.findOpen({
    customerId: input.customerId,
    tenantRef: ctx.actor.tenantRef,
  });
}

Scope from the actor, never from the input

The model composes tool input from the conversation, so treating an input field as authority lets a prompt widen its own scope. Read id / roles / tenantRef off ctx.actor — the actor is resolved server-side by your ActorResolver, not by the model.


Authorization gates

A tool declares one of two gates. Both are enforced by the loop before execute runs, so an unauthorized call never reaches your code.

Built-in role policy. The actor passes if any of its roles intersects the tool's. Omit roles and the tool inherits the module's defaultRoles.

@AiTool({
  name: 'listUsers',
  kind: 'read',
  description: 'List users in the org.',
  input: z.object({}),
  roles: ['ADMIN', 'SUPPORT'],
})
export class ListUsersTool implements ToolHandler { /* … */ }

Delegated policy. With AgentAuthzModule wired, ability is checked via gate.forUser(actor).allows(ability) — your @dudousxd/nestjs-authz abilities decide. Tools without an ability fall back to the role policy, so non-authz apps are unaffected.

@AiTool({
  name: 'purgeCache',
  kind: 'action',
  description: 'Purge a cache key.',
  input: z.object({ key: z.string() }),
  ability: 'cache.purge',
})
export class PurgeCacheTool implements ToolHandler { /* … */ }

The full model — the ActorResolver, why there's no insecure default, and roles vs abilities — is in Identity & Authorization.


On this page