Skip to content

Query the gateway's state

The admin API answers one collection per endpoint. The questions worth asking cross them: which key sits on which profile against which budget, and how much of it is left; which provider’s metering drifted on which day, by how much; what is suspended, and whether the thing it names still exists. Each of those used to be several calls and a script joining the answers. POST /admin/v1/query runs one SQL statement over the same collections as tables, and pistra query is the command in front of it.

Every table is a list endpoint’s response, row for row. keys is what GET /admin/v1/keys returns, budgets is the budgets listing, and so on through profiles, backends, models, mcp_servers, a2a_agents, suspensions, secrets, sources, cluster_members and audit_heads. Two are flattened out of the reconciliation status so they can be joined: reconcile is one row per audited provider and reconcile_verdicts is one row per provider, judged day and token class, which is the drift table.

A table exists for a caller who may call the endpoint it mirrors, and does not exist otherwise. There is no grant for the query door itself. A policy that permits listKeys and nothing else gives its holder a keys table and a profiles table, since profiles read under the keys class, and a statement naming budgets fails with no such table. Forbidding listSecrets removes the secrets table exactly as it removes the endpoint.

Ask the node what it offers you before writing anything:

Terminal window
$ pistra query -socket /run/pistra/admin.sock -tables
keys 3 rows name, metadata (json), profile, unknown_profile, revoked, expires_at
profiles 2 rows name, allowed_models (json), require_caps (json), pool_subset, budget, access_rules (json), guardrails (json), keys
budgets 2 rows budget, scope, window_start, used, status, limit, remaining, source
...
cluster_members unavailable: not running in cluster mode; set `cluster` in the config file id, raft_addr, ...

A table this node cannot load is still described, with the endpoint’s own reason, so a statement can be written against the right columns and run later against a node that has them.

The columns are the endpoint’s JSON fields. Strings and timestamps are TEXT, booleans and counts INTEGER, ratios REAL. A nested object or list is TEXT holding its JSON, marked (json) in the listing, and SQLite’s ->> operator reads into it. A column is NULL where the endpoint’s JSON would have no key, so b.source IS NULL is the same test as “this budget names no source”. The one exception is a boolean, which is never NULL: an absent revoked means false, and a NULL that means false would be a comparison that never matches.

The statement is SQLite’s dialect, one statement, read-only. Pass values with -p rather than splicing them into the text; each -p fills the next ?.

Terminal window
$ pistra query -socket /run/pistra/admin.sock -p live \
'SELECT k.name, p.name AS profile, b.budget, b.used, b."limit", b.remaining
FROM keys k
JOIN profiles p ON p.name = k.profile
JOIN budgets b ON b.budget = p.budget
WHERE b.status = ? AND NOT k.revoked
ORDER BY b.remaining'
name profile budget used limit remaining
ci strict eng 91400 100000 8600
team-a default eng 91400 100000 8600

limit and default are SQL keywords and need double quotes as column names. The result is one read of one node: the tables are loaded into an in-memory database for the statement and discarded, so a key and the bucket it drew down were read together rather than moments apart. What each endpoint says about its view still holds. Budgets are this node’s live buckets, the reconciliation ledgers are replicated and the same everywhere, and a cluster member’s view of who leads is its own.

The drift table is the one most worth a query, because the status endpoint nests it three deep:

Terminal window
$ pistra query -url https://gw.example \
'SELECT provider, day, class, metered, reported, round(ratio, 3) AS ratio
FROM reconcile_verdicts
WHERE breach = 1
ORDER BY day DESC, provider'
provider day class metered reported ratio
openai 2026-09-01 output 184220 201950 0.088

Sums and groups work as they do anywhere:

Terminal window
$ pistra query -url https://gw.example \
'SELECT profile, count(*) AS keys, sum(revoked) AS revoked
FROM keys GROUP BY profile ORDER BY keys DESC'

-o json prints the API’s response as it came: columns, rows, the tables the statement could have read, and truncated when the result was cut at ten thousand rows. It is the form to reach for from a script, and the one that keeps NULL apart from an empty string.

A statement SQLite will not run answers 400 with SQLite’s message: a syntax error, a column that does not exist, a write. A statement that runs past five seconds is interrupted and answers 422. Neither is retried by anything; both are the statement’s fault, and the message says where.

The door adds no capability. It reads what the list endpoints read, decides with the same policy engine per table, and records no audit event, as the list endpoints record none. What it changes is the number of round trips and the place the join is written.

The console and any generated client call the same two operations. GET /admin/v1/query/tables returns the schema as this caller sees it, and the statement goes in a JSON body:

Terminal window
$ curl -s -X POST https://gw.example/admin/v1/query \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"sql": "SELECT name, in_force, expires_at FROM suspensions WHERE kind = ?", "params": ["provider"]}'
{"columns":["name","in_force","expires_at"],"rows":[["openai",1,"2026-09-04T11:00:00Z"]],"tables":["keys","profiles","budgets","backends","models","mcp_servers","a2a_agents","suspensions","secrets","sources"]}