@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
413 lines (340 loc) • 19.3 kB
Markdown
---
name: agent-tools
description: >-
Author and register governed tools for @adonis-agora/agent: @AiTool decorated classes,
BaseTool/ReadTool/ActionTool static-tool subclasses, defineTool functional tools,
discovery from app/agent_tools via the .adonisjs/agent/tools.js barrel (toolsHook with
source/importAlias/output) vs the runtime scan fallback, Standard Schema inputs
(Zod/Valibot/ArkType), AiToolCtx actor scoping, constructor DI (@inject, lazy + cached),
roles/ability gating under defaultRoles (open by default), and the dataTool governed read-only
SQL satellite. Use for "write a tool", "my tool is never called", "tool forbidden",
"agent_tools barrel not generating", or "let the agent query the database".
metadata:
type: core
library: "@adonis-agora/agent"
library_version: "0.25.2"
framework: adonisjs
sources:
- "DavideCarvalho/adonis-agent:packages/adonis/docs/authoring/tools.mdx"
- "DavideCarvalho/adonis-agent:packages/adonis/docs/authoring/data-satellite.mdx"
- "DavideCarvalho/adonis-agent:packages/adonis/src/ai-tool-ref.ts"
- "DavideCarvalho/adonis-agent:packages/adonis/src/tool-discovery.ts"
- "DavideCarvalho/adonis-agent:packages/adonis/src/confirmed-tool.ts"
---
# Writing governed tools for @adonis-agora/agent
A tool is a named capability with a typed input schema; the model decides *when* to call
it, the loop decides *whether it may* (role gate) and *how* (auto-execute vs human
approval). `read` auto-executes, `action` pauses for HITL approval. There are two ways to
author one — a class or a function — and both register the same way.
## Setup
Create `app/agent_tools/get_weather.ts`. The Assembler init hook registered by
`node ace configure @adonis-agora/agent` generates the `.adonisjs/agent/tools.js` barrel
at build/dev time, and the provider registers every export at boot:
```ts
// app/agent_tools/get_weather.ts
import { z } from 'zod'
import { ReadTool } from '@adonis-agora/agent'
import type { AiToolCtx } from '@adonis-agora/agent'
const input = z.object({ city: z.string() })
type Input = z.infer<typeof input>
export default class GetWeather extends ReadTool<Input, { tempC: number }> {
// No `kind` — the base pins 'read'. Type-checks bare, no satisfies needed.
static tool = {
name: 'get_weather',
description: 'Current weather for a city.',
input,
roles: ['MEMBER'], // omit → config.defaultRoles ([] — open — by default)
}
async execute({ city }: Input, _ctx: AiToolCtx) {
return { tempC: 21 }
}
}
```
Prefer a function? `defineTool({ name, input, execute, present, ... })` or the existing
`defineTool(options, execute)` returns a branded `{ spec, handler }`
discovery picks up identically — or pass it straight to `defineConfig({ tools: [...] })`.
## Core patterns
### Pattern 1 — mutating tools as `ActionTool` (HITL approval)
`kind: 'action'` records the call `pending_approval` and suspends the run until a client
posts approve/reject to `/agent/tool-call/approve|reject`. Dependencies come from the IoC
container — declare them in the constructor with `@inject()`:
```ts
// app/agent_tools/purge_cache.ts
import { inject } from '@adonisjs/core'
import { z } from 'zod'
import { ActionTool } from '@adonis-agora/agent'
import type { AiToolCtx } from '@adonis-agora/agent'
const input = z.object({ key: z.string() })
type Input = z.infer<typeof input>
@inject()
export default class PurgeCache extends ActionTool<Input, { purged: string }> {
constructor(private readonly cache: CacheService) {
super()
}
static tool = {
name: 'purge_cache',
description: 'Purge one cache key.',
input,
ability: 'cache.purge', // consumed by an ability-aware RolesPolicy (authz adapter)
}
async execute({ key }: Input, _ctx: AiToolCtx) {
await this.cache.forget(key)
return { purged: key }
}
}
```
Class tools resolve lazily through the container on first invocation, then cached — so a
constructor needing a peer service fails nothing at boot.
Source: `packages/adonis/docs/authoring/tools.mdx` ("Constructor DI").
An action's write and the checkpoint that records it are two writes: a worker that dies between
them re-runs the tool on recovery. Pass `ctx.idempotencyKey` (`<runId>:<toolCallId>`, the same for
every execution of one call) on as the downstream idempotency key — a provider's
`Idempotency-Key`, a unique column on the inserted row, the `id` of a workflow the tool starts — so
the second attempt lands on the first. Over MCP the server mints one per `tools/call`
(alongside `toolCallId` and `pageContext: { channel: 'mcp' }`). It is absent on a direct
`registry.invoke`, so fall back to a key of your own there.
Source: `packages/adonis/src/spi/tool.ts` (`AiToolCtx.idempotencyKey`).
### Pattern 2 — let the agent query SQL with `dataTool` (fail-closed)
`dataTool` validates a single SELECT, enforces a REQUIRED role→group→table allow-list
(there is no allow-all), rewrites in an optional tenant scope, injects LIMIT, and
truncates oversized results before they hit the model's context:
```ts
// config/agent.ts
import { defineConfig, dataTool } from '@adonis-agora/agent'
import db from '@adonisjs/lucid/services/db'
export default defineConfig({
model: () => aiSdkModel(openai('gpt-4o-mini')),
tools: [
dataTool({
db: db.connection('readonly'), // point at a read-only replica
tableAccess: { // REQUIRED — fail-closed, no implicit allow-all
roleGroups: { MEMBER: ['sales'], ADMIN: ['sales', 'ops'] },
tablesByGroup: { sales: ['orders', 'products'], ops: ['audit_log'] },
},
tenant: { tenantColumn: 'tenant_id', scopedTables: ['orders'] },
maxRows: 100, // LIMIT injected when the query has none
}),
],
})
```
The model sees a tool named `executeSql` taking `{ sql: string }`. Two governance layers
stack: `roles`/`ability` gate whether the tool may be called at all; `tableAccess` +
`tenant` gate what a permitted call may read.
Source: `packages/adonis/docs/authoring/data-satellite.mdx`.
### Pattern 3 — move the scan directory with `toolsHook`
To keep tools in a shared package or differently-named directory, call the hook factory
yourself in `adonisrc.ts`:
```ts
// adonisrc.ts
import { defineConfig } from '@adonisjs/core'
import { toolsHook } from '@adonis-agora/agent/hooks/tools'
export default defineConfig({
hooks: {
init: [
async () => ({
default: toolsHook({ source: 'app/ai/tools', importAlias: '#ai_tools' }),
}),
],
},
})
```
Change `source` and `importAlias` together; leave `output` alone (default
`.adonisjs/agent/tools.ts`) — moving the output makes the provider silently fall back to
the runtime scan you were replacing.
Source: `packages/adonis/docs/authoring/tools.mdx` ("Configuring the generator"),
`packages/adonis/src/hooks/tools.ts` (`ToolsHookOptions`).
### Pattern 4 — a write reachable over MCP: `defineConfirmedTool`
An `action` is off the MCP surface by default (`actions: 'refuse'` in `config/mcp.ts` —
neither listed nor callable, since there is no approval channel, and Claude.ai/Desktop have no
elicitation). For a write that must be callable there, put the gate inside the tool: the
first call previews and returns a signed `confirmToken`; the same arguments plus
`confirm: true` and the token commit. (`actions: 'execute'` opts out and runs actions when
called — the MCP client's own confirmation stands in for the loop's approval gate.)
```ts
import { defineConfirmedTool, LucidConfirmTokenStore } from '@adonis-agora/agent'
export const refundOrder = defineConfirmedTool(
{
name: 'refund_order',
description: 'Refund an order. Returns a preview first; confirm to commit.',
input: z.object({ orderId: z.string() }), // confirm / confirmToken are added for you
roles: ['ADMIN'],
secret: () => env.get('APP_KEY').release(), // required, no default
store: new LucidConfirmTokenStore(db), // without it the token is NOT single use
},
{
prepare: async ({ orderId }, ctx) => loadRefundableOrder(orderId, ctx.actor), // throw to refuse
preview: (order) => ({ summary: `Refund ${order.total}?`, data: order }), // writes nothing
commit: async (order) => ({ summary: 'Refunded.', data: await refund(order) }),
},
)
```
`prepare` runs on the preview and again on the confirmation — put every rule the commit
relies on there. The token is bound to tool + `ctx.actor.id` + `ctx.actor.tenantRef` +
arguments and expires (`ttlMs`, default 15 min).
Source: `packages/adonis/docs/mcp.mdx` ("Writes with a human gate"),
`packages/adonis/src/confirmed-tool.ts`.
### Pattern 5 — present a domain result through the component catalog
Keep `execute` returning domain data. An optional `present(output, ctx)` on a class or
functional handler returns one component presentation, an array, or `undefined`. The
registry emits presentations through `ctx.emitUi` and preserves the domain result for
REST/MCP callers. Denied or failed execution does not present; presentation errors do
not retry a successful write. Methods keep their receiver, including injected services.
```ts
import { defineTool } from '@adonis-agora/agent'
import { table } from '@adonis-agora/agent/genui'
import { z } from 'zod'
export const listRecords = defineTool({
name: 'list_records',
description: 'List records visible to the current actor.',
input: z.object({ limit: z.number().int().min(1).max(100) }),
execute: async ({ limit }, ctx) => recordsFor(ctx.actor, limit),
present: async (records) => table({
columns: [{ key: 'name', label: 'Name' }],
rows: records,
}),
})
```
`table` and `chart` present the builtin `DataTable` and `Chart` definitions: put them in the
genui catalog (`BUILTIN_COMPONENTS` from `@adonis-agora/agent/genui/builtins`, or the two
definitions) and register app renderers under those names. Builtins carry no visuals.
`genui({ catalog })` also offers the model a `ui__render` tree tool over that catalog (the
default mode; `mode: 'per-component'` gives `ui__show_*` tools instead). In tree mode,
`componentTools: ['Sandbox']` also registers a flat `ui__sandbox` tool per named standalone
component whose input IS that component's props (prefix `componentToolPrefix`, default `ui__`).
A tool that knows its component before it reads can push a skeleton first: `ctx.emitUi(name,
{ state: 'loading', … })`, then the data with `{ id }` from that push (the component's props
schema must accept the placeholder variant; mark it `internal: true`). Always end on that id
(data, `empty` or `error`), and only push the skeleton when the component is declared in
`ctx.uiCapabilities` and the turn's channel (`ctx.channel`) is not a messaging channel. A tool that throws persists none
of its pushes. See `packages/adonis/docs/genui-loading-states.mdx`.
Use `createComponent` for custom definitions and one `createComponentRegistry` per app
or tenant. Input `props` may transform; if the transform changes shape or is not
idempotent, declare `outputProps` to validate the normalized JSON persisted in frames.
Do not send renderer functions or image/PDF bytes as props. Set `onPresentationError(error,
{ toolName, toolCallId, runId, threadId })` in `defineConfig` — it is wired for turns
and proposal executions (the loop/executor fill in the call, run and thread); omit it
and the provider logs a warning on the app logger. Inside a tool,
`ctx.onPresentationError` carries the same details. SSR and capture use optional server entries,
separate from tool execution and browser chat hooks.
Source: `packages/adonis/docs/authoring/component-rendering.mdx`,
`packages/adonis/src/spi/tool.ts`, `packages/adonis/src/tool-result-presentation.ts`.
## Feature flags and per-user gates — `enabled`, `isEnabled()`, `canUse(actor)`
```ts
// A flag with no service behind it: on the options (boolean, or a predicate re-read every turn).
static tool = { name: 'search_docs', description: '...', input, enabled: () => env.get('DOCS') === true }
// A flag or entitlement that needs a service: methods on the (container-resolved) class.
isEnabled() { return this.flags.on('docs') }
canUse(actor: Actor) { return this.plans.includesDocs(actor.tenantRef) }
```
`defineTool` / `defineConfirmedTool` take `enabled` and `canUse` as options; `mcpServers[]`
takes both per server. Order on the offered list AND on invoke: allow-list → `enabled` →
`RolesPolicy` → `canUse`; none can widen. `invoke` additionally re-asks `describe()` with the
call's actor, thread, agent and `uiCapabilities`: a tool answering `available: false` for that
scope was never offered, and calling it anyway throws `ToolNotFoundError`. A disabled tool is
never shown to the model and invoking one throws `ToolDisabledError` (not `ToolForbiddenError`). Prefer these to registering a
tool conditionally, and to refusing inside `execute` (which costs the model a turn).
Source: `packages/adonis/docs/authoring/tools.mdx`, `packages/adonis/src/tool-filters.ts`,
`packages/adonis/src/tool-registry.ts` (`validated`, `describeScopeOf`).
## Common mistakes
### CRITICAL — shipping a privileged tool without `roles`
```ts
// Wrong — no roles → inherits defaultRoles, which is [] (open) by default: every actor
// except personal agents (A2A, Poppy), an anonymous visitor included, is offered this tool.
static tool = { name: 'refund_order', kind: 'action', description: '...', input }
```
```ts
// Correct — declare who may invoke (or set defaultRoles: ['ADMIN'] to close every tool by default).
static tool = { name: 'refund_order', kind: 'action', description: '...', input, roles: ['ADMIN'] }
```
Mechanism: `definitionsFor` filters the offered tool list through the role gate BEFORE
each model turn, and `invoke` re-checks it. A tool with no `roles` takes `defaultRoles`;
an empty list is no restriction (since 0.46 — before, it denied everyone; `emptyRoles: 'deny'`
in the config, or `ClosedRolesPolicy`, keeps it closed) — except for an actor with the
`personal_agent` role (personal agents — A2A/PACT and Poppy conversations): it reaches only
tools that declare one of its roles, so a tool with no `roles` is out of its reach even when
open by default. Write a tool for them with `roles: ['personal_agent']` (anyone's agent) or
`['scope:<id>']` (a granted scope: a PACT delegation scope, or `scope:poppy:read` / `scope:poppy:write` /
a custom Poppy scope).
> **Experimental:** Poppy (Personal Agent Protocol) is a Draft 0.1 spec still in development
> (https://personalagentprotocol.org/docs/spec). The Poppy surface implements Draft 0.1 and WILL change
> as the spec evolves — possibly breaking, outside semver majors while it is a draft.
> To test your Poppy surface end to end, the package ships a reference **personal agent**:
> `@adonis-agora/agent/poppy-client` (framework-agnostic library, no AdonisJS imports) and the
> `poppy-agent` CLI (`npx poppy-agent --help`; `--insecure-dev` for a local http app). Source:
> `packages/adonis/docs/poppy-client.mdx`.
> The other direction — the app's own agent acting at OTHER Poppy companies for its users — is
> `@adonis-agora/agent/poppy_connections_provider` + `config/poppy_connections.ts`: allow-listed
> companies (default none), connections sealed with the app key, `poppy_*` tools where every company
> write (`poppy_write`, `poppy_request`) is an `action` behind the normal confirmation. Source:
> `packages/adonis/docs/poppy-connections.mdx`.
An `action` still parks on approval — but by default the
requester approves, so for a public chat that is only a confirmation.
Source: `packages/adonis/docs/governance/authorization.mdx`,
`packages/adonis/src/tool-registry.ts` (`DefaultRolesPolicy.can`),
`packages/adonis/src/a2a/gate.ts` (`personalAgentGate`).
### HIGH — trusting an id from the model's arguments instead of `ctx.actor`
```ts
// Wrong — the MODEL chose this customerId; any caller can read anyone's orders.
async execute({ customerId }: Input) {
return Order.query().where('customer_id', customerId)
}
```
```ts
// Correct — scope to the acting identity the resolver authenticated.
async execute(_input: Input, ctx: AiToolCtx) {
return Order.query().where('customer_ref', ctx.actor.id)
}
```
Mechanism: tool arguments are untrusted model output; identity is single-sourced on
`ctx.actor` (`id`, `roles`, `tenantRef`) populated from the server-side actor resolver.
Source: `packages/adonis/docs/authoring/tools.mdx` ("The tool context"),
`packages/adonis/src/spi/tool.ts`.
### MEDIUM — renaming the generated barrel's output path
```ts
// Wrong — the provider imports .adonisjs/agent/tools.js; elsewhere = silently ignored.
toolsHook({ source: 'app/ai/tools', importAlias: '#ai_tools', output: '.adonisjs/mytools.ts' })
```
```ts
// Correct — change source + importAlias together; leave output at its default.
toolsHook({ source: 'app/ai/tools', importAlias: '#ai_tools' })
```
Mechanism: the provider imports the compiled barrel from the fixed
`.adonisjs/agent/tools.js`; a moved file is absent there, so boot falls back to scanning
`app/agent_tools` — which no longer holds your tools — and the agent runs with none of
them, logging only a quiet fallback.
Source: `packages/adonis/docs/authoring/tools.mdx` ("Point `output` elsewhere…"),
`packages/adonis/src/hooks/tools.ts` (`GENERATED_TOOLS_OUTPUT`).
### MEDIUM — debugging "zero tools" after adding one that throws on import
```ts
// Wrong assumption — a throwing tool file aborts discovery, so NOTHING registers.
// app/agent_tools/broken.ts references a service whose `app` is undefined during boot.
```
```ts
// Correct mental model — the bad file is skipped loudly; every OTHER tool still registers.
// Fix the import error surfaced in the logs; the rest of agent_tools keeps working meanwhile.
```
Mechanism: `discoverTools` wraps each dynamic import in its own try/catch, logs the
failure, and continues — so the symptom is one missing tool (the model narrating calls it
was never given), not a dead registry.
Source: `packages/adonis/src/tool-discovery.ts` (`discoverTools` import try/catch and log
message).
### LOW — declaring `kind` on a ReadTool/ActionTool subclass
```ts
// Wrong — the base already pins kind; the literal widens and fights the inherited static type.
export default class CloseTicket extends ActionTool<Input, Row> {
static tool = { name: 'close_ticket', kind: 'action', description: '...', input }
}
```
```ts
// Correct — truly bare static; the base carries kind.
export default class CloseTicket extends ActionTool<Input, Row> {
static tool = { name: 'close_ticket', description: '...', input }
}
```
Mechanism: `ReadTool`/`ActionTool` pin `static readonly kind`; redeclaring it adds a union
field the inherited `static tool?: BaseToolOptions` doesn't contextualize, so the literal's
`kind: string` widens and type-checking degrades instead of failing.
Source: `packages/adonis/src/base-tool.ts` (`BaseToolOptions`, kind-specific bases),
`packages/adonis/docs/authoring/tools.mdx` ("truly bare").
See also: `agent-governance/SKILL.md` — swapping `rolesPolicy` for ability checks;
`agent-personas-agents/SKILL.md` — narrowing tools per persona/agent.