Chio/Docs
LOGIN · JOIN

OpenAPI Sidecar

Put Larkfield's plain HTTP pricing API behind Chio, and let its OpenAPI document decide which routes need a grant.

Guide chio@5f63e6e

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.

Start

Four operations, one inspectable boundary

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

Terminal 1 · the APIbash
chio new pricing-api --example pricing-api && cd pricing-api
python3 pricing_api.py
Output
Pricing API: http://127.0.0.1:8087; data: .pricing/quotes.db
Terminal 2 · the sidecarbash
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.db

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

Terminal 3 · pricing-apibash
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
Output
{"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.

pricing_api.py · in do_POSTpython
        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
pricing_api.pypython
"""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.yaml · completeyaml
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

ExtensionTypeEffect
x-chio-side-effects
boolean
Overrides the method default: true for a GET that should need a grant, false for a POST that only reads.
x-chio-approval-required
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-chio-budget-limit
integer (minor units)
A per-invocation cost cap, applied by the budget guard against the caller's remaining budget.
x-chio-sensitivity
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:

Terminal 3 · pricing-apibash
python3 receipts.py
Output
01a12ef0-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.

Next: serve the same API to an agent