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.
@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()
}
}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 }
},
)You can also pass functional tools directly to the config instead of a file: defineConfig({ tools: [purgeCache] }).
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. 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.
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(['ADMIN']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 a future@adonis-agora/authzBouncer adapter. The default role-based policy ignores it. Both live on the same seam, so neither is required.
See Authorization for the full governance model.
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
persona?: Persona
pageContext?: PageContext
host?: unknown // optional host handle (e.g. an ORM manager)
}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.
The ctx also carries emitComponent?(name, data) — a tool can push a typed UI component into the run's stream instead of (or alongside) text, and the frontend renders your React component inline. See Generative UI.
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.
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.
Personas & agents
Shape one assistant with personas (prompt + tool allow-list), or run several named agents that hand work to one another through delegatesTo multi-agent delegation.