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.
What the tables are
Section titled “What the tables are”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:
$ pistra query -socket /run/pistra/admin.sock -tableskeys 3 rows name, metadata (json), profile, unknown_profile, revoked, expires_atprofiles 2 rows name, allowed_models (json), require_caps (json), pool_subset, budget, access_rules (json), guardrails (json), keysbudgets 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.
Ask a question
Section titled “Ask a question”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 ?.
$ 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 remainingci strict eng 91400 100000 8600team-a default eng 91400 100000 8600limit 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:
$ 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 ratioopenai 2026-09-01 output 184220 201950 0.088Sums and groups work as they do anywhere:
$ 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.
What it refuses, and how
Section titled “What it refuses, and how”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.
From the API
Section titled “From the API”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:
$ 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"]}