Chio/Docs
LOGIN · JOIN

Query Receipts with SDKs

Collect receipt pages in Python or TypeScript for a dashboard or scheduled job.

Guide chio@5f63e6e

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.

Start

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.

Terminal · quickstartTerminal · quickstart
npm install @chio-protocol/sdk

2. 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.

Terminal 1 · keep the control service runningbash
CHIO_TRUST_SERVICE_TOKEN=corbel-audit-read \
  chio --receipt-db .chio/receipts.db trust serve --listen 127.0.0.1:9098

In a second terminal, export the service URL and token the clients read.

Terminal 2 · quickstartbash
export CONTROL_URL=http://127.0.0.1:9098
export CHIO_CONTROL_TOKEN=corbel-audit-read

For 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.

audit.tsaudit.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);
}
Output
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.

audit.tsaudit.py
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`);
Output
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.

requested URLs, python runtext
/v1/receipts/query?outcome=deny&since=1785521720&limit=1
/v1/receipts/query?toolServer=pricing&toolName=refund
/v1/receipts/query?toolServer=pricing&toolName=refund&cursor=42

5. 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.

audit.tsaudit.py
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;
  }
}
Output
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.

Terminal 2 · inspect the HTTP envelopebash
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}]}'
Output
{
  "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.

Next: filter by every dimension the store records