BuildConnect
Protect an API
Place a reverse proxy before an HTTP service to check requests against an OpenAPI spec and sign a receipt for each call.
Prerequisites
Why Protect an API with Chio
Autonomous workflows make API calls. When a workflow can issue a POST /orders or a DELETE /tenants/{id}, your existing API gateway stops being enough. Gateways authenticate, rate limit, and terminate TLS; they do not reason about whether this specific call was authorized by a capability the operator issued, or whether the response leaks into a non-repudiable audit trail.
chio api protect runs as a sidecar between the caller and upstream service. It performs four operations:
- Discovers routes: parses your OpenAPI spec (or fetches one from the upstream) to build a typed route table keyed on method plus path pattern.
- Evaluates every request: matches each inbound call to a route, classifies it safe or side-effect, and checks for a presented capability when required.
- Signs a receipt: every allowed or denied call produces a signed
HttpReceiptwith caller identity hash, route pattern, verdict, guard evidence, and policy hash, all bound under an Ed25519 signature. - Proxies transparently: the upstream receives the original request, unmodified except for stripped capability headers. Clients observe no behavior change on allowed paths.
Because Chio keys on the OpenAPI contract, you do not write a policy file per endpoint. The default rule set is derived from HTTP semantics: safe methods pass, side-effect methods need authorization. You then sharpen that with OpenAPI extensions or explicit overrides.
What You Need
| Ingredient | Purpose | Required |
|---|---|---|
| Upstream URL | Base URL of the HTTP service Chio will proxy to. | Yes |
| OpenAPI spec | Defines route patterns and methods. Auto-discovered from the upstream when omitted. | Recommended |
| Listen address | Where chio binds for inbound requests. Defaults to 127.0.0.1:9090. | No |
| Receipt store | SQLite path for persisting signed receipts across restarts. | Yes (or opt in to ephemeral) |
| Authority seed | Stable Ed25519 seed so the receipt kernel key does not rotate on restart. | Recommended for production |
If you do not yet have an OpenAPI spec, Chio can still protect the service. Unknown routes fall back to a method-based default: safe methods (GET, HEAD, OPTIONS) are allowed; every other method denies by default. This is safe-by-default but coarse. Point Chio at a spec as soon as you can.
The Protect Command
The canonical invocation names an upstream and a durable receipt store:
$ chio api protect --upstream <url> --receipt-store <path> [--spec <file>] [--listen <addr>]The receipt store is not optional at boot. chio api protect refuses to start without --receipt-store ("refusing to start without durable receipts") unless you explicitly pass --allow-ephemeral-receipts to accept in-memory receipts that are lost on every restart. That flag is a local-development opt-in only.
A production-shaped invocation with the common flags:
$ chio --authority-seed-file ./chio-seed.hex \
api protect \
--upstream https://orders.internal:8443 \
--spec ./openapi.yaml \
--listen 0.0.0.0:9090 \
--receipt-store ./receipts.sqlite \
--upstream-timeout-secs 20Each flag in turn:
| Flag | Default | Meaning |
|---|---|---|
--upstream | required | Base URL of the service to protect. Path and query of each inbound request are appended when forwarding. |
--spec | auto-discover | Path to a local OpenAPI (YAML or JSON) spec. If omitted, Chio probes well-known locations on the upstream. |
--listen | 127.0.0.1:9090 | Address chio binds for inbound proxy traffic. |
--receipt-store | required at boot | SQLite path for durable receipt persistence. The command refuses to start without it unless --allow-ephemeral-receipts is set. |
--allow-ephemeral-receipts | false | Permit in-memory receipts, whose audit evidence is lost on every restart. Required to boot without --receipt-store. Local development only. |
--upstream-timeout-secs | 20 | Wall-clock ceiling in seconds on a single upstream hop, including reading the full response. |
--authority-seed-file | ephemeral | Top-level Chio flag. Hex-encoded Ed25519 seed that stabilizes the kernel signing key across restarts. |
A successful start prints nothing. With --receipt-store and --authority-seed-file supplied, the process loads the spec, builds the route table, binds the listener, and writes no line to stdout or stderr. Ask the sidecar instead: GET /chio/live answers as soon as the process is up, and GET /chio/health adds the storage backends the embedded kernel resolved.
$ curl -s http://127.0.0.1:9090/chio/health{"status":"healthy","version":"0.1.0","receipt_backend":"durable","revocation_backend":"durable"}receipt_backend and revocation_backend read durable when a store is attached and are the fastest way to catch a sidecar that came up on ephemeral state. The liveness route reports the same two fields as empty strings because it is process-only and does not inspect storage.
Zero-config quickstart with chio start
chio start is a convenience alias for chio api protect with zero-config defaults. It serves the same sidecar router (capability mint, release, and validate; receipt verify; tool-call evaluate; and the human-in-the-loop approval endpoints) but runs no upstream proxy, so the catch-all route returns 502 rather than forwarding. Durable receipts are on by default (pass --allow-ephemeral-receipts for an in-memory run). Reach for chio api protect once you need production reverse-proxying with --upstream and --spec.How HTTP Requests Flow Through Chio
Each request follows the route match, identity, policy, capability, and receipt steps shown below. The proxy fails closed and records allow and deny decisions.
The route matcher is literal. It walks the route table in order, matching both the HTTP method and a segmented path pattern that supports {param} placeholders. A request for GET /pets/42 matches the route GET /pets/{petId} and inherits that route's policy. Matching is case-sensitive and rejects trailing-slash mismatches, so /pets/ and /pets are distinct patterns.
If no route matches, Chio falls back to a method-based default:
- Safe methods (
GET,HEAD,OPTIONS) getSessionAllowand pass through with an audit receipt. - Side-effect methods (
POST,PUT,PATCH,DELETE) getDenyByDefaultand require a valid capability token inX-Chio-Capability.
An unknown side-effect route does not fall through to the upstream. The proxy records the denial.
Both verdicts, end to end
Here is the whole shape against a two-route spec: a GET /orders that is safe, and a POST /orders/{orderId}/refund marked x-chio-side-effects: true. The read passes straight through and the upstream response comes back with a receipt id attached:
$ curl -s -i http://127.0.0.1:9090/ordersHTTP/1.1 200 OK
server: BaseHTTP/0.6 Python/3.13.13
date: Sat, 05 Sep 2026 06:26:37 GMT
content-type: application/json
content-length: 22
x-chio-receipt-id: 3c908e3471ee8edbf91f726b6841e004e5a28744d89df6705865776259dd2297
{"orders": ["ord-42"]}The write with no capability never reaches the upstream. The 403 body is a structured refusal that names the missing input and both channels it accepts:
$ curl -s -i -X POST http://127.0.0.1:9090/orders/ord-42/refund \
-H 'content-type: application/json' \
-d '{"amount_cents": 4200}'HTTP/1.1 403 Forbidden
content-type: application/json
x-chio-receipt-id: 99a121361c82426bdfc3063a7bce333363583cb80b5e3d9f4a399b4025abfc56
content-length: 283
date: Sat, 05 Sep 2026 06:26:37 GMT
{"error":"chio_access_denied","message":"side-effect route requires a capability token","receipt_id":"99a121361c82426bdfc3063a7bce333363583cb80b5e3d9f4a399b4025abfc56","suggestion":"provide a valid capability token in the X-Chio-Capability header or chio_capability query parameter"}Present a valid capability in X-Chio-Capability and the same request forwards. Note that the header is stripped before the hop, so the upstream never sees it:
$ CAP=$(jq -c .capability capability.json)
$ curl -s -i -X POST http://127.0.0.1:9090/orders/ord-42/refund \
-H 'content-type: application/json' \
-H "X-Chio-Capability: $CAP" \
-d '{"amount_cents": 4200}'HTTP/1.1 200 OK
server: BaseHTTP/0.6 Python/3.13.13
date: Sat, 05 Sep 2026 06:26:37 GMT
content-type: application/json
content-length: 49
x-chio-receipt-id: a8fcfc7de60a1d06f66f6b9ab7fd3b788d94cd90ec1ff5b7e5239e126c355859
{"status": "refund_issued", "refund_id": "rf-99"}Three exchanges, three signed receipts, one of them a refusal. Minting the capability is covered under Handling authentication, and reading the receipts back under Receipts for HTTP calls.
Writing Policies for HTTP
Chio derives HTTP policy from the OpenAPI spec plus the x-chio-* extension fields. You are not authoring HushSpec rules per route: you annotate the spec and Chio derives the policy. The default rule set is:
| Method | Default policy | Capability required |
|---|---|---|
GET, HEAD, OPTIONS | SessionAllow | No |
POST, PUT, PATCH, DELETE | DenyByDefault | Yes |
Override per operation by adding x-chio-* fields. Here is a spec fragment that opts a GET into deny-by-default (because it returns sensitive data) and requires explicit human approval on a DELETE:
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
paths:
/orders:
get:
operationId: listOrders
summary: List orders in the current tenant.
responses:
"200": { description: OK }
/orders/{orderId}:
get:
operationId: getOrder
x-chio-sensitivity: sensitive
x-chio-side-effects: true
responses:
"200": { description: OK }
delete:
operationId: deleteOrder
x-chio-approval-required: true
x-chio-budget-limit: 10000
responses:
"204": { description: Deleted }
/orders/{orderId}/refund:
post:
operationId: refundOrder
x-chio-side-effects: true
x-chio-budget-limit: 50000
responses:
"200": { description: Refund issued }chio-openapi parses five x-chio-* fields off an operation (crates/protocol/chio-openapi/src/extensions.rs:26-40). Only two of them reach the sidecar route table, because ProxyState::build_routes turns each operation into a pattern, a method and one policy decision and keeps nothing else (crates/products/chio-api-protect/src/proxy/state.rs:485-513):
| Field | Read by the sidecar | Effect |
|---|---|---|
x-chio-side-effects | Yes | Boolean override of the method default. true forces DenyByDefault on a GET; false forces SessionAllow on a POST. |
x-chio-approval-required | Yes | Forces DenyByDefault ahead of everything else, including x-chio-side-effects (chio-openapi/src/policy.rs:49-51). It does not hold the call for an operator. |
x-chio-sensitivity | No | Parsed into Sensitivity (public, internal, sensitive, restricted) and dropped on this path. It changes no verdict and reaches no receipt. |
x-chio-budget-limit | No | Parsed as minor currency units and dropped on this path. Budget ceilings that bind come from the capability grant, not the spec. |
x-chio-publish | No | Governs manifest generation, which the sidecar does not do. The route is still matched and still governed. Use it with Bridge OpenAPI to MCP, whose generator honors it. |
An annotated field is not an enforced field
x-chio-sensitivity: restricted reads like a control and is not one. Put the ceiling on the capability and the classification in whatever consumes your spec downstream.Capability design does not go through the route pattern. The sidecar projects every governed request onto one synthetic tool call, so the grant it checks names the tool authorize_http_request on the server chio_http_authority (crates/platform/chio-http-core/src/authority.rs:33-35). A token whose grant names anything else is refused with capability does not authorize tool authorize_http_request on server chio_http_authority, whatever the route was. Narrowing therefore happens on the grant's operations, max_invocations and max_total_cost, and on the token's lifetime, rather than on a per-route pattern.
Start with the spec, not with rules
Handling Authentication
Chio does not replace your upstream's authentication. It layers capability authorization on top. The distinction matters:
- Authentication: who is the caller. Handled by the upstream via
Authorization: Bearer ..., API keys, mTLS, or whatever the service already requires. - Capability authorization: does this caller hold a signed grant to perform this action on this resource right now. Handled by chio via the
X-Chio-Capabilityheader or thechio_capabilityquery parameter.
Chio extracts a caller identity hash from standard auth signals so the receipt binds which caller invoked which capability. The precedence is:
Authorization: Bearer <token>: chio hashes the token (never stores it) and emits a caller subject likebearer:3a7c8f...X-Api-Key, that one header name and no other spelling: chio hashes the key value and emitsapikey:plus the same sixteen hex characters.- Neither present, or the value is not well formed: the caller subject is
anonymous(crates/products/chio-api-protect/src/evaluator.rs:540-575).
The subject is a prefix plus the first sixteen characters of the SHA-256 hex of the credential. The receipt itself carries caller_identity_hash, a SHA-256 over the whole identity record, so an anonymous caller still hashes to a stable value rather than to nothing.
Auth headers flow through unchanged. Upstream still sees the original Authorization and validates it. Chio strips only the capability envelope: X-Chio-Capability on the header side, and the chio_capability query parameter on the URL side, so the upstream never sees chio metadata.
The capability travels as the token document itself, compact JSON, not as a JWS or any other compact serialization. The sidecar parses the header value straight into a CapabilityToken (crates/products/chio-api-protect/src/proxy/http.rs:120-124), so whatever produced the token is what you paste in. Mint one against the sidecar and it comes back ready to present:
$ curl -s -X POST http://127.0.0.1:9090/v1/capabilities/mint \
-H 'content-type: application/json' \
-d '{"subject": "agent-refunds", "scopes": ["tool:chio_http_authority:authorize_http_request:invoke"],
$ "ttl_seconds": 300, "job_uid": "refund-batch-1"}' \
| tee capability.json{"capability":{"schema":"chio.capability.v1","id":"sidecar-f80ee84531c5d4ad1b7f068f3187c2fcf7eb4f0d0ac75a5e83ba01397b9525f0","issuer":"04c8e7aadf96a0af7d148b8b25e0cc0e2509bbdce3e1266661168c262c822b7e","subject":"b16122b44a030ccf12519713adec3b232a3482775134765081dff99043ae8575","scope":{"grants":[{"server_id":"chio_http_authority","tool_name":"authorize_http_request","operations":["invoke"]}]},"issued_at":1788589597,"expires_at":1788589897,"signature":"5be928657bd06d26eecf43d4e9af3ecd8aea4d8d306523f95dd2a997d42bec9a0842d8f8b86b187aad32b408678b0cf7d0c71e39ad0dea75583ad3b0fb1e3f06"}}Two things in that response are worth reading closely. issuer, subject and signature are bare lowercase hex, with no ed25519: prefix and no 0x. And the grant names chio_http_authority plus authorize_http_request, because that is the pair the sidecar checks for every governed route.
Receipts for HTTP Calls
Every evaluated request produces at least one HttpReceipt. The proxy signs a decision receipt before the upstream call and then a final receipt once the status is known, and persists the final one only (crates/products/chio-api-protect/src/proxy/router.rs:632-659). The final receipt carries metadata.chio_decision_receipt_id, so it names the decision it finalized without the decision record being separately retrievable. A deny is finalized the same way, which is why a refusal is a stored receipt and not just a status code.
This is the receipt the refused refund wrote. Its route_selection metadata block is dropped here for length; nothing else is edited.
$ python3 -c 'import json, sqlite3, sys
$ rows = sqlite3.connect("receipts.sqlite").execute(
$ "SELECT receipt_json FROM http_receipts").fetchall()
$ deny = [json.loads(r[0]) for r in rows
$ if json.loads(r[0])["verdict"]["verdict"] == "deny"][0]
$ deny["metadata"].pop("route_selection", None)
$ json.dump(deny, sys.stdout, indent=2)'{
"id": "99a121361c82426bdfc3063a7bce333363583cb80b5e3d9f4a399b4025abfc56",
"request_id": "01a0703f-6162-76e1-a2b9-c95d7b5e3ffc",
"route_pattern": "/orders/{orderId}/refund",
"method": "POST",
"caller_identity_hash": "5a45e80d1ab1a73e8c2644f3f6c6e53974d0a986c2fc86692372d3f130248c9c",
"verdict": {
"verdict": "deny",
"reason": "side-effect route requires a capability token",
"guard": "CapabilityGuard",
"http_status": 403
},
"receipt_kind": "mediated_decision",
"boundary_class": "prevent",
"tool_origin": "caller_executed",
"redaction_mode": "none",
"evidence": [
{
"guard_name": "CapabilityGuard",
"verdict": false,
"details": "side-effect route requires a valid capability token"
}
],
"response_status": 403,
"timestamp": 1788589597,
"content_hash": "37458db593fb679bf8629b8f345d39c9f32c264e9b045340dc4d11248ccf8433",
"policy_hash": "215a1eecb13caf8340c4905407b0c03f2a6309866ff2ff0a5a5f9735ea3b3566",
"trust_level": "mediated",
"metadata": {
"chio_decision_receipt_id": "ce578445a868ef2b0575a99adc04da2322455c258f019d0d976feff7b11573bc",
"chio_http_status_scope": "final",
"chio_kernel_receipt_id": "7ff5f3b5fed6b5d74c6048eae04a6e0ed26ec2c02016d7c13689f4401009ec0f"
},
"kernel_key": "04c8e7aadf96a0af7d148b8b25e0cc0e2509bbdce3e1266661168c262c822b7e",
"signature": "59f7b28344c224e37816ef87ef36a12971cf6880e24f731ded1d9a4478850103942b62c9344c044741c8d41917b949698c82bb83d25a7990dccf0b3f18406b09"
}Fields of note:
id: the SHA-256 of the canonical receipt body withidremoved, so 64 lowercase hex characters (crates/platform/chio-http-core/src/receipt.rs:343-353). The proxy returns it to the caller inX-Chio-Receipt-Id.request_id: a UUIDv7 in dashed form, minted per request unless a signed execution nonce carries one in.route_pattern: the matched OpenAPI pattern, not the literal URL. This keeps cardinality bounded for downstream analytics. An unmatched path falls back to the literal path.verdict: on a deny it widens to{ verdict, reason, guard, http_status }. Theguardhere really is the individual guard,CapabilityGuardorDefaultPolicyGuard, and it repeats inevidence.caller_identity_hash: SHA-256 over the canonical JSON of the whole caller identity record, not over the raw credential.content_hash: SHA-256 over the canonicalized request binding, which is method, route pattern, path, query and the body hash (crates/platform/chio-http-core/src/request.rs:168-188).policy_hash: SHA-256 of the spec bytes chio loaded at start (crates/products/chio-api-protect/src/proxy/state.rs:575). Rotate the spec and the hash changes, so a downstream verifier notices.receipt_kind,boundary_class,tool_origin,redaction_modeandtrust_level: five non-optional classification fields. A receipt that is missing any of them is not anHttpReceipt, and/chio/verifychecks three of them before it reportsok.session_id: absent. The proxy path always sets it toNone(crates/products/chio-api-protect/src/evaluator.rs:363), and the field is skipped when serializing, so it never appears.
Reading receipts back
chio receipt list does not return HttpReceipt rows. The sidecar writes those to an http_receipts table in the store file (crates/products/chio-api-protect/src/proxy/state.rs:144-147); what the CLI lists is the kernel receipt for the same decision, whose tool server is always chio_http_authority and whose tool name is always authorize_http_request. The route lives one level down, in action.parameters.route_pattern, so a filter written against a top-level route_pattern silently matches nothing.
$ chio --receipt-db ./receipts.sqlite receipt list --admin-all \
| jq -c 'select(.action.parameters.route_pattern == "/orders/{orderId}/refund")
$ | {id, tool_server, tool_name, decision,
$ route: .action.parameters.route_pattern}'{"id":"7ff5f3b5fed6b5d74c6048eae04a6e0ed26ec2c02016d7c13689f4401009ec0f","tool_server":"chio_http_authority","tool_name":"authorize_http_request","decision":{"verdict":"deny","reason":"guard denied the request: guard \"http_projection_policy\" error (fail-closed): guard denied the request: side-effect route requires a capability token","guard":"kernel"},"route":"/orders/{orderId}/refund"}
{"id":"1b409feb6b244d7758d988fc4d6c2fcb2df201782ac725be67b2cfc97806a03c","tool_server":"chio_http_authority","tool_name":"authorize_http_request","decision":{"verdict":"allow"},"route":"/orders/{orderId}/refund"}
{"id":"6fc6f7ad5e142d248a4b486a2f9e1d23eca9b0217c8e0c345d811f0e2b9b1dd9","tool_server":"chio_http_authority","tool_name":"authorize_http_request","decision":{"verdict":"deny","reason":"guard denied the request: guard \"http_projection_policy\" error (fail-closed): guard denied the request: capability signature verification failed","guard":"kernel"},"route":"/orders/{orderId}/refund"}
{"id":"077f7145b73f795a514594e2124513921b9089e39c5e3c89a2856fe0b0a668c5","tool_server":"chio_http_authority","tool_name":"authorize_http_request","decision":{"verdict":"deny","reason":"guard denied the request: guard \"http_projection_policy\" error (fail-closed): guard denied the request: invalid capability token: capability expired at 1788589598","guard":"kernel"},"route":"/orders/{orderId}/refund"}The two records are joined by metadata.chio_kernel_receipt_id on the HTTP receipt, which is the id of the kernel receipt. Note also that the kernel receipt's decision.guard is kernel rather than the guard name, because the HTTP verdict reaches the kernel as one projected guard result.
The chio receipt list filters are: --capability, --tool-server, --tool-name, --outcome, --since, --until, --min-cost, --max-cost, --cost-currency, and paging (--limit, --cursor). The listing also fails closed on tenant scope: pass --tenant <id> or --admin-all, or it refuses rather than defaulting to every tenant. There is no route-pattern filter: shape the query by server plus tool and refine with jq.
Sensitive headers are not in the receipt, but do not be casual
Authorization or X-Api-Key values into receipts. Only hashes reach the signed body. That said, your upstream may log request bodies that contain secrets, and Chio does not sanitize those. Treat the upstream log pipeline with the same rigor you give chio receipts. For fields you know contain PII or credentials, prefer schema-level hashing upstream of the proxy so the plaintext never reaches disk.Common Patterns
Public read, authorized write
Reads are audit-only; writes require a capability. This is the default behavior, so no spec annotations are needed: just run chio api protect against your upstream.
$ chio api protect --upstream https://catalog.internal:8080 \
--spec ./catalog.openapi.yaml \
--listen 0.0.0.0:9090 \
--receipt-store ./catalog-receipts.sqliteAgents can freely call GET /products and GET /products/{sku}; every call writes an audit receipt. Any POST, PATCH, or DELETE requires a signed capability and fails closed with a 403 receipt when absent.
Scoping one sidecar per boundary
A capability grant cannot name a route. Its shape is a ToolGrant, whose narrowing fields are operations, constraints, max_invocations, max_cost_per_invocation, max_total_cost and dpop_required (crates/core/chio-core-types/src/capability/scope.rs:94-117). On the sidecar path server_id and tool_name are pinned to chio_http_authority and authorize_http_request, so a token that is valid for one route is valid for every governed route on that sidecar.
The tenant boundary therefore has to be a deployment boundary. Run one chio api protect per tenant, each with its own --spec covering only that tenant's routes and its own --receipt-store, and mint that tenant's capabilities against that sidecar. Two sidecars that do not share an authority seed do not share an issuer, so a token minted by one is refused by the other.
One token, every governed route on that sidecar
POST /orders/{orderId}/refund. Present a token minted for the refund flow against any other side-effect route on the same sidecar and it is accepted. Reach for a narrow ttl_seconds and a small max_invocations, and keep unrelated surfaces behind separate sidecars.Webhook and egress control
Point Chio the other direction: an agent that needs to POST to a third-party webhook can be routed through a chio-protected egress proxy. The same controls apply with a different audience. Because the spec covering the egress sidecar lists only the webhook routes, everything else is an unmatched side-effect path and denies by default. Because receipts bind the request hash, an attempt on an approved URL that gets through still leaves a non-repudiable record.
# Egress proxy in front of a stripe-like webhook target
$ chio --authority-seed-file ./egress-seed.hex \
api protect --upstream https://api.thirdparty.example \
--spec ./thirdparty-webhook.openapi.yaml \
--listen 127.0.0.1:9191 \
--receipt-store ./egress-receipts.sqlite
# Agent calls localhost; chio checks capability, forwards outbound
$ curl -X POST http://127.0.0.1:9191/v1/webhooks/orders \
-H "X-Chio-Capability: $(jq -c .capability ./webhook-cap.json)" \
-H "Content-Type: application/json" \
-d @event.jsonSensitive reads require capability
Some GET endpoints return sensitive data and should not be treated as safe. Annotate them in the spec:
paths:
/users/{userId}/ssn:
get:
operationId: getUserSsn
x-chio-sensitivity: restricted
x-chio-side-effects: true
x-chio-approval-required: true
responses:
"200": { description: OK }Now the route is treated as side-effect: a capability is required, and because x-chio-approval-required is set, the route would deny by default even if x-chio-side-effects were false. Approval-required is belt and braces on the policy decision, not a hold: present a valid capability and the request forwards to the upstream in the same exchange. Nothing on this path waits for an operator. Gate on a human with the approval endpoints on the sidecar listener, or with chio_approval in chio-fastapi, and read the spec annotation as a statement of intent.
The Sidecar Wire Protocol
The same binary that runs the reverse proxy also speaks a small JSON wire protocol on the sidecar listener, which is how SDK middleware evaluates a request in-process. Four /chio/* routes, all JSON over the default 127.0.0.1:9090 listener (override with the SDK's sidecarUrl or the CHIO_SIDECAR_URL env var where the SDK reads one):
POST /chio/evaluatereturns{ verdict, receipt, evidence }, plus anexecution_noncewhen the kernel issues one. It responds with HTTP 200 for both allow and deny: theverdictfield carries the outcome, not the status code. A middleware fails closed on any other status. The request body is aChioHttpRequest, which requiresrequest_id,method,route_pattern,path,callerandtimestamp.POST /chio/verifychecks a receipt and returns eleven booleans and strings, not one verdict field (crates/platform/chio-http-core/src/evaluation.rs:33-45).signature_validis only the Ed25519 check;okis that plussigner_trusted,receipt_id_valid,parameter_hash_validand the semantic classification; andauthorizedisoknarrowed to an allow verdict. Readauthorized, notsignature_valid, when you mean “this call was permitted”. None of the eleven is a temporal check: a valid signature on an expired receipt still reportssignature_valid: true.GET /chio/healthis dependency-aware readiness. It returns{ status, version, receipt_backend, revocation_backend }and503when the status isdegradedorunhealthy.GET /chio/liveis process-only liveness. Same body, but the two backend fields are empty strings because it never touches storage. Point a liveness probe here and a readiness probe at/chio/health, so a dependency blip pulls the instance from rotation without killing it.
SDK middleware defaults to a 5000ms timeout on these calls and must fail closed when the sidecar is unreachable or times out. Several SDKs expose an onSidecarError or fail-open option. In the TypeScript and .NET packages the setting is reserved and inert, and their own tests assert that onSidecarError: "allow" still fails closed. Rust's chio-tower is the one that honors it: with with_fail_open(true) the service logs and forwards the request unenforced (crates/protocol/chio-tower/src/service.rs:136-142). Everywhere else, treat fail-closed as the behavior you get.
Alternative: In-process Middleware
The gateway approach above (chio api protect) runs chio as a separate process in front of your service. The SDK middleware approach moves the same enforcement logic inside your application, as a package you import. Both end up with the same signed HttpReceipt on every call; the question is where the enforcement happens.
When to pick which
| Aspect | chio api protect (gateway) | SDK middleware (in-process) |
|---|---|---|
| Deploy shape | Separate process in front of your service | One package added to your service |
| Extra hop | Yes (proxy forwards to upstream) | No (evaluates inline) |
| Language | Language-agnostic; anything that speaks HTTP | Requires an SDK for your framework |
| Policy source | OpenAPI spec plus x-chio-* extensions | Same policy via the sidecar, plus native route decorators and DI |
| Sidecar dependency | Self-contained binary | Middleware calls a local Chio sidecar over HTTP |
| Best for | Services you cannot modify; multi-tenant edge gateways | Services that can add a Chio dependency and use framework-specific bindings |
Fail-closed in both modes
403 with the same structured ChioErrorResponse body the gateway emits.Wire the middleware
One package per ecosystem, then a handful of lines:
$ pip install chio-asgi # or chio-django, chio-fastapi
$ npm install @chio-protocol/express # or @chio-protocol/fastify, @chio-protocol/elysiaGo, the JVM, and .NET take their dependency from the tree rather than from a registry. Go adds a replace directive to go.mod: replace github.com/backbay-labs/chio/sdks/go/chio-go-http => ../../sdks/go/chio-go-http. The JVM adds includeBuild("../../sdks/jvm") to settings.gradle.kts, which is what resolves implementation("world.chio:chio-spring-boot:0.1.0") in build.gradle.kts. .NET adds <ProjectReference Include="../../sdks/dotnet/ChioMiddleware/src/ChioMiddleware.csproj" /> to the csproj.
Five reference apps in the Chio source tree implement the identical route set against the identical contract: GET /healthz outside Chio entirely, GET /hello allowed with a receipt, and POST /echo denied without a capability. Each one sits behind the same chio api protect invocation, which is byte-identical across all five smoke scripts. What differs is the handful of lines that put the middleware in front of the app:
from chio_asgi import ChioASGIMiddleware
from chio_asgi.config import ChioASGIConfig
def build_chio_config(sidecar_url: str | None = None) -> ChioASGIConfig:
return ChioASGIConfig(
sidecar_url=sidecar_url
or os.environ.get("CHIO_SIDECAR_URL", "http://127.0.0.1:9090"),
exclude_paths=frozenset({"/healthz"}),
)
if enable_chio:
app.add_middleware(
ChioASGIMiddleware,
config=chio_config or build_chio_config(),
)import express from "express";
import { chio, chioErrorHandler } from "@chio-protocol/express";
const app = express();
app.use(chio({ config: "chio.yaml" }));
app.use(chioErrorHandler);import { chio, chioErrorHandler } from "@chio-protocol/express";
if (enableChio) {
app.use(
chio({
sidecarUrl,
skip: ["/healthz"],
}),
);
}
app.use(express.json());
app.use(chioErrorHandler);chio "github.com/backbay-labs/chio/sdks/go/chio-go-http"
func protectedHandler(sidecarURL string) http.Handler {
return chio.Protect(
newRouter(),
chio.WithSidecarURL(sidecarURL),
)
}import world.chio.ChioFilter
import world.chio.ChioFilterConfig
@SpringBootApplication
class HelloSpringBootApplication {
@Bean
fun chioFilterRegistration(): FilterRegistrationBean<ChioFilter> {
val filter = ChioFilter(
ChioFilterConfig(
sidecarUrl = System.getenv("CHIO_SIDECAR_URL") ?: "http://127.0.0.1:9090",
),
)
return FilterRegistrationBean<ChioFilter>().apply {
setFilter(filter)
addUrlPatterns("/hello", "/echo")
order = Ordered.HIGHEST_PRECEDENCE
}
}
}using Backbay.Chio;
internal static WebApplication Create(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddChioProtection();
var app = builder.Build();
app.MapGet("/healthz", HelloEndpoints.Health);
app.UseWhen(
context => RequiresChioProtection(context.Request.Path),
branch => branch.UseChioProtection());
app.MapHelloEndpoints();
return app;
}
internal static bool RequiresChioProtection(PathString path) =>
!path.Equals("/healthz", StringComparison.OrdinalIgnoreCase);The two Node tabs are the two ways to tell the middleware where the sidecar is. The TypeScript one is the package's own documented form and names a chio.yaml the sidecar reads routes and policy from. The JavaScript one is the runnable reference app, which passes the URL directly and skips /healthz; it ships as plain ESM .mjs, and since the package carries its own declarations those lines type-check unchanged in a .ts file. The Spring Boot example is Kotlin and the JVM filter is a separate artifact from the JVM client: world.chio:chio-spring-boot carries ChioFilter, and world.chio:chio-sdk-jvm carries world.chio.sdk.ChioClient.
Verify the result
Each example ships a smoke.sh that brings up the trust control plane, the sidecar, and the app, then walks the three routes. Run the Python one with ./run-hello-smokes.sh hello-fastapi. Its last four lines are hello-fastapi smoke passed, an artifacts: path, and the three receipt ids the run produced, one per route. The artifacts directory holds the exchanges themselves. The allowed read carries its receipt id on the response:
$ cat hello.headers hello.jsonHTTP/1.1 200 OK
date: Sat, 05 Sep 2026 11:49:07 GMT
server: uvicorn
content-length: 32
content-type: application/json
x-chio-receipt: 1dba0f38326d8eaba47d2b8e50a54fdeb0da056639cbaa20fca0100e0dc67f64
{"message":"hello from fastapi"}The write with no capability never reaches the handler. The response is the denial, and it is receipted too:
$ cat deny.headers deny.jsonHTTP/1.1 403 Forbidden
date: Sat, 05 Sep 2026 11:49:08 GMT
server: uvicorn
content-type: application/json
content-length: 103
x-chio-receipt: 5d31d1463fe3819b3a5ffae0fb89e01c92339c3e4da455e6701511509690aaa3
{"error": "CapabilityGuard", "message": "side-effect route requires a capability token", "status": 403}Note the body: json.dumps default separators put a space after each colon and comma, which is why content-length reads 103 for a payload that looks shorter than that.
The other four apps produce the same three verdicts against the same contract. Two details differ and both are configuration, not behavior: the Python examples name the header X-Chio-Receipt because their settings say so, while the Node, Go, Kotlin, and C# examples use X-Chio-Receipt-Id; and the Django app renders the denial in its own envelope, {"error": {"code": "CHIO_GUARD_DENIED", ...}}.
Where the receipt lands
Every middleware attaches the signed receipt to the response and, where the framework has somewhere to put it, to the request object your handler already has:
| Package | Entry point | Receipt on the request |
|---|---|---|
chio-asgi | ChioASGIMiddleware | Response header, name set by receipt_header (default X-Chio-Receipt). |
chio-django | ChioDjangoMiddleware | request.chio_receipt, a dict whose id is the receipt id. |
@chio-protocol/express | chio(), chioErrorHandler | req.chioResult.receipt. verdict is a tagged union: narrow it with isAllowed() rather than comparing to a string. |
@chio-protocol/fastify | fastify.register(chio, ...) | request.chioResult, the EvaluateResponse. There is no chioResult.caller; caller identity rides on the receipt as caller_identity_hash. |
chio-go-http | chio.Protect | Response header X-Chio-Receipt-Id. Wraps any http.Handler, so chi, gorilla/mux, Echo, and Gin all work through their adapters. |
chio-spring-boot | ChioFilter | Response header X-Chio-Receipt-Id. Configure under the chio prefix in application.yaml. |
Backbay.Chio.Middleware | AddChioProtection(), UseChioProtection() | Response header X-Chio-Receipt-Id. Namespace is Backbay.Chio. |
Per-route enforcement in FastAPI
The ASGI middleware governs the whole app. When you want a specific route to name the capability it needs, chio-fastapi adds decorators and dependency injection on top: chio_requires declares the grant, and get_caller_identity and get_chio_receipt hand the handler what the evaluator already computed.
from fastapi import FastAPI, Request
from chio_fastapi import chio_requires
app = FastAPI()
@app.post("/search")
@chio_requires(server_id="search-srv", tool_name="search_documents")
async def search(request: Request, query: str) -> dict:
return {"results": run_search(query)}A request with no capability gets HTTP 401 and the body error code CHIO_CAPABILITY_REQUIRED, asserted in sdks/python/chio-fastapi/tests/test_decorators.py. Stack chio_approval above chio_requires to gate a write on an operator-issued approval token above a monetary threshold.
Other frameworks
Additional first-party middleware packages ship alongside the SDKs:
- TypeScript:
@chio-protocol/fastify,@chio-protocol/elysia,@chio-protocol/node-http(the framework-agnostic base the other three are built on). - Python:
chio-django,chio-asgi(for Starlette, Litestar, and other ASGI frameworks),chio-fastapi(per-route decorators). - C++:
chio-drogon, linked as a CMake subdirectory and attached per route aschio::drogon::ChioMiddleware. - Rust:
chio-towerships atower::Layer, so axum and anything else built ontower::Servicewires the same way. This is the one path where the evaluator runs in your process instead of a neighboring one.
See each SDK's reference page for the full API: TypeScript SDK, Python SDK, Go SDK, JVM SDK, .NET SDK, Rust SDK.
Failures and Recovery
The sidecar refuses rather than degrades, and it says which input it was missing. These are the four you will meet first.
It will not start without a receipt store
$ chio api protect \
--upstream http://127.0.0.1:8080 \
--spec ./openapi.yaml \
--listen 127.0.0.1:0error [urn:chio:error:cli:other]: refusing to start without durable receipts: pass --receipt-store <path> for a durable audit log on a filesystem path, or --allow-ephemeral-receipts to run with in-memory receipts that are lost on every restart
context: {"domain":"cli","severity":"error","stability":"deprecated","string_code":"CHIO-CLI-OTHER"}
suggested fix: Preserve the original message and migrate the call site to a specific registry code when touched.Give it --receipt-store <path>, or --allow-ephemeral-receipts for a local run whose audit trail you are willing to lose on restart. Every chio error is this three-line envelope: the code and message, a context object, and a suggested fix. A bad --spec path fails the same way, with urn:chio:error:transport:http-failed and failed to load OpenAPI spec.
A token whose bytes were changed
$ BAD=$(jq -c '.capability.subject = "00000000..."' capability.json)
$ curl -s -X POST http://127.0.0.1:9090/orders/ord-42/refund \
-H "X-Chio-Capability: $BAD" -d '{}'{"error":"chio_access_denied","message":"capability signature verification failed","receipt_id":"c751fa3734bff4c2716cc851363402515490fd5edbccbd4730a345f0a0ba88e6","suggestion":"provide a valid capability token in the X-Chio-Capability header or chio_capability query parameter"}The signature covers the whole token, so editing any field, including the subject, invalidates it. The same message appears for a token minted by a different sidecar whose public key is not in this one's trusted issuer set, which is what happens when two instances do not share an --authority-seed-file.
A token past its expiry
$ EXPIRED=$(curl -s -X POST http://127.0.0.1:9090/v1/capabilities/mint \
-H 'content-type: application/json' \
-d '{"subject": "agent-refunds", "ttl_seconds": 1,
$ "scopes": ["tool:chio_http_authority:authorize_http_request:invoke"],
$ "job_uid": "short"}' | jq -c .capability)
$ sleep 3
$ curl -s -X POST http://127.0.0.1:9090/orders/ord-42/refund \
-H "X-Chio-Capability: $EXPIRED" -d '{}'{"error":"chio_access_denied","message":"invalid capability token: capability expired at 1788589598","receipt_id":"33b7fe82430c395b6c38cbe823ba106005139cc81cc54fbe7d41b4f7fff8d579","suggestion":"provide a valid capability token in the X-Chio-Capability header or chio_capability query parameter"}The refusal names the epoch second the token expired at, so a clock skew between the minting host and the sidecar shows up as a token that was never valid. Mint a fresh one; there is no refresh.
The upstream is unreachable or slow
An allowed request whose upstream hop fails gets a 502, and the receipt is still finalized and stored, with response_status: 502 (crates/products/chio-api-protect/src/proxy/router.rs:661-676). A hop that runs past --upstream-timeout-secs (20 by default, and the ceiling covers reading the whole response) lands the same way. So a 502 from a chio-protected endpoint is an upstream problem, not a policy one, and the receipt log will show it as an allow.
Summary
Protecting an API with Chio gives you:
| Capability | How it is delivered |
|---|---|
| Route-aware policy | OpenAPI spec plus x-chio-* extensions; no per-route HushSpec rules required. |
| Capability-scoped writes | Side-effect methods deny by default; X-Chio-Capability token gates each call. |
| Signed receipts | Ed25519-signed HttpReceipt per call; decision plus final scopes. |
| Auth pass-through | Upstream still sees its own Authorization/API-key headers unchanged. |
| Zero upstream changes | The proxy is transparent; the service behind Chio is unmodified. |
Next Steps
- Write a Policy · guide to HushSpec authoring. Even though HTTP policy is spec-driven, your capability scopes and approval rules still live in HushSpec.
- Wrap an MCP Server · the MCP analog of this guide. Same receipt model, different transport.
- Guards · how the
CapabilityGuardandDefaultPolicyGuardcompose into the HTTP evaluation pipeline. - Envoy ext_authz · run chio as an external authorization service behind Envoy instead of as a standalone proxy.