Agora
Governance

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

config/agent.ts
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 !== undefined

The 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 governanceAuthorize and /agent/governance/* is not mounted at all: every one of these paths answers 404. 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 (401 on failure), then runs governanceAuthorize (403 on deny — the gate is fail-closed, so a throw denies too).
  • Set governanceAuthorize: () => true to 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 & pathPurpose
GET /agent/governance/spend/modelPer-model token + cost rollup over the range.
GET /agent/governance/spend/actorPer-actor token + cost rollup over the range.
GET /agent/governance/usage/trendThe daily token + cost trend over the range.
GET /agent/governance/tool-calls/recentNewest-first recent tool-call activity feed.
GET /agent/governance/threads/recentNewest-first recent thread activity feed.
GET /agent/governance/runsFilterable, cursor-paginated run list, newest-first. Query: actor?, agent?, status?, from?, to?, after?, first?.
GET /agent/governance/runs/:idOne run's full trace — run + messages + tool calls + approvals + usage — or null.
GET /agent/governance/approvals/pendingCross-thread HITL approvals inbox, oldest first. Query: actor?, limit?.
GET /agent/governance/tools/statsPer-tool call/failure/rejection/latency rollup. Query: from?, to?.
GET /agent/governance/reliabilitySuccess/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 & pathPurpose
GET /agent/governance/pricingEvery model's current per-1M rates.
POST /agent/governance/pricingUpsert 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 & pathPurpose
GET /agent/governance/threads/:idOne 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

spendByModelModelSpendRow[], spendByActorActorSpendRow[], usageTrendUsageTrendPoint[]:

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 & pathPurpose
GET /agent/approvals/mineThe 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:

config/agent.ts
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 ?? actorRef is 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:

config/agent.ts
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.

On this page