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.
| Option | Type | Required | Description |
|---|---|---|---|
name | string | yes | The tool name the model calls. Unique across the registry. |
kind | 'read' | 'action' | yes | read auto-executes; action pauses for approval. See below. |
description | string | yes | What the model reads to decide when to call it. Write it for the model. |
input | Standard Schema | yes | Zod, Valibot, or ArkType. Validated before execute runs. |
roles | string[] | no | Roles allowed to invoke. Omit to inherit the module's defaultRoles. |
ability | string | no | An 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.
kind | Behavior | Use for |
|---|---|---|
read | Auto-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. |
action | Never 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 (theIgeneric).ctx— the per-invocationAiToolCtx(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.
| Field | Type | What it is |
|---|---|---|
actor | Actor | The full caller — { id, roles?, tenantRef? }. Your source of truth for scoping and identity. |
threadId | string | The conversation thread this turn belongs to. |
runId | string | This turn's run id. |
requestId | string | The originating request id. |
agentName | string? | The name of the agent running this turn — provenance a tool can scope on (e.g. capability sets). |
pageContext | PageContext? | Host-supplied context about where the turn was started. |
host | unknown? | 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.
Related
- Identity & Authorization —
ActorResolver, the actor, roles vs abilities - Human-in-the-loop & Durability — how an
actiontool suspends and resumes - Multi-agent — the synthesized
agent-kind delegate tools - Governed SQL — a prebuilt read-only SQL tool
- Getting Started — install, first tool, first turn
Governance
Four layers stack on every turn — who's calling and what they may do, how many tokens they've spent today, what it cost, and an audit trail of everything that happened.
Identity & Authorization
How the agent learns who is calling (ActorResolver, with no insecure default) and decides — per tool, server-side — whether they may run it (roles vs abilities).