Governance read-model
The read/analytics side of the agent — spend, usage trend, run lifecycle, tool stats, reliability, and a cross-thread approvals inbox over the persisted tables, exposed as /agent/governance/* routes.
Everything the agent does is persisted; the governance read-model is the read/analytics side of that data — cost and usage rollups, the run lifecycle, per-tool stats, run reliability, and a cross-thread approvals inbox. It lives behind one interface, AgentGovernanceQueries, so the dashboard SPA and the Telescope tab consume the same rolled-up numbers.
Separate from the store on purpose
AgentStore owns the write/thread path; AgentGovernanceQueries owns the read/analytics path (agent_token_usage ⋈ agent_model_pricing, tool calls, threads, runs). A store adapter implements both — the Lucid read-model reads the same tables the Lucid store writes.
Wire it
import { defineConfig, stores, pricingStores, governanceQueries } from '@adonis-agora/agent'
export default defineConfig({
model: () => aiSdkModel(openai('gpt-4o-mini')),
store: 'lucid',
stores: { lucid: stores.lucid() },
pricingStore: pricingStores.lucid(),
governanceQueries: governanceQueries.lucid(),
})The factory receives the already-resolved pricingStore, so the read-model prices its cost rollups against the same live prices the loop's cost fold uses. See Quota & cost for pricing.
Omitting `governanceQueries` does not turn the read-model off
It defaults to the Lucid read-model whenever your main store is Lucid — that is the useful default, since the read-model reads the very tables the store just wrote. Setting it explicitly, as above, is documentation rather than activation.
To actually have no read-model, say so: governanceQueries: false. With no Lucid main store there is nothing to default to, and it stays absent either way.
And note that the read-model alone does not mount the routes. Mounting requires the read-model and a governanceAuthorize gate — see below.
Who may read it
These routes serve cross-actor data — every actor's spend, usage, threads, runs, and pending approvals — so unlike the chat routes they are not owner-scoped. Mounting them takes both halves:
governance !== undefined && governanceAuthorize !== undefinedThe read-model is what there is to read; the gate is who may read it. Neither alone mounts anything. The read-model is usually already there by default, so in practice the gate is the switch:
- Omit
governanceAuthorizeand/agent/governance/*is not mounted at all: every one of these paths answers404. Fail-closed by omission — an ungated cross-actor read-model would hand every actor's spend, usage, threads and approvals to any authenticated caller. The provider logs a boot warning naming both ways forward. - Set it — typically an ADMIN check — and the routes mount gated: each resolves the actor through the same resolver as the chat routes (
401on failure), then runsgovernanceAuthorize(403on deny — the gate is fail-closed, so a throw denies too). - Set
governanceAuthorize: () => trueto deliberately restore the old behaviour of letting ANY authenticated actor read them — the historical default, but now greppable and reviewable rather than implicit.
GET /agent/approvals/mine is unaffected by all of this: it stays mounted whenever the read-model resolves, gate or no gate, and is always scoped to the calling actor's own pending approvals.
The console needs this gate too
@adonis-agora/agent-dashboard is a browser consumer of these very routes, so it also refuses to mount without governanceAuthorize — a console whose every panel but Quota 404s is worse than no console. One gate brings back both. Per-actor chat records are separately protected by object-level ownership.
The routes
All read-only (GET), all mounted only when a governanceAuthorize gate exists, all authenticated and gated by it (governance is cross-actor data). from/to are inclusive UTC days (YYYY-MM-DD); limit defaults to 50, clamped to 200.
| Method & path | Purpose |
|---|---|
GET /agent/governance/spend/model | Per-model token + cost rollup over the range. |
GET /agent/governance/spend/actor | Per-actor token + cost rollup over the range. |
GET /agent/governance/usage/trend | The daily token + cost trend over the range. |
GET /agent/governance/tool-calls/recent | Newest-first recent tool-call activity feed. |
GET /agent/governance/threads/recent | Newest-first recent thread activity feed. |
GET /agent/governance/runs | Filterable, cursor-paginated run list, newest-first. Query: actor?, agent?, status?, from?, to?, after?, first?. |
GET /agent/governance/runs/:id | One run's full trace — run + messages + tool calls + approvals + usage — or null. |
GET /agent/governance/approvals/pending | Cross-thread HITL approvals inbox, oldest first. Query: actor?, limit?. |
GET /agent/governance/tools/stats | Per-tool call/failure/rejection/latency rollup. Query: from?, to?. |
GET /agent/governance/reliability | Success/failure/cancel rates + mean settled duration. Query: from?, to?. |
Two more mount only when a pricingStore is also bound — the store already exists for the loop's cost fold, and these expose it:
| Method & path | Purpose |
|---|---|
GET /agent/governance/pricing | Every model's current per-1M rates. |
POST /agent/governance/pricing | Upsert one model's rates. Body: { modelId, inputPricePer1m, outputPricePer1m, cacheWritePricePer1m?, cacheReadPricePer1m? }. A malformed body is 400; the write is an atomic supersede, so the previous current row is retired in the same breath. |
And one route answers 501 rather than 404 when it is unsupported:
| Method & path | Purpose |
|---|---|
GET /agent/governance/threads/:id | One thread's drill-down: metadata, lifetime usage rollup, recent runs and messages — or null when unknown. threadDetail is an optional SPI method, so a third-party adapter that predates it gets a 501 instead of a crash. |
The response payloads
Every route returns the return value of its AgentGovernanceQueries method verbatim. These are the shapes a client can rely on.
Rollups
spendByModel → ModelSpendRow[], spendByActor → ActorSpendRow[], usageTrend → UsageTrendPoint[]:
interface ModelSpendRow { modelId: string; requests: number; inputTokens: number; outputTokens: number; costUsd: number }
interface ActorSpendRow { actorRef: string; actorLabel?: string; requests: number; totalTokens: number; costUsd: number }
interface UsageTrendPoint { day: string; totalTokens: number; costUsd: number }costUsd here is non-nullable, and an unpriced model contributes 0 — see the warning in Quota & cost.
Activity feeds
interface ToolCallActivityRow { toolCallId: string; toolName: string; toolType: string; status: string; threadId: string; createdAt: string }
interface ThreadActivityRow {
threadId: string; title: string
actorRef: string; actorLabel?: string
messageCount: number; totalTokens: number; lastActivityAt: string
}Runs
listRuns takes the ecosystem's forward-only cursor parameters and returns a CursorPage — the same
shape @adonis-agora/filter uses, so every @adonis-agora/* listing paginates identically:
interface CursorParams { after?: string; first?: number } // forward-only: no before/last
interface CursorPage<T> {
items: T[]
nextCursor: string | null // pass back as `after`; null on the last page
prevCursor: string | null // always null here — the read-model is forward-only
hasNext: boolean
hasPrev: boolean // always false, likewise
}
interface ListRunsFilter extends CursorParams { actor?, agent?, status?, from?, to? }
type ListRunsResult = CursorPage<RunSummaryRow>
interface RunSummaryRow {
runId: string; threadId: string
actorRef: string; actorLabel?: string; tenantRef: string | null
agentName: string | null
status: 'running' | 'completed' | 'failed' | 'cancelled'
startedAt: string
finishedAt: string | null // null while still running
durationMs: number | null // likewise
stepCount: number
inputTokens: number; outputTokens: number
costUsd: number | null // null when nothing priced it
error: string | null
durable: boolean // true under the durable runner
}Pass nextCursor back as ?after= for the next page; null means that was the last one. Page size is
?first= (default 50, clamped to 200). prevCursor/hasPrev are constants, not a bug: neither backend
behind a paginated @adonis-agora/agent surface can page backwards, so the fields are kept — to match
@adonis-agora/filter's CursorPage byte for byte — rather than dropped.
runDetail is the full trace, or null:
interface RunDetail {
run: RunSummaryRow
messages: RunMessageRow[] // { id, role, content, createdAt }
toolCalls: RunToolCallRow[] // { toolCallId, toolName, toolType, status, input, output, error, executionMs, createdAt }
approvals: RunToolCallRow[] // the subset of toolCalls still `pending_approval`
usage: RunUsageRow[] // { modelId, purpose, inputTokens, outputTokens, costUsd, createdAt }
}Approvals, tools, reliability
interface PendingApprovalRow {
toolCallId: string; toolName: string; input: unknown
threadId: string
runId: string | null // null for a call recorded before run tracking existed
actorRef: string; actorLabel?: string
requestedAt: string
}
interface PerToolStatRow {
toolName: string; toolType: string
calls: number; failed: number; rejected: number
avgDurationMs: number | null // null when no call recorded a duration
}
interface RunReliability {
runs: number; completed: number; failed: number; cancelled: number; running: number
successRate: number; failureRate: number; cancelRate: number // 0 when runs === 0
avgDurationMs: number | null // mean over SETTLED runs, null when none settled
byAgent?: RunAgentBreakdownRow[] // { agentName: string | null, runs, failed, successRate }
trend?: RunTrendPoint[] // { day, runs, failed }, oldest first
}byAgent and trend are optional so an adapter written before they existed stays valid; a consumer renders an empty section rather than failing when they are absent.
Thread detail
interface GovernanceThreadDetail {
threadId: string; title: string
actorRef: string; actorLabel?: string
createdAt: string; updatedAt: string
deleted: boolean // soft-deleted threads are still readable here
usage: {
totalTokens: number
costUsd: number | null // null when the thread has no usage rows at all
runCount: number
messageCount: number
}
runs: RunSummaryRow[] // newest-first, capped by the adapter
messages: RunMessageRow[] // likewise
}Unlike the rollups, this one keeps a null cost when there is nothing to sum — though a thread whose rows are all unpriced still totals 0, same as everywhere else.
Pricing
The pricing store is a two-method SPI, and the routes above are a thin shell over it:
interface AgentPricingStore {
upsertModelPrice(input: ModelPriceInput): Promise<void>
listCurrentPrices(): Promise<CurrentModelPrice[]>
}
interface ModelPriceInput {
modelId: string
inputPricePer1m: number
outputPricePer1m: number
cacheWritePricePer1m?: number // omitted → priced at the input rate
cacheReadPricePer1m?: number // likewise
}
interface CurrentModelPrice extends ModelPriceInput {
effectiveFrom: string
}upsertModelPrice is an atomic supersede: the model's prior current row is retired and the new one inserted as current in one step, so the cost fold always joins to exactly one live price per model with no window where two rows race. For a batch, seedModelPrices(store, [...]) applies them in order.
estimateCost(usage, price) is the pure function every surface shares — the SQL adapter and the in-memory one both call it, so a rollup and a run detail can never disagree about what a turn cost. Its arithmetic is documented in Quota & cost.
The matching AgentGovernanceQueries methods for the read routes are spendByModel, spendByActor, usageTrend, recentToolCalls, recentThreads, threadDetail?, listRuns, runDetail, pendingApprovals, perToolStats, and runReliability.
The per-actor approvals inbox
There's one more route mounted alongside the read-model that is not behind governanceAuthorize:
| Method & path | Purpose |
|---|---|
GET /agent/approvals/mine | The calling actor's own pending HITL approvals, oldest first. Query: limit? (default 50, clamped to 200). |
It reads the same store as the governance inbox — pendingApprovals({ actor }) filters by the owning run's actor_ref — but is always scoped to actor.id, so it needs no admin gate. This is the route a non-admin surface (a coordinator's chat, an operator console) polls to discover its own suspended tool calls, even when the cross-actor GET /agent/governance/approvals/pending inbox above is ADMIN-only. The two are complementary: approvals/mine is per-actor and always available; governance/approvals/pending is platform-wide and governance-gated.
Run lifecycle tracking
Both runners record each run (turn) to an agent_run table through store.recordRunStart(...) / recordRunEnd(...), tracked through running → completed | failed | cancelled with stepCount, token totals, an error, and a durable flag (true under the durable runner). A run's messages, tool calls, and usage back-reference its run_id.
A delegation's child run also records parent_run_id — the run that asked for it — surfaced as RunSummaryRow.parentRunId and null for a turn a person started. Both runners fill it. Without it a delegation's tokens and cost read as an orphan turn on every surface built over these rows: the durable engine journals the parent→child edge, but only in its own journal, which nothing reading run rows can join to.
The run tables come with the rest
agent_run and the run_id back-references are part of the one create_agent_tables migration — there is no separate run-tracking migration to remember. On a database provisioned before run tracking existed, that migration ALTERs the missing run_id columns in rather than skipping them, and the same repair adds agent_run.parent_run_id to a database provisioned before that column existed. Adapters backed by a store without run recording return an empty page / null / zeros from the run-lifecycle methods, so the surface degrades gracefully.
Actor labels
Persisted actorRefs are opaque by design (no FK into your user table). To render names instead of raw refs on governance surfaces, bind an actor directory — the read-side dual of the actor resolver:
import { actorDirectories } from '@adonis-agora/agent'
export default defineConfig({
// ...
actorDirectory: actorDirectories.memory({ labels: { u_1: 'Ada Lovelace' } }),
})ActorDirectory.resolveDisplay(refs) returns a ref → label map. Every route above that returns an actorRef then also returns an optional actorLabel:
{
"actorRef": "u_8f21c3",
"actorLabel": "Ada Lovelace",
"requests": 42,
"costUsd": 1.87
}Three properties are worth relying on:
- One lookup per page. The distinct refs in a response are resolved in a single batched call, not one per row — so a 200-row run list is one query against your user table, not 200.
- An unknown ref omits the field entirely — not
null, not a copy of the ref.actorLabel ?? actorRefis the whole rendering rule, and an unresolved ref stays visibly unresolved. - A failure is invisible. If your directory throws, the rows come back with their raw refs and the request succeeds. A display name is never worth failing a governance page over.
Bind one over your own user table:
export default defineConfig({
actorDirectory: {
async resolveDisplay(refs) {
const users = await User.query().whereIn('id', [...refs])
return Object.fromEntries(users.map((user) => [user.id, user.fullName]))
},
},
})Omit it and every surface renders raw refs, which is a perfectly reasonable default for an internal console.
In tests
InMemoryGovernanceQueries (with a settable InMemoryModelPrice table) implements the same SPI over in-memory data, so a test exercises the identical read-model code paths without a database. It's in the testing kit.
Quota & cost
Meter agent spend — a fail-closed daily token quota checked before the model runs, a per-turn usage ledger with real or estimated cost, and the model pricing table.
Governance console
The bundled React console — nine sections over the governance read-model, an approvals inbox that decides, a pricing editor, and the gate that decides whether it mounts at all.