OpenAPI Sidecar
Put Larkfield's plain HTTP pricing API behind Chio, and let its OpenAPI document decide which routes need a grant.
On this page
A service with no Chio code in it can still sit behind the kernel. The sidecar reads the service's OpenAPI document into a route table, asks the kernel about each request, forwards what is admitted, and signs a receipt for every decision.
Starts from chio new pricing-api --example pricing-api. The API prices quotes with Python's standard library and keeps them in SQLite; the sidecar adds the authority boundary in front of it. Protect an API walks the same project step by step, including revocation; this page reads the contract and the evidence.
Four operations, one inspectable boundary
| Operation | Default HTTP policy |
|---|---|
GET /v1/quotes | List up to 100 issued quotes; no capability required. |
POST /v1/quote | Issue and record a quote; marked side-effecting, so it needs a capability. |
GET /v1/quotes/{quoteId} | Read one quote; no capability required. |
DELETE /v1/quotes/{quoteId} | Withdraw a quote; a side-effecting method, so it needs a capability. |
1. Run the API and the sidecar
You need Python 3.11 or newer, jq, and the installed chio. You can also download the project.
chio new pricing-api --example pricing-api && cd pricing-api
python3 pricing_api.pyPricing API: http://127.0.0.1:8087; data: .pricing/quotes.db
chio --authority-seed-file .pricing/authority.hex api protect \
--upstream http://127.0.0.1:8087 \
--spec openapi.yaml --listen 127.0.0.1:9097 \
--receipt-store .pricing/receipts.dbIn a third terminal in the same directory, ask for a quote without a grant, mint the five-minute grant http-grant.json describes for Basalt's buying agent, ask again with it, and list the quotes.
curl -sS -w ' HTTP %{http_code}\n' http://127.0.0.1:9097/v1/quote \
-H 'Content-Type: application/json' -d '{"sku":"sku-301","percent":80}'
curl -sS http://127.0.0.1:9097/v1/capabilities/mint \
-H 'Content-Type: application/json' --data-binary @http-grant.json -o capability.json
curl -sS -w ' HTTP %{http_code}\n' http://127.0.0.1:9097/v1/quote \
-H 'Content-Type: application/json' \
-H "X-Chio-Capability: $(jq -c .capability capability.json)" \
-d '{"sku":"sku-301","percent":80}'
curl -sS http://127.0.0.1:9097/v1/quotes{"error":"chio_access_denied","message":"side-effect route requires a capability token","receipt_id":"01a12ef0-bf17-7d80-ac22-c82d49abccb6","suggestion":"provide a valid capability token in the X-Chio-Capability header or chio_capability query parameter"} HTTP 403
{"id":1,"sku":"sku-301","percent":80,"price":"28.00","currency":"USD"} HTTP 201
{"quotes":[{"id":1,"sku":"sku-301","percent":80.0,"price":"28.00","currency":"USD"}]}You should see 403 and no quote recorded, then 201 with USD 28.00, then the one quote. Keep requests on port 9097: a request sent straight to port 8087 bypasses Chio.
Keep the application yours
The API validates its own input, prices with Decimal, and commits the quote to SQLite. Change its data model and business rules here; Chio does not replace them.
price = str((PRICES[sku] * (1 - Decimal(str(percent)) / 100)).quantize(CENT, ROUND_HALF_UP))
with sqlite3.connect(self.server.database) as database:
cursor = database.execute("INSERT INTO quotes(sku, percent, price) VALUES (?, ?, ?)", (sku, percent, price))
quote_id = cursor.lastrowid
self.respond(201, {"id": quote_id, "sku": sku, "percent": percent, "price": price, "currency": "USD"})Complete application source
"""Larkfield's pricing API over HTTP, with SQLite persistence and no Chio dependency."""
import argparse
from decimal import ROUND_HALF_UP, Decimal
import json
import sqlite3
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
PRICES = {"sku-118": Decimal("24.00"), "sku-204": Decimal("58.00"), "sku-301": Decimal("140.00")}
CENT = Decimal("0.01")
PREFIX = "/v1/quotes/"
def row_to_quote(row):
return {"id": row[0], "sku": row[1], "percent": row[2], "price": row[3], "currency": "USD"}
class Pricing(BaseHTTPRequestHandler):
def setup(self):
super().setup()
self.connection.settimeout(10)
def respond(self, status, payload):
body = json.dumps(payload, separators=(",", ":")).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = urlsplit(self.path).path
with sqlite3.connect(self.server.database) as database:
if path == "/healthz":
return self.respond(200, {"status": "ready"})
if path == "/v1/quotes":
rows = database.execute("SELECT id, sku, percent, price FROM quotes ORDER BY id LIMIT 100").fetchall()
return self.respond(200, {"quotes": [row_to_quote(row) for row in rows]})
if path.startswith(PREFIX) and path[len(PREFIX):].isdigit():
row = database.execute("SELECT id, sku, percent, price FROM quotes WHERE id = ?", (int(path[len(PREFIX):]),)).fetchone()
if row:
return self.respond(200, row_to_quote(row))
self.respond(404, {"error": "quote_not_found"})
def do_POST(self):
if urlsplit(self.path).path != "/v1/quote":
return self.respond(404, {"error": "route_not_found"})
if self.headers.get("Transfer-Encoding"):
return self.respond(400, {"error": "content_length_required"})
try:
length = int(self.headers.get("Content-Length", "0"))
if not 0 < length <= 8192:
return self.respond(413, {"error": "body_must_be_1_to_8192_bytes"})
raw = self.rfile.read(length)
if len(raw) != length:
return self.respond(400, {"error": "incomplete_body"})
payload = json.loads(raw)
if not isinstance(payload, dict) or set(payload) != {"sku", "percent"}:
return self.respond(400, {"error": "body_must_contain_sku_and_percent"})
sku, percent = payload["sku"], payload["percent"]
if sku not in PRICES:
return self.respond(400, {"error": "unknown_sku"})
if isinstance(percent, bool) or not isinstance(percent, (int, float)) or not 0 <= percent <= 100:
return self.respond(400, {"error": "percent_must_be_0_to_100"})
except (ValueError, UnicodeError):
return self.respond(400, {"error": "invalid_json_body"})
price = str((PRICES[sku] * (1 - Decimal(str(percent)) / 100)).quantize(CENT, ROUND_HALF_UP))
with sqlite3.connect(self.server.database) as database:
cursor = database.execute("INSERT INTO quotes(sku, percent, price) VALUES (?, ?, ?)", (sku, percent, price))
quote_id = cursor.lastrowid
self.respond(201, {"id": quote_id, "sku": sku, "percent": percent, "price": price, "currency": "USD"})
def do_DELETE(self):
path = urlsplit(self.path).path
if not path.startswith(PREFIX) or not path[len(PREFIX):].isdigit():
return self.respond(404, {"error": "route_not_found"})
with sqlite3.connect(self.server.database) as database:
cursor = database.execute("DELETE FROM quotes WHERE id = ?", (int(path[len(PREFIX):]),))
self.respond(200 if cursor.rowcount else 404, {"withdrawn": cursor.rowcount == 1})
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", type=int, default=8087)
parser.add_argument("--data", type=Path, default=Path(".pricing"))
args = parser.parse_args()
args.data.mkdir(mode=0o700, parents=True, exist_ok=True)
database = args.data / "quotes.db"
with sqlite3.connect(database) as connection:
connection.execute("CREATE TABLE IF NOT EXISTS quotes(id INTEGER PRIMARY KEY, sku TEXT NOT NULL, percent REAL NOT NULL, price TEXT NOT NULL)")
server = ThreadingHTTPServer(("127.0.0.1", args.port), Pricing)
server.database = database
print(f"Pricing API: http://127.0.0.1:{args.port}; data: {database}", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()The API binds loopback, accepts bodies up to 8 KiB, and lists the first 100 quotes. It has no user authentication or tenancy; add those before serving other users.
Edit the contract for your service
The same document supplies the sidecar's route policy and, through the OpenAPI bridge, the agent's named tools.
openapi: 3.1.0
info:
title: Larkfield pricing
version: 1.0.0
paths:
/v1/quotes:
get:
operationId: listQuotes
summary: List the first 100 quotes issued
responses:
"200": { description: Quotes returned }
/v1/quote:
post:
operationId: quote
summary: Quote a SKU at a discount and record the quote
x-chio-side-effects: true
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
sku: { type: string }
percent: { type: number, minimum: 0, maximum: 100 }
required: [sku, percent]
responses:
"201": { description: Quote issued }
/v1/quotes/{quoteId}:
parameters:
- name: quoteId
in: path
required: true
schema: { type: integer, minimum: 1 }
get:
operationId: readQuote
summary: Read one issued quote
responses:
"200": { description: Quote returned }
delete:
operationId: withdrawQuote
summary: Withdraw one issued quote
responses:
"200": { description: Quote withdrawn }To require a capability for listing quotes, add x-chio-side-effects: true under /v1/quotes → get and restart the sidecar with the same seed and stores. Paths the document does not list still use the method default. The policy reference covers the route controls in full.
The x-chio-* extensions
| Extension | Type | Effect |
|---|---|---|
x- | boolean | Overrides the method default: true for a GET that should need a grant, false for a POST that only reads. |
x- | boolean | Makes the operation deny by default, ahead of method inference. The approval itself comes from a person through chio approvals pending and chio approve. |
x- | integer (minor units) | A per-invocation cost cap, applied by the budget guard against the caller's remaining budget. |
x- | public, internal, sensitive, restricted | A data classification that sets logging level and audit granularity; it leaves policy unchanged. |
x-chio-publish | boolean | Read by the OpenAPI bridge to omit an operation from the tool manifest. It does not block the HTTP route. |
2. Connect each response to its signed evidence
The sidecar matches each request to a route, the kernel evaluates the projected HTTP authority, and the sidecar forwards what it admits to the API without the X-Chio-Capability header. Every decision lands in .pricing/receipts.db, reads included:
python3 receipts.py01a12ef0-bf17-7d80-ac22-c82d49abccb6 DENY POST /v1/quote HTTP 403 01a12ef0-bf64-7c12-b46e-430b507267c3 ALLOW POST /v1/quote HTTP 201 01a12ef0-bf87-7c13-8e6f-4e5b177a5e6d ALLOW GET /v1/quotes HTTP 200
Each row carries the HTTP receipt ID from the response's x-chio-receipt-id header. Pass one to receipts.py to print the complete signed record, and post that record to the sidecar's /chio/verify to check it against the sidecar's key, as HTTP receipt verification shows. An HTTP receipt records the route, verdict, and status at this boundary, and its metadata links to the kernel decision. It cannot show that every access to the API went through Chio.
Source verification
Every Chio release passes the HTTP sidecar's smoke suite, which exercises a refused write, an authorized write, and receipt persistence against the built binary, before it is tagged. chio release verify --evidence qualification shows its result for the release you run; the suite itself runs from a source checkout, as Contributors describes.
Signing keys and sidecar deployment
The sidecar's authority key signs receipts and the capabilities it mints. A trusted issuer public key verifies another issuer's tokens and cannot sign anything. The MCP host generates, signs, and verifies its own tool manifest separately. Keep the signing seed and the stores across restarts, restrict the control endpoints, and make the upstream reachable only through the sidecar.