Chio/Docs
LOGIN · JOIN

BuildProtocols

OpenAPI Sidecar

Use an OpenAPI 3.x spec to gate side effects and persist signed receipts for a plain HTTP app.

Prerequisites

Python 3 for the upstream app and smoke driver, and a chio source checkout. The smoke resolves its binary through ensure_chio_bin in examples/_shared/hello-http-common.sh, which never consults PATH: it takes $CHIO_BIN when that is set and executable, otherwise target/debug/chio inside the checkout, and runs cargo build --bin chio when that is missing. So you need either a Rust toolchain or CHIO_BIN pointed at a binary you already built. An installed chio with no checkout will not run this example. See Installation.

What It Shows

This example uses one OpenAPI spec to show four behaviors:

  • Safe routes pass through. GET /hello is allowed by the sidecar and returns an x-chio-receipt-id response header.
  • Side-effect routes deny without a token. POST /echo without a capability token returns an chio_access_denied error body and a receipt ID; the sidecar does not dispatch it to the upstream app.
  • Side-effect routes allow with a token. With an X-Chio-Capability header issued by chio trust serve, the same POST /echo runs the upstream app and returns a 200 with the receipt id.
  • Receipts persist locally. The sidecar writes signed receipts to a SQLite store; the smoke flow reads them back from the store's http_receipts table and writes them out as NDJSON.

The upstream app has no Chio framework SDK or middleware. The sidecar applies the policy and stores receipts.


Run It

Start the upstream app only:

terminalbash
cd examples/hello-openapi-sidecar
./run.sh

Run the full trust + sidecar smoke flow (boots a trust service, the upstream app, and sidecar; then runs three governed scenarios):

openapi-sidecar · runtranscript
$ ./smoke.sh
hello-openapi-sidecar smoke passed
artifacts: ~/chio/examples/hello-openapi-sidecar/.artifacts/20260905T114923Z
hello receipt: 737752a34a6a3f61ee36f9a0b4c99cd7de51a3bd46198afc798d9d55740bec54
deny receipt: 8ba25e4748c545bc7a20946187bcb287fc064f26d5e40b4bd3d0639c4eb6c466
allow receipt: ebf8ee9a44795b2ffdd07272379e4923f7aa7f9eb4efee90a3468d8c87784b8f
exit 0

Three scenarios, three signed receipts, including one for the call that was refused. The artifacts: line is the run directory, which is wherever the smoke was run from.

Run output is written under .artifacts/<timestamp>/ as JSON response bodies, response header dumps, the issued capability token, and a summary.json with three receipt ids: one for hello, one for deny, one for allow.

Zero-config alias: chio start

chio start stands up the same sidecar router with zero configuration: it binds 127.0.0.1:9090, keeps receipts durable by default, and configures no upstream, so the catch-all route returns a loud 502. It is the SDK-quickstart and chio-hermes entry point and takes the same store flag this example uses: chio start --receipt-store PATH [--listen ADDR] [--allow-ephemeral-receipts] [--print-config]. Use chio api protect when you need --upstream and --spec.

The Upstream App

The app is a 160-line Python ThreadingHTTPServer with three routes: GET /healthz, GET /hello, and POST /echo. JSON responses carry chio_sdk: false as proof that the app does not import any Chio code.

app.py (excerpt)python
def do_GET(self) -> None:
    path = urlparse(self.path).path
    if path == "/healthz":
        self._json(HTTPStatus.OK, {"status": "ok"})
        return
    if path == "/hello":
        self._json(HTTPStatus.OK, {
            "message": "hello from openapi-sidecar upstream",
            "runtime": "python-http-server",
            "chio_sdk": False,
        })
        return
    self._json(HTTPStatus.NOT_FOUND, {"error": "not_found"})

The OpenAPI Spec

The spec describes the same three operations the upstream app implements. No Chio extensions are present in this minimal example; default policy applies (GET is session-allow, POST is deny-by-default until a capability token gates it).

examples/hello-openapi-sidecar/openapi.yamlyaml
openapi: 3.1.0
info:
  title: hello-openapi-sidecar
  version: 0.1.0
paths:
  /healthz:
    get:
      operationId: healthz
      responses:
        "200":
          description: Health check
  /hello:
    get:
      operationId: hello
      responses:
        "200":
          description: Greeting
  /echo:
    post:
      operationId: echo
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message]
              properties:
                message:
                  type: string
                count:
                  type: integer
      responses:
        "200":
          description: Echo payload

x-chio-* Extensions You Can Add

The OpenAPI bridge parses a small extension vocabulary out of each operation object. Three of the five drive behavior at this commit; the other two are parsed and not yet read, and the table says which is which.

ExtensionTypeEffect
x-chio-sensitivitypublic | internal | sensitive | restrictedParsed into ChioExtensions.sensitivity and read nowhere else at this commit: it reaches no annotation, no manifest field and no guard. Record it if you want the spec to carry the classification; do not expect it to change behavior yet.
x-chio-side-effectsbooleanOverride the method default. Use true for a GET that mutates, false for a POST that only reads.
x-chio-approval-requiredbooleanWhen true, forces deny-by-default and sets annotations.requires_approval on the manifest. Takes precedence over everything else.
x-chio-budget-limitinteger (minor currency units)Parsed into ChioExtensions.budget_limit. Like x-chio-sensitivity, nothing outside the parser reads it at this commit, so it does not yet reach the budget guard.
x-chio-publishbooleanWhen false, excludes the operation from the generated manifest. Useful for health checks. Default true.

Walkthrough

Pipeline: OpenAPI 3.x to Tools to Chio

rendering
Each OpenAPI operation becomes a tool in the Chio manifest. The sidecar mediates matched requests before dispatching to the upstream app.

Each HTTP operation (method + path pair) becomes one entry in the generated ToolManifest. The operation id maps to the tool name; the request body schema and parameters merge into a single JSON Schema input shape; HTTP method drives the default has_side_effects flag (false for GET, HEAD, OPTIONS; true for everything else).

Trust Service and Sidecar Startup

The smoke flow boots a local trust service first, then the upstream app, then the sidecar pointed at both. The sidecar reads the OpenAPI spec at startup. Restart the sidecar after changing the spec.

smoke.sh (excerpt)bash
"${CHIO_BIN}" trust serve \
  --listen "127.0.0.1:${TRUST_PORT}" \
  --service-token "${SERVICE_TOKEN}" \
  --receipt-db "${STATE_DIR}/trust-receipts.sqlite3" \
  --revocation-db "${STATE_DIR}/trust-revocations.sqlite3" \
  --authority-db "${STATE_DIR}/trust-authority.sqlite3" \
  --budget-db "${STATE_DIR}/trust-budgets.sqlite3" &

(
  export CHIO_TRUSTED_ISSUER_KEY="${TRUSTED_ISSUER_KEY}"
  exec "${CHIO_BIN}" \
    --control-url "${CONTROL_URL}" \
    --control-token "${SERVICE_TOKEN}" \
    api protect \
    --upstream "${APP_URL}" \
    --spec "${EXAMPLE_ROOT}/openapi.yaml" \
    --listen "127.0.0.1:${SIDECAR_PORT}" \
    --receipt-store "${RECEIPT_STORE}"
) &

The Three Scenarios

The smoke flow exercises each policy outcome with one curl call. All three responses carry an x-chio-receipt-id header, which the smoke flow extracts for the summary.

smoke.sh (excerpt)bash
# 1. Safe GET allowed without a token.
curl -sS -D hello.headers "${SIDECAR_URL}/hello" > hello.json

# 2. POST denied without a capability token.
curl -sS -D deny.headers \
  -H "content-type: application/json" \
  --data '{"message":"denied","count":1}' \
  "${SIDECAR_URL}/echo" > deny.json

# 3. POST allowed with an issued capability token.
curl -sS -D allow.headers \
  -H "content-type: application/json" \
  -H "X-Chio-Capability: $(tr -d '\n' < capability.token)" \
  --data '{"message":"hello","count":2}' \
  "${SIDECAR_URL}/echo" > allow.json

Assertions on each response:

  • hello.json has message: "hello from openapi-sidecar upstream" and chio_sdk: false.
  • deny.json has error: "chio_access_denied" and a non-empty receipt_id plus a suggestion field. The upstream app is never reached.
  • allow.json has handled_by: "plain-upstream-app" and chio_sdk: false: the sidecar mediated, the app ran the request unmodified.

Full Request and Response

The capability-gated POST in the third scenario, in full.

The header carries the capability token as raw JSON, not as an encoded or signed envelope: the sidecar parses the header value straight into a CapabilityToken. This is the first 220 bytes of the token the smoke issued and then sent.

head -c 220 capability.tokentranscript
$ head -c 220 capability.token
{"schema":"chio.capability.v1","id":"cap-01a07166-f872-76c1-8543-40f3eb1afcd6","issuer":"5c5dc997b16343039e6c72824a2378d8c861e9668546e23f9d733fb9bc6bc63c","subject":"000000000000000000000000000000000000000000000000000000
exit 0
requesthttp
POST /echo HTTP/1.1
Host: 127.0.0.1:{SIDECAR_PORT}
Content-Type: application/json
X-Chio-Capability: {"schema":"chio.capability.v1","id":"cap-...","issuer":"...","subject":"...","scope":{...},"issued_at":...,"expires_at":...,"signature":"..."}

{"message":"hello","count":2}

And the response, header dump and body, from the run captured below. Header names are normalized lower-case by the sidecar.

cat allow.headers allow.jsontranscript
$ cat allow.headers allow.json
HTTP/1.1 200 OK
server: hello-openapi-sidecar/0.1 Python/3.13.13
date: Sat, 05 Sep 2026 11:49:29 GMT
content-type: application/json
content-length: 87
x-chio-receipt-id: ebf8ee9a44795b2ffdd07272379e4923f7aa7f9eb4efee90a3468d8c87784b8f

{"message": "hello", "count": 2, "handled_by": "plain-upstream-app", "chio_sdk": false}
exit 0

The deny variant returns 403 with the same x-chio-receipt-id header plus a structured error body. Same route, same run, no capability token on the request:

cat deny.headers deny.jsontranscript
$ cat deny.headers deny.json
HTTP/1.1 403 Forbidden
content-type: application/json
x-chio-receipt-id: 8ba25e4748c545bc7a20946187bcb287fc064f26d5e40b4bd3d0639c4eb6c466
content-length: 283
date: Sat, 05 Sep 2026 11:49:28 GMT

{"error":"chio_access_denied","message":"side-effect route requires a capability token","receipt_id":"8ba25e4748c545bc7a20946187bcb287fc064f26d5e40b4bd3d0639c4eb6c466","suggestion":"provide a valid capability token in the X-Chio-Capability header or chio_capability query parameter"}
exit 0

The body names the guard's reason and tells the caller how to fix it. The id on the header equals the id in the body, so a client that only reads headers and a client that only reads bodies land on the same receipt.

Smoke assertions

The smoke asserts on each body in a separate Python heredoc. The nine body assertions, gathered here with headings that are this page's, not the file's, are at examples/hello-openapi-sidecar/smoke.sh:79-80,95-97,121-124.

the nine body assertions, grouped by scenariopython
# /hello: upstream answered, no SDK linked
assert body["message"] == "hello from openapi-sidecar upstream", body
assert body["chio_sdk"] is False, body

# /echo deny: structured error, receipt id present, suggestion field
assert body["error"] == "chio_access_denied", body
assert body["receipt_id"], body
assert body["suggestion"], body

# /echo allow: upstream ran, still no SDK
assert body["message"] == "hello", body
assert body["count"] == 2, body
assert body["handled_by"] == "plain-upstream-app", body
assert body["chio_sdk"] is False, body

Two more assertions run after those. At :152 the smoke checks that every receipt id it saw on a response header is also present in the SQLite store, and at :176 that all three ids in summary.json are non-empty. Those two are what make the header ids and the persisted rows one claim rather than two.

Listing Persisted Receipts

The sidecar writes each signed receipt to RECEIPT_STORE (SQLite), one row per receipt in the http_receipts table. The smoke flow reads them back directly with a small python3/sqlite3 query and writes each receipt as one JSON line to receipts.ndjson:

smoke.sh (excerpt)bash
python3 - "${RECEIPT_STORE}" "${ARTIFACT_ROOT}/receipts.ndjson" <<'PY'
import json
import sqlite3
import sys
from pathlib import Path

with sqlite3.connect(Path(sys.argv[1])) as db:
    rows = db.execute(
        "SELECT receipt_json FROM http_receipts ORDER BY rowid ASC"
    ).fetchall()

receipts = [json.loads(row[0]) for row in rows]
Path(sys.argv[2]).write_text(
    "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in receipts),
    encoding="utf-8",
)
PY

Three receipts are produced: one for GET /hello (allow), one for the unauthorized POST /echo (deny), one for the capability-gated POST /echo (allow). Receipt ids match the values returned in the response headers.

Inspect after

bash
cd .artifacts/$(ls -t .artifacts | head -1)

Four questions, four commands. Each block below is the command and what it printed on one captured run.

First, the three receipt ids the smoke recorded.

openapi-sidecar · summarytranscript
$ jq '.receipt_ids' summary.json
{
  "hello": "737752a34a6a3f61ee36f9a0b4c99cd7de51a3bd46198afc798d9d55740bec54",
  "deny": "8ba25e4748c545bc7a20946187bcb287fc064f26d5e40b4bd3d0639c4eb6c466",
  "allow": "ebf8ee9a44795b2ffdd07272379e4923f7aa7f9eb4efee90a3468d8c87784b8f"
}
exit 0

Second, the same ids off the response headers. The order is the order grep was handed the files: hello, deny, allow.

openapi-sidecar · headerstranscript
$ grep -i x-chio-receipt-id hello.headers deny.headers allow.headers
hello.headers:x-chio-receipt-id: 737752a34a6a3f61ee36f9a0b4c99cd7de51a3bd46198afc798d9d55740bec54
deny.headers:x-chio-receipt-id: 8ba25e4748c545bc7a20946187bcb287fc064f26d5e40b4bd3d0639c4eb6c466
allow.headers:x-chio-receipt-id: ebf8ee9a44795b2ffdd07272379e4923f7aa7f9eb4efee90a3468d8c87784b8f
exit 0

Third, the persisted store: three records, two allows and one deny.

openapi-sidecar · verdictstranscript
$ wc -l receipts.ndjson
$ jq -r '.verdict.verdict' receipts.ndjson | sort | uniq -c
3 receipts.ndjson
      2 allow
      1 deny
exit 0

Fourth, which guard refused and why.

openapi-sidecar · guardstranscript
$ jq -r '.verdict.guard, .verdict.reason' receipts.ndjson | head -6
null
null
CapabilityGuard
side-effect route requires a capability token
null
null
exit 0

That last block prints a guard and reason pair per receipt in store order. The two allows carry null for both, because there is nothing to name when nothing refused. The middle pair is the whole denial: CapabilityGuard refused, and the reason is the string the caller saw in deny.json.

Decision rule

Use this when: the upstream app is already a working HTTP service, the team has an OpenAPI 3.x spec, and you want governance with no code change to the app. Don't use this if you need receipts surfaced inside the request scope (for instance to log the receipt id alongside business events): use a framework SDK instead. See Python, Node, JVM and .NET, or Go and C++.

Why start here

This example is the cleanest separation of concerns Chio offers for HTTP services. The app stays a plain HTTP server. Routing, capability validation, guards, and receipt persistence are all in the sidecar. When you outgrow this and want framework-native interception (for instance to surface receipts in a request scope), the framework integrations are drop-in replacements for the sidecar.

Manifest Signing and Sidecar Deployment

The sidecar consumes the OpenAPI spec at startup and produces an in-memory manifest signed with the configured trusted issuer key (passed via CHIO_TRUSTED_ISSUER_KEY in this example). For production deployments where the manifest is shared with other services, sign the manifest once with chio and distribute the signed manifest instead of regenerating it for each process. See the Bridge OpenAPI to MCP guide for the manifest-first variant.


Next Steps