Tools
Give the agent things to do — @AiTool classes and defineTool functions, discovered from app/agent_tools, with read/action kinds, Standard Schema inputs, and role/ability gating.
Tools are what turn a chatbot into an agent. A tool is a named capability with a typed input schema and a handler; the model decides when to call it, and the agent loop decides whether it may (role gate) and how (auto-execute vs. human approval). There are two ways to author one, and both register the same way.
For result-driven UI, use the tool’s optional present hook. See Component registries and server rendering for functional/class tools, custom components, shared React renderers, and WhatsApp image/PDF delivery.
@AiTool classes
Decorate a class that implements execute(input, ctx):
import { AiTool } from '@adonis-agora/agent'
import type { AiToolCtx, ToolHandler } from '@adonis-agora/agent'
import { z } from 'zod'
@AiTool({
name: 'getOrder',
kind: 'read',
description: 'Fetch an order by id for the current customer.',
input: z.object({ orderId: z.string() }),
roles: ['MEMBER'],
})
export default class GetOrderTool implements ToolHandler<{ orderId: string }> {
async execute(input: { orderId: string }, ctx: AiToolCtx) {
// ctx carries the acting identity and thread — scope your query to it.
return await Order.query()
.where('id', input.orderId)
.where('customer_ref', ctx.actor.id)
.firstOrFail()
}
}Only description and input are required. name defaults to the class name camelCased with a trailing Tool dropped (GetOrderTool → getOrder, SQLQueryTool → sqlQuery) and kind to 'read' — so the decorator above could be just @AiTool({ description, input, roles }). The same defaults apply to static tool = { … }; defineTool has no class, so it still needs a name.
The decorator stamps its options onto the class; discovery resolves an instance through the IoC container (see Constructor DI below) and binds execute.
Base classes — BaseTool / ReadTool / ActionTool
The @AiTool decorator isn't the only way to author a class. Three abstract base classes give you the same registration with a plain static tool field and a type-checked execute — the counterpart of durable's BaseWorkflow. Extend one, implement execute(input, ctx), and discovery reads the static tool off your subclass and registers it (the abstract bases carry no static tool, so they're never registered themselves):
BaseTool<I, O>— the general base. Declare the full options includingkind. The inheritedstatic tool?: AiToolOptionstype-checks your literal, so you write neithersatisfies AiToolOptionsnor a type annotation.ReadTool<I, O>— fixeskind: 'read'. Yourstatic toolomitskindand type-checks truly bare — just{ name, description, input, ability? }.ActionTool<I, O>— fixeskind: 'action'(requires HITL approval). Same barestatic tool, nokind.
The <I, O> type parameters flow into execute's signature, so the compiler checks the body against what the tool promises — I is the parsed input, O is the return.
import { ReadTool } from '@adonis-agora/agent'
import type { AiToolCtx } from '@adonis-agora/agent'
import { z } from 'zod'
const input = z.object({})
type Input = z.infer<typeof input>
export default class AllocationQueue extends ReadTool<Input, Row[]> {
// No `kind` — the base fixes 'read'. Type-checks bare, no `satisfies`.
static tool = {
name: 'allocation_queue',
description: 'The current allocation queue.',
input,
ability: 'agent.queue.read',
}
async execute(_input: Input, ctx: AiToolCtx): Promise<Row[]> {
// ...
return []
}
}defineTool functions
For a plain function, defineTool(options, execute) returns a branded { spec, handler } that discovery picks up the same way:
import { defineTool } from '@adonis-agora/agent'
import { z } from 'zod'
export const purgeCache = defineTool(
{
name: 'purgeCache',
kind: 'action',
description: 'Purge a cache key.',
input: z.object({ key: z.string() }),
roles: ['ADMIN'],
},
async ({ key }, ctx) => {
await cache.forget(key)
return { purged: key }
},
)The second argument can also be a handler object — { execute, preflight?, present?, isEnabled?, canUse?, describe? } — and everything can go in one object, with the input type inferred from the schema:
export const orderStatus = defineTool({
name: 'orderStatus',
description: 'Look up one order',
input: z.object({ orderId: z.string() }),
execute: async ({ orderId }, ctx) => Order.findForCustomer(orderId, ctx.actor.id),
// optional: `orderSummary` is a component factory — see One output, many surfaces
present: (order) =>
orderSummary({ id: order.id, status: order.status, totalCents: order.totalCents }),
})You can also pass functional tools directly to the config instead of a file: defineConfig({ tools: [purgeCache] }).
defineConfirmedTool — a write that previews first
defineConfirmedTool(options, { prepare, preview, commit }) is defineTool for a write whose human gate lives inside the tool: the first call returns a preview and a signed confirmToken, and the same arguments plus confirm: true and the token commit. It registers as kind: 'read', so it works where an action cannot — over MCP, where there is no approval channel. In a chat surface with HITL approval, prefer kind: 'action'.
Tool kinds
The kind decides how the loop treats a call:
| Kind | Behavior |
|---|---|
read | Auto-executes. Recorded as auto_executed, then invoked immediately. Use for anything safe to run without a human — lookups, reads, computations. |
action | Requires approval (HITL). Recorded as pending_approval; the run pauses until a human approves or rejects it — or, with actionApprovalMode: 'independent', becomes a proposal executed later. Use for anything that mutates state or has side effects. See Streaming & HTTP. |
The agent kind is synthesized, never authored
Core also has an agent kind for delegation, but you never write it. It's synthesized from an agent's delegatesTo edges as ask_<target> tools. See Personas & agents.
Input schemas — Standard Schema
A tool's input is a Standard Schema — so Zod, Valibot, or ArkType all work. The schema does double duty:
- The provider converts it into the JSON schema the model sees as the tool's parameters.
- The loop re-validates the model's arguments against it (
~standard.validate) before your handler runs — bad input throwsToolInputInvalidErrorand never reachesexecute.
Zod 3 doesn't expose the Standard JSON Schema extension, so the AI SDK adapter recognizes it by its zod vendor tag and converts it natively; Valibot, ArkType, and Zod 4 carry the extension and are converted through it. A bare schema the adapter can't introspect degrades to a permissive object schema for the model — but the loop still validates the real schema before running the tool.
Presentation — how a chat surface talks about a tool
A tool name is an identifier, not copy. presentation declares the words a chat surface uses instead — beside the input schema, so whoever changes the input re-words the sentence that mentions it. It is never shown to the model.
defineTool(
{
name: 'purgeCache',
kind: 'action',
description: 'Purge one cache key',
input: z.object({ key: z.string() }),
presentation: {
label: 'Cache purge',
running: 'Purging {key}', // `{dotted.path}` placeholders read the call's input
done: 'Purged {key}',
icon: 'cache', // a key into the client's own glyph map
tone: 'destructive',
confirm: { title: 'Purge {key}?', verb: 'Purge' }, // the approval prompt
result: { kind: 'note', text: 'Freed {bytes} bytes' }, // how the OUTPUT reads
},
},
purge,
)The confirm templates are filled on the server too (fillToolConfirmation, the same filler as the React layer's fillTemplate): every approval frame carries the resolved confirmation (Purge sessions:42?), so a transport whose client has no tool catalog — AG-UI, A2UI, OpenUI over AG-UI, A2A — words the prompt as the native page does, and the approve button reads as its verb wherever there is one (A2UI, channel cards).
A tool that declares no confirm never shows its identifier to a person: every adapter words the prompt with resolveToolConfirmation(toolName, input, presentation?) — the filled confirm when declared, else "<presentation.label>?", else the humanized name (refund_order → Refund order?), with the verb Approve (DEFAULT_CONFIRM_VERB; a text channel shows its own localized texts.approve instead). humanizeToolName is exported too.
The same field works on @AiTool({ … }) and static tool = { … }. GET /agent/tools?agent= serves { name, kind, presentation? }[] for the tools the caller can reach — the exact list the model is offered for that actor and agent (an unknown agent is a 404) — which @adonis-agora/agent/react's useToolCatalog reads to narrate calls. A tool with no presentation is narrated generically. Agora exports the shape (ToolPresentation, ToolResultView, …) locally, retaining protocol compatibility.
Roles & ability
Two optional governance fields on every tool:
roles: string[]— the roles allowed to invoke it. Omit it and the tool inherits the config'sdefaultRoles([]— no restriction — unless you change it). Authorization is a plain set intersection of the actor's roles against the tool's. A tool the actor can't reach is never even offered to the model.ability: string— an authorization ability name (e.g.'cache.purge') consumed by an ability-awareRolesPolicysuch as the Bouncer adapter in@adonis-agora/agent/authz. The default role-based policy ignores it. Both live on the same seam, so neither is required.
See Authorization for the full governance model.
Turning a tool off, and gating it per user
roles/ability answer "may this actor use it?" with one app-wide policy, and an agent's tools allow-list is fixed when the agent is declared. Two more gates answer the questions those can't, per turn:
enabled/isEnabled()— does this capability exist in this deployment at all? The feature-flag seam.canUse(actor)— may THIS actor use it? The per-user seam, next to the tool that knows what to ask (a plan, an entitlement row, ownership of the record in question).
On a class, implement them as methods — the class is resolved through the container, so they can read @inject'd services:
@inject()
export default class SearchDocs extends ReadTool<Input> {
static tool = { name: 'searchDocs', description: '…', input: schema }
constructor(private plans: PlanService) { super() }
isEnabled() { return env.get('DOCS_SEARCH_ENABLED') === true }
canUse(actor: Actor) { return this.plans.includesDocSearch(actor.tenantRef) }
async execute(input: Input, ctx: AiToolCtx) { /* … */ }
}For availability that needs no service, declare it on the options — a boolean, or a predicate re-read every turn: enabled: false, enabled: () => env.get('FLAG') === true. defineTool and defineConfirmedTool take both enabled and a canUse(actor) option.
All of them run when the turn's tool list is built, so a tool that fails one is never shown to the model — no wasted step, no refusal that tells the user a capability exists — and the same list is what GET <path>/tools and the MCP server's tools/list answer. They run again on invoke, which is what stops a HITL action approved before the flag moved from executing after it. Every gate can only remove tools: the agent's allow-list, then enabled/isEnabled, then the roles policy, then canUse. A disabled tool raises ToolDisabledError, distinct from ToolForbiddenError (wrong actor, or canUse said no) and ToolNotFoundError (no such tool) — three different things to go fix.
The tool context
Every handler receives an AiToolCtx as its second argument:
interface AiToolCtx {
actor: { id: string; roles?: string[]; tenantRef?: string }
threadId: string
runId: string
requestId: string
toolCallId?: string // absent outside a turn (MCP, a direct registry.invoke)
idempotencyKey?: string // `<runId>:<toolCallId>`, stable across replays and retries
agentName?: string
persona?: Persona
pageContext?: PageContext // free-form, from the send (`{ kind?, …anything }`)
uiCapabilities?: UiCapabilities // what the client can draw — see Generative UI
host?: unknown // optional host handle (e.g. an ORM manager)
abortSignal?: AbortSignal // aborted when the run is stopped (inline runner); pass it to fetch, queries…
onPresentationError?(error: unknown, details: { toolName: string; toolCallId?: string; runId?: string; threadId?: string }): void | Promise<void>
emitUi(
component: string,
props: Record<string, unknown>,
options?: { id?: string; version?: number; fallbackText?: string },
): Promise<{ id: string }>
emitComponent?(name: string, data: unknown): void | Promise<void> // the older push
}A test that calls a handler directly builds the ctx itself — pass emitUi: createNoopEmitUi() (from @adonis-agora/agent/testing).
Stopping. A Stop (POST <path>/chat/:runId/cancel, AgentService.cancel, the React Stop button) aborts ctx.abortSignal under the inline runner. Hand it to whatever the tool waits on, so the Stop does not wait for the tool to finish:
async (input, ctx) => {
const response = await fetch(url, { signal: ctx.abortSignal })
return response.json()
}A call cut short this way settles failed, and the run ends cancelled at its next safe point. A tool that ignores the signal is not interrupted: it runs to the end, and the run stops after it. The signal is absent under the durable runner (its runtime's own cancel stops the run) and outside a turn, so read it as optional.
Identity is single-sourced on ctx.actor — read ctx.actor.id / ctx.actor.tenantRef and scope every query to it. Never trust an id the model passes in its arguments.
ctx.emitUi(component, props) pushes a typed UI component into the conversation instead of (or alongside) text — streamed live, persisted on the message, rendered by the frontend's own component, and sent as text to a client that cannot draw it. See Generative UI and One output, many surfaces.
More per-tool options
| Option | On | Meaning |
|---|---|---|
present(output, ctx) | handler (class method, defineTool handler/object) | Turns a successful result into component presentations, pushed through ctx.emitUi. A presentation failure never fails the call. See Component registries. |
terminal: true | @AiTool, static tool, defineTool | A successful call ends the turn: no model call narrates what it showed. |
replacementKey | defineTool, @AiTool, static tool | Groups independent proposals that supersede each other. |
describe(scope) | handler | Varies the description and input schema the model sees per actor, thread, agent or UI capabilities; { available: false } hides the tool for that turn, and a call to it anyway is refused as an unknown tool. |
Constructor DI (@inject)
Class tools — whether @AiTool-decorated or a BaseTool/ReadTool/ActionTool subclass — are resolved through the AdonisJS IoC container, so a tool declares its dependencies in its constructor and lets the container build them. This is the idiomatic Adonis way: no service locator, no manual new, and your handler stays testable.
import { inject } from '@adonisjs/core'
import { ActionTool } from '@adonis-agora/agent'
import type { AiToolCtx } from '@adonis-agora/agent'
import { z } from 'zod'
import RefundService from '#services/refund_service'
const input = z.object({ orderId: z.string(), amountCents: z.number().int().positive() })
type Input = z.infer<typeof input>
@inject()
export default class IssueRefund extends ActionTool<Input, { refundId: string }> {
// The container injects RefundService (and its transitive deps) here.
constructor(private readonly refunds: RefundService) {
super()
}
static tool = {
name: 'issue_refund',
description: 'Issue a refund against an order.',
input,
ability: 'agent.refunds.issue',
}
async execute({ orderId, amountCents }: Input, ctx: AiToolCtx) {
// Always scope to the acting identity — never the model's arguments.
return this.refunds.issue({ orderId, amountCents, actorId: ctx.actor.id })
}
}Lazy and cached
A tool is constructed from the container on its first invocation, then reused. It is deliberately not built at discovery time: discovery happens early in boot, and a tool whose constructor needs a service that isn't ready yet would fail there rather than working fine at request time. A tool that constructs cleanly with no dependencies works unchanged — @inject() matters only when the constructor takes arguments.
Discovery
Tools are registered at boot from two possible sources, in order:
When you configure the package, an Assembler init hook is registered that generates a typed app/agent_tools barrel at build/dev time (.adonisjs/agent/tools.js). The provider imports it — no runtime readdir. This mirrors how @adonis-agora/durable generates its steps/workflows barrels.
First wins
A tool name already registered is skipped — the first registration wins. Registration order is discovery (barrel or scan) first, then config-level tools, then synthesized delegate tools. Names must be unique across all of them.
Configuring the generator
node ace add @adonis-agora/agent registers the hook for you:
export default defineConfig({
hooks: {
init: [() => import('@adonis-agora/agent/hooks/tools')],
},
})The default export is a ready hook over app/agent_tools. To scan somewhere else — a package of shared tools, a differently-named directory — call toolsHook yourself:
import { toolsHook } from '@adonis-agora/agent/hooks/tools'
export default defineConfig({
hooks: {
init: [async () => ({ default: toolsHook({ source: 'app/ai/tools', importAlias: '#ai_tools' }) })],
},
})| Option | Default | Meaning |
|---|---|---|
source | 'app/agent_tools' | Directory scanned for tool modules, relative to the app root. |
importAlias | '#agent_tools' | The subpath import alias the generated barrel uses. Must exist in your package.json imports. |
output | '.adonisjs/agent/tools.ts' | Where the barrel is written. |
The generated file is a map of lazy imports, with the Tool suffix stripped and the name PascalCased:
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
export const tools = {
GetWeather: () => import('#agent_tools/get_weather_tool'),
PurgeCache: () => import('#agent_tools/purge_cache_tool'),
}Changing source or output means the provider must still be able to find the result — it imports the compiled barrel from .adonisjs/agent/tools.js. Point output elsewhere and the provider silently falls back to the runtime scan of app/agent_tools, which is the behaviour you were probably trying to replace. Change source and importAlias together; leave output alone.
Action preflight
Actions may implement preflight(input, ctx, { phase }) to inspect current domain state without effects. The registry validates input and checks availability, roles, and canUse before this hook. Read tools do not run preflight.
import { ActionTool, type AiToolCtx, type ToolPreflightOptions, type ToolPreflightResult } from '@adonis-agora/agent'
class RefundTool extends ActionTool<{ orderId: string }, { refunded: boolean }> {
static tool = {
name: 'refund', description: 'Refund an order',
input: z.object({ orderId: z.string() }),
}
async preflight(input: { orderId: string }, ctx: AiToolCtx, { phase }: ToolPreflightOptions): Promise<ToolPreflightResult<{ refunded: boolean }>> {
const order = await orders.findForActor(input.orderId, ctx.actor.id)
if (order.refunded) return { status: 'completed', output: { refunded: true } }
if (!order.refundable) return { status: 'denied', reason: 'This order cannot be refunded.' }
return {
status: 'ready',
confirmation: { title: `Refund ${order.label}?`, verb: 'Refund', detail: `Return ${order.total}` },
}
}
async execute(input: { orderId: string }, ctx: AiToolCtx) {
await orders.refund(input.orderId, { idempotencyKey: ctx.idempotencyKey })
return { refunded: true }
}
}prepare runs before requesting approval, including when the approval policy is automatic or a decision was remembered. denied, completed, and failed preparation produce a tool result without an approval card. Ready confirmation strings are already resolved for display and travel on approval-requested.confirmation and persisted ToolCallApproval.confirmation.
execute runs immediately before the handler, after approval, with fresh authorization and validation. A domain refusal then emits tool-output-denied while preserving the person's approved decision in history. Completed output bypasses execute in either phase. Preparation and settled execution refusals are journaled in the existing checkpoints; replay does not repeat settled checks.
The functional API accepts a handler object as well as an execution function:
export const refund = defineTool(options, {
preflight: async (input, ctx, { phase }) => ({ status: 'ready' }),
execute: async (input, ctx) => refundOrder(input, ctx),
})Preflight must remain free of effects. The check and the eventual write are separate operations: enforce atomic constraints or transactions in execute, and use ctx.idempotencyKey for replay-safe effects. Do not rely on a preflight check alone to prevent concurrent writes.
The agent loop
The provider-agnostic agent turn — the model→tools→approval→model state machine, the hooks seam that makes it replay-safe, and the inline vs durable runners that drive it.
Component registries and server rendering
Tool presentations, custom components, and shared web/WhatsApp table and chart exports.