Query Receipts with SDKs
Collect receipt pages in Python or TypeScript for a dashboard or scheduled job.
On this page
Code that runs beside a deployment reads your kernel’s decisions one filtered page at a time, in TypeScript or Python, through the trust-control service that fronts the receipt store.
Inside your own project, project.history.receipts(...) returns the same records in process, as Inspect your receipts shows.
1. Install the query client
Both languages ship ReceiptQueryClient in the Chio SDK. Install it in the project that holds your receipt store. The Python client needs Python 3.11 or newer and installs into a local virtual environment.
npm install @chio-protocol/sdkpython3 -m venv .venv
.venv/bin/pip install chio-sdk2. Serve your receipt store over the trust-control API
Start a local trust-control service over the existing store and leave it running. This example token grants administrative read access on loopback; a shared service uses its configured tenant read token.
CHIO_TRUST_SERVICE_TOKEN=corbel-audit-read \
chio --receipt-db .chio/receipts.db trust serve --listen 127.0.0.1:9098In a second terminal, export the service URL and token the clients read.
export CONTROL_URL=http://127.0.0.1:9098
export CHIO_CONTROL_TOKEN=corbel-audit-readFor a service you already operate, set its URL and an authorized token instead. The clients speak the trust-control API; an HTTP middleware sidecar keeps its own receipt interface. Stop the service with Ctrl-C when you finish, and .chio/receipts.db stays the source of the records.
3. Count the refusals in your store
Save the script as audit.ts or audit.py, then run it with bun run audit.ts or .venv/bin/python audit.py.
import { ReceiptQueryClient } from "@chio-protocol/sdk";
const client = new ReceiptQueryClient(
process.env.CONTROL_URL!,
process.env.CHIO_CONTROL_TOKEN!,
);
const page = await client.query({ outcome: "deny", limit: 10 });
console.log(`${page.totalCount} denied decisions`);
for (const receipt of page.receipts) {
console.log(receipt.tool_name, receipt.decision?.verdict, receipt.id);
}2 denied decisions refund deny 5d1f0c8e2a7b4e9d3c6a1f08b2e7d4c9a3f6e1b8d0c5a2f7e4b9d6c3a8f1e0b5 quote deny 9a4e7b1c3d8f2a6e5b0c9d4f7a1e8b3c6d2f5a0e9b4c7d1f8a3e6b2c5d0f9a4e
import os
from chio import ReceiptQueryClient
client = ReceiptQueryClient(os.environ["CONTROL_URL"], os.environ["CHIO_CONTROL_TOKEN"])
page = client.query({"outcome": "deny", "limit": 10})
print(f"{page['totalCount']} denied decisions")
for receipt in page["receipts"]:
print(receipt["tool_name"], receipt["decision"]["verdict"], receipt["id"])2 denied decisions refund deny 5d1f0c8e2a7b4e9d3c6a1f08b2e7d4c9a3f6e1b8d0c5a2f7e4b9d6c3a8f1e0b5 quote deny 9a4e7b1c3d8f2a6e5b0c9d4f7a1e8b3c6d2f5a0e9b4c7d1f8a3e6b2c5d0f9a4e
You should see the count, then one line per refusal with its tool name, verdict, and receipt ID. After Quickstart part 2 the store holds two: Basalt's refund of order-77, outside a grant that covers only quote, and a stranger's quote with no grant at all. Receipt IDs follow your own run, and every later run adds matches.
4. Walk every matching page with the generator
Count matches cheaply with limit: 1, then let paginate fetch each page of a filtered query.
import { ReceiptQueryClient } from "@chio-protocol/sdk";
const client = new ReceiptQueryClient(
process.env.CONTROL_URL!,
process.env.CHIO_CONTROL_TOKEN!,
);
// How many receipts match, without fetching them all.
const head = await client.query({
outcome: "deny",
since: Math.floor(Date.now() / 1000) - 86_400,
limit: 1,
});
console.log(`${head.totalCount} denials in the last 24 hours`);
// Walk every matching page with the async generator.
let total = 0;
for await (const page of client.paginate({
toolServer: "pricing",
toolName: "refund",
since: Math.floor(Date.now() / 1000) - 7 * 86_400,
})) {
for (const receipt of page) {
total += 1;
// decision is optional: a trace or advisory receipt carries none.
if (receipt.decision?.verdict === "deny") {
console.log(receipt.id, receipt.decision.guard, receipt.decision.reason);
}
}
}
console.log(`${total} refund requests this week`);2 denials in the last 24 hours 5d1f0c8e2a7b4e9d3c6a1f08b2e7d4c9a3f6e1b8d0c5a2f7e4b9d6c3a8f1e0b5 kernel requested tool refund on server pricing is not in capability scope 1 refund requests this week
import os
import time
from chio import ReceiptQueryClient
client = ReceiptQueryClient(os.environ["CONTROL_URL"], os.environ["CHIO_CONTROL_TOKEN"])
# How many receipts match, without fetching them all.
head = client.query({"outcome": "deny", "since": int(time.time()) - 86_400, "limit": 1})
print(f"{head['totalCount']} denials in the last 24 hours")
# Walk every matching page. paginate() is a generator over pages.
total = 0
for page in client.paginate({
"toolServer": "pricing", "toolName": "refund",
"since": int(time.time()) - 7 * 86_400,
}):
for r in page:
total += 1
# decision is optional: a trace or advisory receipt carries none.
decision = r.get("decision") or {}
if decision.get("verdict") == "deny":
print(r["id"], decision["guard"], decision["reason"])
print(f"{total} refund requests this week")2 denials in the last 24 hours 5d1f0c8e2a7b4e9d3c6a1f08b2e7d4c9a3f6e1b8d0c5a2f7e4b9d6c3a8f1e0b5 kernel requested tool refund on server pricing is not in capability scope 1 refund requests this week
You should see the day’s denial count and the week’s refund requests, with each refused refund’s guard and reason. Both clients call GET /v1/receipts/query with an Authorization: Bearer header, and the number of requests follows the number of returned pages. Each client requires the next cursor to advance: a repeated or backward cursor raises an error and ends the loop.
Requests from a captured Python query
This capture made one count query and two receipt-page requests. It used its own filters and omitted the seven-day lower bound in the examples above.
/v1/receipts/query?outcome=deny&since=1785521720&limit=1
/v1/receipts/query?toolServer=pricing&toolName=refund
/v1/receipts/query?toolServer=pricing&toolName=refund&cursor=425. Separate a rejected query from a dropped connection
Narrow a query to one agent with agentSubject: export AGENT_SUBJECT with the complete subject key of the agent whose receipts you want, and append the example to the script that created client. Each SDK raises a rejected query and an unreachable service as different errors: QueryError and TransportError in TypeScript, ChioQueryError and ChioTransportError in Python.
import { QueryError, TransportError } from "@chio-protocol/sdk";
try {
const { totalCount, receipts } = await client.query({
agentSubject: process.env.AGENT_SUBJECT!,
outcome: "deny",
limit: 50,
});
console.log(`${totalCount} total denials for this agent`);
for (const r of receipts) {
console.log(r.timestamp, r.tool_server, r.tool_name, r.decision);
}
} catch (err) {
if (err instanceof QueryError) {
console.error("Control plane rejected the query", err.status, err.message);
} else if (err instanceof TransportError) {
console.error("Could not reach the control plane", err.message);
} else {
throw err;
}
}Control plane rejected the query 401 receipt query failed with status 401 Could not reach the control plane failed to fetch receipts
from chio import ChioQueryError, ChioTransportError
try:
response = client.query({
"agentSubject": os.environ["AGENT_SUBJECT"],
"outcome": "deny",
"limit": 50,
})
print(f"{response['totalCount']} total denials for this agent")
for r in response["receipts"]:
print(r["timestamp"], r["tool_server"], r["tool_name"], r["decision"])
except ChioQueryError as err:
print("Control plane rejected the query", err.status, err)
except ChioTransportError as err:
print("Could not reach the control plane", err)Control plane rejected the query 401 receipt query failed with status 401 Could not reach the control plane failed to fetch receipts
You should see the two Python lines when the service rejects the bearer token and then when nothing is listening: a 401 lands in the query branch, a dead socket in the transport branch. The TypeScript branches print the same two lines to stderr.
6. Read the envelope the clients unwrap
Send the same query straight over HTTP with the token from Terminal 2. The command also needs jq.
curl --fail --silent --show-error \
-H "Authorization: Bearer $CHIO_CONTROL_TOKEN" \
"$CONTROL_URL/v1/receipts/query?outcome=deny&limit=1" \
| jq '{totalCount, nextCursor, receipts: [.receipts[] | {tool_name, decision}]}'{
"totalCount": 2,
"nextCursor": 2,
"receipts": [
{
"tool_name": "refund",
"decision": {
"guard": "kernel",
"reason": "requested tool refund on server pricing is not in capability scope",
"verdict": "deny"
}
}
]
}You should see totalCount at 2 while the page holds one receipt, because the count ignores limit and cursor. The decision comes back flat, with guard reading kernel and the reason naming the scope check that refused the refund.