Governed SQL (data satellite)
Give the agent a single, fail-closed read-only SQL tool — dataTool validates one SELECT, enforces a table allow-list, rewrites in a tenant scope, injects a LIMIT, and truncates oversized results.
dataTool is a ready-made governed read-only SQL tool — a first-class way to let the agent answer questions about real data without handing it the database. Every call is validated as a single SELECT, checked against a table allow-list, tenant-scoped, LIMIT-capped, and size-truncated before a row leaves your app. It's exported from the package root (and the @adonis-agora/agent/data subpath).
Register it
dataTool(config) returns a branded functional tool, so register it in the config tools array or export it from an app/agent_tools module:
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 connection/replica
tableAccess: {
// two-layer role → group → table allow-list — REQUIRED (fail-closed, no allow-all)
roleGroups: { MEMBER: ['sales'], ADMIN: ['sales', 'ops'] },
tablesByGroup: { sales: ['orders', 'products'], ops: ['audit_log'] },
},
tenant: { tenantColumn: 'tenant_id', scopedTables: ['orders'] }, // per-row tenant constraint
maxRows: 100, // LIMIT injected when the query has none
}),
],
})The model sees a tool named executeSql (override with name) whose input is { sql: string } — "a single read-only MySQL SELECT statement".
The pipeline
Every call runs the same fail-closed pipeline:
- Validate — parse and assert the statement is a single
SELECT(noINSERT/UPDATE/DELETE/DDL, no multi-statement). - Table access — every referenced table must pass
tableAccess.canAccess(actor.roles, table); a forbidden table throws with the roles and tables named. There is no implicit allow-all —tableAccessis required. - Tenant scope — if
tenantis configured, rewrite the query to constrain tenant-scoped tables toctx.actor.tenantRef. - Inject
LIMIT— cap unbounded queries atmaxRows(default 100). - Run — through your read-only
db.rawQuery(or a customrunner), normalizing whatever the driver returns. - Truncate — if the serialized rows would exceed ~256 KB, keep the prefix and flag
truncated: true, so one tool result can't blow the model's context.
The result is { rows, rowCount, sql, truncated? } — rowCount is the true count even when rows is truncated, and sql is the final rewritten query.
Tenant pass-through is deliberate
A strictly-undefined tenantRef is the privileged pass-through (all tenants); a null or empty one is not coerced to undefined — that would leak every tenant's rows. Give privileged callers no tenantRef and everyone else a real one.
Configuration
| Field | Default | Meaning |
|---|---|---|
db / runner | — | Read-only Lucid handle (structural) or a custom QueryRunner. One is required. |
tableAccess | — | Table allow-list — a TableAccessPolicy, or a GroupTableAccessConfig ({ roleGroups, tablesByGroup } — roles map to groups, groups to tables). Required. |
tenant | — | Per-row tenant constraint ({ tenantColumn, scopedTables }) injected before the query runs. |
maxRows | 100 | Row cap injected when the query has no LIMIT. |
statementTimeoutMs | off | Reject if the runner hasn't resolved in time (soft guard — pair with a DB-level statement timeout for a hard cap). |
name | executeSql | Tool name the model sees. |
roles / ability | config defaults | Spec-level governance on the tool itself. |
Two governance layers, not one
roles/ability gate whether the model may call the tool at all (the standard authorization seam); tableAccess and tenant gate what a permitted call may read. Defense-in-depth — a leaked tool call still can't read a table the actor's role isn't allowed. node-sql-parser is imported lazily on the first call, so it stays an optional peer.
The building blocks — SqlValidator, GroupTableAccessPolicy, TenantScopeRewriter, injectLimit, loadSqlParser — are exported too, if you want to assemble a bespoke variant.
Attachments & multimodal
Let users attach images and PDFs to a message so a vision-capable model sees them natively — the MessageAttachment shape, the attachment-staging SPI, and the optional POST /agent/attachments upload route.
Asking the user
A structured question set the agent puts to the user before it works — and parks on.