Agora
Governance

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.

A dependency-light React SPA over the agent's own governance read-model — spend by model and by actor, the usage trend, a filterable run list with full traces, thread drill-downs, per-tool stats, reliability, an approvals inbox, today's quota, and a model-pricing editor. It is bundled into @adonis-agora/agent itself and served by a thin static provider; there is no bundled API, every panel reads the same routes your own client could.

It is already registered

node ace add @adonis-agora/agent registers the dashboard provider along with the agent provider, so there is nothing to install. It mounts at <agentPath>/dashboard/agent/dashboard by default — reading config/agent.ts for the agent path and actorResolver.

Two gates decide whether it mounts at all

The console is a browser client of /agent/governance/*, so it refuses to mount when those routes would not exist:

  • No governanceAuthorize → not mounted. Every panel but Quota would 404, which is worse than no console. Set the gate in config/agent.ts (typically an ADMIN check) to bring the routes and the console back together, or governanceAuthorize: () => true to deliberately restore the old behaviour of letting any authenticated actor read them.
  • governanceQueries: false → not mounted. You turned the read-model off explicitly; the console has nothing to show.

Both log a boot warning naming the responsible knob. (governanceQueries left unset is fine — it defaults to the Lucid read-model when your main store is Lucid.)

Configuration

The console's settings live in the same config/agent.ts object, under dashboard:

config/agent.ts
import { defineConfig } from '@adonis-agora/agent'

export default defineConfig({
  // ...
  governanceAuthorize: (actor) => actor.roles?.includes('ADMIN') ?? false,

  dashboard: {
    enabled: true,
    path: '/ops/agent',
    authorize: (actor) => actor.roles?.includes('ADMIN') ?? false,
    onUnauthenticated: (ctx) => ctx.response.redirect('/login'),
  },
})
KeyDefaultMeaning
enabledtruefalse keeps the routes off entirely.
path<agentPath>/dashboardWhere the SPA mounts.
authorizeAn extra gate, run after the actor resolves. Return false (or throw) → 403.
onUnauthenticatedRuns when the actor resolver itself rejects the caller.
accessDeniedTweak or replace the access-denied page a refused browser sees.

authorize and governanceAuthorize take the same shape deliberately, so one predicate can gate both the JSON routes and the console:

const isAdmin = (actor: Actor) => actor.roles?.includes('ADMIN') ?? false

export default defineConfig({
  governanceAuthorize: isAdmin,
  dashboard: { authorize: isAdmin },
})

Note the asymmetry: governanceAuthorize gates the data, dashboard.authorize gates the page. Omitting the latter means any actor your resolver accepts can load the console — but every panel in it still goes through governanceAuthorize, so they will see an empty shell of 403s. Setting both is what produces a coherent experience.

The access-denied page

A rejected request gets a 401 (nobody resolved) or a 403 (authorize said no) — and, since the console's routes are only ever hit by a browser, the body is a real page rather than JSON: a dark card in the console's visual language showing the status, a sentence explaining the refusal and a "Back to app" link. It carries no inline script, so a nonce'd script-src CSP cannot break it (its one inline <style> picks up @adonisjs/shield's request nonce). Outside production, a thrown resolver/authorize message is shown on it as a developer detail.

Tweak it with accessDenied — every field optional:

config/agent.ts
dashboard: {
  accessDenied: {
    brand: 'Entre Textos',           // eyebrow + <title>; default "Agent"
    title: 'Sem acesso',             // default depends on the refusal
    message: 'Peça ao admin para liberar o console de governança.',
    homeHref: '/admin',              // "Back to app"; default "/", `false` hides it
    homeLabel: 'Voltar',
    accent: '#f59e0b',               // any CSS colour; default: the console's violet
  },
}

Or replace it. Pass a function and it receives the refusal (status, reason'unauthenticated' or 'forbidden'basePath, detail, and the CSP nonce when there is one) plus the HttpContext. Return an HTML string to have it served with the right status; answer the request yourself and return nothing to make the provider stand down:

config/agent.ts
dashboard: {
  accessDenied: (info, ctx) => {
    if (info.reason === 'unauthenticated') {
      ctx.response.redirect(`/login?next=${encodeURIComponent(info.basePath)}`)
      return
    }
    return `<!doctype html><title>${info.status}</title><h1>Sem acesso</h1>`
  },
}

Redirecting instead of returning the page

For a console a human opens in a browser, a redirect to your login page is often friendlier than any page. onUnauthenticated gets the HttpContext, and if it sets a Location header the provider stands down and lets the redirect through:

dashboard: {
  onUnauthenticated: (ctx) => ctx.response.redirect('/login?next=/agent/dashboard'),
}

The same applies on the 403 path: an authorize that redirects instead of returning false sends the caller wherever you point it. The hook is context-only and never receives an actor — it runs precisely when there isn't one, and the resolver's contract is that it never invents an identity.

Thrown messages are dev-only

When the resolver or authorize throws, the page shows the error message only outside production. In production it is the generic unauthorized / forbidden copy.

What is in it

Nine sections, reachable by hash (#runs, #approvals, …), with a from/to day picker scoping the Overview and a light/dark toggle.

SectionWhat it shows
OverviewSpend and token stats, spend by model (donut + table), the usage trend, spend by actor.
RunsFilterable, cursor-paginated run list (status, agent, actor, date range). A row opens the full trace.
ThreadsRecent threads, and a drill-down with lifetime usage, recent runs and recent messages.
Tool callsThe recent tool-call feed.
ApprovalsThe cross-actor HITL inbox — and the approve/reject buttons.
ToolsPer-tool rollup: calls, failures, rejections, mean duration. Sortable.
ReliabilityRun-outcome breakdown and reliability stats.
QuotaToday's spend for the calling actor — the one per-actor panel.
PricingCurrent per-1M model rates, and an editor to upsert them.

It is not a read-only console

Two sections write. Approvals posts to /agent/tool-call/approve and /agent/tool-call/reject, which resumes or rejects a suspended run — a real, irreversible side effect on someone else's conversation. Pricing posts to /agent/governance/pricing, which changes what every subsequent turn costs and, because rollups are computed against current prices, what your spend reports say.

Gate it accordingly. dashboard.authorize is the switch, and "anyone authenticated" is not the right value for it.

The run trace (a row in Runs) is where most investigations end up: the run's summary stats, its message timeline, every tool call with the pending-approval subset called out, and the usage ledger row by row — which is where you can see a null cost and know the model was unpriced rather than free.

Pricing shows its own panel rather than a generic error when the API answers 501; that means no pricingStore is bound, so there is nothing to edit. See Quota & cost.

Reading the routes yourself

The console's fetch client is exported, so your own admin UI can read the same data without reimplementing the endpoints:

import { AgentClient } from '@adonis-agora/agent-dashboard/client'

const client = new AgentClient({ baseUrl: '/agent' })

const spend = await client.spendByModel({ fromDay: '2026-03-01', toDay: '2026-03-07' })
const runs = await client.listRuns({ status: 'failed', first: 50 })
const trace = await client.runDetail(runs.items[0].runId)
const thread = await client.threadDetail(trace.run.threadId)
const prices = await client.pricing()

Twelve read methods and three writes (approveToolCall, rejectToolCall, upsertPrice). It is a plain fetch wrapper — no React, no state — so it works from a server-side script as readily as from a browser.

The standalone package

@adonis-agora/agent-dashboard also ships as its own package with its own provider, serving the identical SPA, for apps that wired it before the bundled provider existed or that want its independent release cadence. Register it by hand:

adonisrc.ts
providers: [
  () => import('@adonis-agora/agent/agent_provider'),
  () => import('@adonis-agora/agent-dashboard'),
]

Both providers read the same agent.dashboard block and run the same gating logic. Register only one — mounting both at the same path throws AdonisJS's duplicate-route error at boot, so if you add the standalone one, drop @adonis-agora/agent/dashboard_provider from adonisrc.ts first.

No `node ace add` for the standalone package

It exposes no configure hook, so node ace add @adonis-agora/agent-dashboard has nothing to run. Install it and register the provider manually as above.

For live in-flight activity alongside the rest of your app's instrumentation, see the Telescope "Agent" tab — and set its runHref/threadHref to /agent/dashboard so a Telescope panel links straight into a full trace here.

On this page