← kiosk.tech

Kiosk Protocol Specification

Version 0.3 — Draft (wire format stable)

What this specification answers

As an operator developer or AI assistant author, you want to understand how your software and an AI assistant communicate through Kiosk. This document describes the protocol — the exact HTTP requests and responses, the authentication model, and the payment mandate chain — backed by a working reference implementation.

The spec is intentionally small. It covers the invariants — the things every Kiosk operator and every compatible AI assistant must agree on. Individual operators extend the surface with their own queries and actions (catalog, delivery slots, booking — whatever their vertical needs).

Normative language. The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as described in RFC 2119. Requirements bind two conformance targets: the operator (the party serving the endpoints) and the AI assistant (the client calling them). An operator can be a service provider, an information steward, or a merchant/aggregator. Boxes marked Reference note describe the Ruby reference implementation and are non-normative — one way to satisfy a requirement, not the requirement itself.

Protocol version. This page specifies protocol version 0.3. The protocol, the reference implementation, and the skill share their MAJOR.MINOR version (version parity: an operator on Kiosk 0.3 pins a 0.3 skill against a 0.3 wire). A new MINOR (0.2 → 0.3) is a feature milestone that bundles backward-compatible additions — new endpoints and fields only. Within a MINOR series (0.3.x) the wire stays additive and backward-compatible — patches add endpoints and fields only, existing flows never break (the same rule the skill files follow below).

High-level flow

Flow 1: Discovery and registration

sequenceDiagram participant A as AI assistant participant P as Operator A->>P: GET /.well-known/kiosk.json P-->>A: {issuer, endpoint, capabilities, auth, skill} A->>A: Generate RSA-2048 keypair (first visit) A->>P: GET /kiosk/auth/challenge?public_key=… P-->>A: {challenge, exp} A->>A: Sign challenge as RS256 JWS (aud = this origin) A->>P: POST /kiosk/auth/register
{public_key, signed} P-->>A: 201 {agent_id, user_id, access_token (RS256 JWT)} Note over A,P: Returning key → POST /kiosk/auth/login
{public_key, signed} → 200 {access_token} A->>P: GET /kiosk/schema
Authorization: Bearer {token} P-->>A: {verbs: [...], queries: [...], actions: [...]}

Flow 2: Proof-of-work (when required)

sequenceDiagram participant A as AI assistant participant P as Operator A->>P: POST /kiosk/query {name: "catalog"} P-->>A: 402 {error: {code: "pow_required",
challenges: [{id, alg, params, salt, exp, sig}, …]}} A->>A: Solve each Equihash challenge
(reference solver: ~10s, ~1.3 GiB per proof) A->>P: POST /kiosk/query {name: "catalog",
pow: {proofs: [{challenge, nonce: {indices}}, …]}} P-->>A: 200 {rows: [...]}

Flow 3: Browse, order, pay

sequenceDiagram participant A as AI assistant participant P as Operator participant S as Stripe A->>P: POST /kiosk/query {name: "catalog"} P-->>A: {rows: [{sku, name, price_cents}, ...]} A->>P: POST /kiosk/run {name: "create_order", items: [...]} P-->>A: {value: {order_id, total_cents}} A->>P: POST /kiosk/run {name: "payment_setup"} P-->>A: {value: {status: "setup_required", setup_url}}
or {status: "ready"} opt Card setup (first time) A->>A: Hand setup_url to human Human->>S: Enter card on Stripe hosted page A->>P: POST /kiosk/run {name: "payment_setup"} P-->>A: {value: {status: "ready"}} end A->>A: Sign 3 JWS mandates
(intent → cart → payment) A->>P: POST /kiosk/pay
{intent_mandate_jws, cart_mandate_jws, payment_mandate_jws} P-->>A: {value: {settlement_id, psp_reference}} A->>P: POST /kiosk/run {name: "schedule_delivery", ...} P-->>A: {value: {order_id, scheduled_at}}

Endpoints

VerbMethodPathAuthPurpose
GET/.well-known/kiosk.jsonNoneDiscovery: issuer, endpoint, capabilities, auth, and the skill URL + SHA-256 (dual-check)
GET/kiosk/auth/challengeNoneGet a single-use challenge nonce for a public key
POST/kiosk/auth/registerNoneRegister a NEW public key with a signed challenge → access_token
POST/kiosk/auth/loginNoneRefresh a token for an EXISTING public key with a signed challenge
POST/kiosk/auth/revokeBearerRevoke all of this identity's tokens ("log out other sessions"); returns a fresh one
GET/kiosk/.well-known/jwks.jsonNoneJWKS (RFC 7517): the operator's public signing keys, so an AI assistant can verify the RS256 access tokens Kiosk issues
POST/kiosk/oauth/device_authorizationNoneAccount binding: start the claim ceremony for a public key → user_code + verification_uri
GET/POST/kiosk/oauth/device/verifyOperator sessionAccount binding: the human approves/denies the code (human-side page, not an AI assistant call)
POST/kiosk/oauth/tokenPoP (signed)Account binding: poll for completion with a possession proof → kiosk JWT (OAuth-shaped, see Account binding)
POST/kiosk/auth/linkOperator sessionAccount binding: the human mints a single-use link code (human-side)
POST/kiosk/auth/claimPoP (signed)Account binding: redeem a link code with {code, public_key, signed} → linked assistant account + token
POST/kiosk/auth/unlinkOperator sessionAccount binding: deactivate a key's binding (registration-layer revocation, human-side)
schemaGET/kiosk/schemaBearerMachine-readable surface: queries + actions
queryPOST/kiosk/queryBearerRead data (catalog, slots, orders)
runPOST/kiosk/runBearerPerform action (create_order, schedule_delivery, payment_setup)
payPOST/kiosk/payBearerSettle payment with signed AP2 mandates

All POST bodies are JSON. All successful responses are JSON. The operator self-describes its surface via GET /kiosk/schema — queries and actions are operator-specific, not part of the spec.

Response envelope

Every schema, query, run, and pay response — success or error — is wrapped in a uniform envelope so an AI assistant can branch on one field. A success carries ok: true and a kind discriminator naming the payload shape, alongside the payload itself:

// query success                    // schema / action / pay success
{ "ok": true,                       { "ok": true,
  "kind": "rows",                     "kind": "value",
  "rows": [ … ] }                     "value": { … } }

// error (any endpoint)
{ "ok": false,
  "error": { "code": "…", "message": "…",
             "hint": "…",             // optional — remediation pointer
             "challenges": [ … ] } }  // pow_required only

The examples below show only the payload field for brevity; the full response always carries the ok/kind envelope shown here.

Error vocabulary

The error.code values are a closed, stable vocabulary — an AI assistant branches on code, never on the HTTP status alone. The error object is {code, message, hint?, challenges?}: hint is an optional remediation pointer (for example the known query names on a not_found), and challenges appears only on pow_required.

codeHTTPMeaning
bad_request400Malformed request: unparseable JSON body, missing or invalid fields
unauthenticated401Missing, invalid, expired, or revoked Bearer token
forbidden403Authenticated, but this identity may not do this
rls_denied403A row-level-security policy denied the statement (operators running opt-in RLS)
spending_cap_exceeded403The acting assistant's per-assistant spending cap would be exceeded by this pay; the human raises the cap (optional, operator-configured — see Payment)
kyc_required403An action requires a KYC attribute the AI assistant has not attested; hint names what is needed. The AI assistant submits a KYC attestation carrying the missing attributes, then retries (see KYC)
not_found404Unknown query or action name, or a missing resource; hint carries the known names
conflict409State conflict — e.g. registering a public key that is already registered (use /auth/login)
pow_required402Proof-of-work gate; carries challenges and WWW-Authenticate: Kiosk-PoW (see Proof-of-work)
payment_setup_required402Payment gate: no card on file; no challenges; carries WWW-Authenticate: Payment (see Card setup)
quota_exceeded429Reserved for the quotas companion; the core operator never emits it
action_failed500An operator-registered action raised
internal_error500Catch-all server error

The auth endpoints speak the same envelope: registering an already-known key is conflict/409, logging in an unknown key is not_found/404 (register first). The one exception on this page is the account-binding /kiosk/oauth/* pair, which follows the OAuth wire (see Account binding).

The schema verb — self-description

GET /kiosk/schema (Bearer) returns a kind: "value" envelope whose value is exactly {verbs, queries, actions}:

GET /kiosk/schema
Authorization: Bearer <jwt>

→ 200 OK
{
  "ok":   true,
  "kind": "value",
  "value": {
    "verbs":   ["query", "run", "pay", "schema"],
    "queries": [
      { "name": "catalog", "description": "Browse the product catalog", "params": null }
    ],
    "actions": [
      { "name": "create_order",  "description": "Create an order from items", "params": { "items": "array" } },
      { "name": "payment_setup", "description": "Check or begin card setup",  "params": null }
    ]
  }
}
ROADMAP — typed parameter schema. params is a free-form hint today; a structured parameter schema is under consideration. Treat descriptors as AI-assistant-facing documentation, not a validation contract.

Discovery

Every operator serves a discovery document at GET /.well-known/kiosk.json, unauthenticated, so an AI assistant can bootstrap from the origin alone. It carries the issuer (the AP2 mandate iss anchor), the API endpoint, the auth block, an optional pinned skill reference, and a top-level capabilities array. The document's own version field is the discovery-document format version (currently "1.0") — independent of the protocol version (0.3) this page specifies. It may also carry optional owner (operator contact) and min_client fields; this page shows the common fields, and the formal spec plus JSON Schema enumerate every field.

GET /.well-known/kiosk.json

→ 200 OK
{
  "kiosk": {
    "version":      "1.0",
    "issuer":       "https://getgroceries.com",
    "endpoint":     "https://getgroceries.com/kiosk",
    "capabilities": ["schema", "query", "run", "pay"],
    "auth": {
      "kind":          "kiosk-pop",
      "challenge_url": "https://getgroceries.com/kiosk/auth/challenge",
      "register_url":  "https://getgroceries.com/kiosk/auth/register",
      "login_url":     "https://getgroceries.com/kiosk/auth/login",
      "revoke_url":    "https://getgroceries.com/kiosk/auth/revoke",
      "device_authorization_url": "https://getgroceries.com/kiosk/oauth/device_authorization",
      "claim_url":     "https://getgroceries.com/kiosk/auth/claim"
    },
    "skill": { "url": "https://kiosk.tech/skill-v0.3.5.md", "sha256": "…" }
  }
}

capabilities is an array of the verb names this endpoint actually serves, drawn from the canonical set schema, query, run, pay, always in that order. Membership is derived from what the operator has registered:

Typically all four are present. HTTP methods are not encoded in capabilities: the method binding is fixed and known to the AI assistant — schema is GET, query is POST, run is POST, and pay is POST. An AI assistant needs the capability list, not a method map. There is no routing or verbs field; the endpoint paths derive from endpoint and the fixed verb-to-path binding in the Endpoints table above.

The "this site speaks Kiosk" signal. An operator advertises Kiosk support on its human-facing pages with a <link rel="kiosk"> tag in the HTML <head> (an equivalent HTTP Link: <…>; rel="kiosk" response header also counts), so an AI assistant browsing the site knows to bootstrap from /.well-known/kiosk.json on the same origin:

<link rel="kiosk" href="https://kiosk.tech/skill.md">

The tag is a signal, not a source: its href points at the universal skill on kiosk.tech, and an AI assistant must never load skill instructions from the operator itself — a malicious operator could inject arbitrary AI-assistant instructions. Everything operator-specific comes from the origin's own /.well-known/kiosk.json.

Skill versioning & the dual-check

The universal skill is versioned and immutable. Each published version is a distinct file at https://kiosk.tech/skill-vX.Y.Z.md whose content never changes once published; a change ships a NEW version file rather than editing an existing one. https://kiosk.tech/skill.md is the "latest" alias, byte-identical to the newest version file. A file's own version lives in its frontmatter version field, matching the version in its URL.

The optional skill pin in /.well-known/kiosk.json is a versioned URL plus its SHA-256 — e.g. { "url": "https://kiosk.tech/skill-v0.3.5.md", "sha256": "…" }. Because a version file is immutable, this pin cannot drift by construction. There is no skill-version field on the wire: the pinned version is read from the URL itself. When an operator pins a skill, the AI assistant performs the dual-check:

  1. Read the pinned version from the URL (skill-vX.Y.Z.md).
  2. If it is newer than the AI assistant's cached skill, fetch and adopt it before transacting — the operator may rely on newer protocol features.
  3. Fetch the file from kiosk.tech (never from the operator) and verify both that its frontmatter version matches the URL and that the SHA-256 of its content equals the pinned sha256.
  4. If verification fails, fall back to the locally cached skill.

Within a MINOR series (0.1.x, 0.2.x, 0.3.x, …), versions are backward-compatible and additive — new endpoints and fields only, existing flows never break — so an AI assistant on a newer patch version can still transact with an operator pinning an older one, and must update before transacting with an operator pinning a newer one. The skill's MAJOR.MINOR tracks the protocol version (version parity).

Standard discovery surfaces

Alongside kiosk.json — the canonical structured contract — an operator serves the emerging agent-web discovery surfaces. All are rendered from the same registry model as kiosk.json, so they cannot drift from it:

PathFormatWhat it carries
/agents.txtagents.txt v1.0, text/plain + CORS *Directives: Protocols: ap2 · Payments: required (emitted only when the operator serves pay) · Authorization: agent-auth auth-md · Identity: required · Skills: <the pinned skill URL>
/agents.jsonagents.json v1.0site, payments.ap2 (required: true) — the payments block is present only when the operator serves pay, authorization (protocols: ["agent-auth", "auth-md"], discovery: /.well-known/agent-configuration, identity: required), skills[]; the Kiosk wire rides the sanctioned x-kiosk extension (wire.verbs, wire.schema, min_client, api_version, mount_path, api_catalog)
/.well-known/agent-configurationRFC 8414-style JSONagent-auth discovery: issuer, endpoints (challenge / register / login / revoke), jwks_uri, auth_modes: ["kiosk-pop", "user-claimed", "link-code"], auth_md pointer
/.well-known/api-catalogRFC 9727, application/linkset+jsonLinkset of the live wire endpoints, filtered to the advertised capabilities; schema carries the service-desc relation
/auth.mdauth.md-format MarkdownThe auth surface for AI assistants that discover via auth.md: kiosk-pop presented as its anonymous-class registration with a proof-of-possession upgrade, the User-Claimed claim ceremony, the link flow (marked a Kiosk extension), and both revocation layers; ID-JAG is not supported (planned)

kiosk.json remains the canonical Kiosk contract; the standard surfaces are the envelopes around it — an AI assistant may bootstrap from any of them and end up on the same wire.

Registration & login (kiosk-pop)

Kiosk's auth scheme is kiosk-pop — a proof-of-possession challenge-response, advertised as auth.kind: "kiosk-pop" in the discovery document. It is not OAuth. A public key is not a credential — it is public. Before issuing a token, the operator requires proof of possession of the matching private key: it hands out a single-use challenge, the AI assistant signs it as an RS256 JWS. This also separates two operations the naïve flow conflates — register (a key the operator has never seen) and login (refresh a token for a key it already knows).

Step 1 — get a challenge for your public key. Single-use, short-lived.

GET /kiosk/auth/challenge?public_key=<url-encoded PEM>

→ 200 OK
{ "challenge": "S6f2yq…", "exp": 1751846400 }

Step 2 — sign the challenge as a compact RS256 JWS with your private key, then register (new key) or login (known key).

// JWS payload signed with your RSA private key
{
  "aud":   "https://getgroceries.com",   // the origin you connected to
  "nonce": "S6f2yq…",                     // the challenge from step 1
  "jti":   "0f1c…"
  // "iat": 1751846340                    — OPTIONAL: informational only (see below).
  // "pub": "<RFC 7638 thumbprint>"  — OPTIONAL: the server verifies it
  //   only when present (the public_key field already binds the proof).
}

POST /kiosk/auth/register        // NEW key — 409 Conflict if already registered (use /login)
{ "public_key": "-----BEGIN PUBLIC KEY-----\n…", "signed": "eyJhbGciOiJSUzI1NiJ9…" }
→ 201 Created
{ "agent_id": "b2e9…", "user_id": "a7f3…", "access_token": "eyJhbGciOiJSUzI1NiJ9.eyJhZ2VudF9pZCI6….Qk7…" }

POST /kiosk/auth/login           // KNOWN key — 404 if unknown (register first)
{ "public_key": "-----BEGIN PUBLIC KEY-----\n…", "signed": "eyJhbGciOiJSUzI1NiJ9…" }
→ 200 OK
{ "access_token": "eyJhbGciOiJSUzI1NiJ9.eyJhZ2VudF9pZCI6….Qk7…" }

The proof's required claims are exactly aud, nonce, and jti; iat is optional and informational only. The nonce is server-issued, single-use, and expires on a server-held TTL — it is the authoritative freshness and anti-replay bound, so a client-asserted iat (which the server cannot trust) adds nothing the server relies on. (Contrast the AP2 mandates below: those are standalone credentials carrying no server-issued nonce, so THEY require both iat and exp to bound their own validity window.)

Origin binding (relay defense). The aud claim MUST be the origin the AI assistant actually connected to — filled in by the AI assistant from the connection it dialed, never echoed from server-supplied data. The operator rejects any proof whose aud is not its own origin, so a signature captured by a malicious endpoint cannot be relayed to a different operator and cannot take over an existing account. This is WebAuthn's anti-phishing model, and the same audience-binding principle as the mandate iss claim below.

Identity ↔ card. A key the operator already knows maps to the same user_id, so a saved payment card survives across sessions — but that mapping is served by /auth/login, not by re-registering (registering a known key is a 409 Conflict). An AI assistant SHOULD generate a fresh keypair per operator origin — the keypair is the identity, and a per-origin key means no cross-operator identifier exists.

Reference note. The reference AI assistant scripts store the RSA-2048 private key at ~/.kiosk/<domain>/key.pem and the issued identity at ~/.kiosk/<domain>/identity.json.

AI assistants use kiosk-pop; an operator's own session is an additional channel. kiosk-pop is the default and the scheme every AI assistant uses: an AI assistant always authenticates the wire verbs (schema, query, run, pay) with the Authorization: Bearer access token above. An operator MAY additionally let its own already-authenticated principal — a signed-in web or mobile session on the operator's frontend — call those same wire endpoints, so the operator can build its human UI on the identical surface. When both are configured, each request is resolved agent-IdP first (verify the Bearer token); only when that yields no identity does the operator fall back to its session channel. This session channel is an operator-side integration seam, not something an AI assistant selects: an AI assistant never sees it and always presents kiosk-pop.

Optional registration toll. An operator MAY price fresh-identity minting: POST /auth/register can answer 402 pow_required with the same Equihash challenge wire as the Proof-of-work section below. The registration toll binds to the AI assistant's public key being registered (not to a request fingerprint), so a proof solved for one key cannot be reused to mint another. The AI assistant solves every challenge and resubmits the same signed body with the pow field. The proof-of-possession signature is not consumed on the 402, so the same signed is reused. Default is no registration toll.

Access-token format. The access_token is a standard 3-part RS256 JWT (header.payload.signature), not an opaque handle — it carries the agent_id and is signed by the operator, so the operator verifies it statelessly on every request. It is presented as Authorization: Bearer <jwt>. (The full claim set — sub = the identity's user_id, agent_id, actor, iat/nbf/exp, jti, and the optional role — is enumerated in the formal spec.) A role claim is optional: a single-role operator (the common case) omits it, and registration MUST NOT require or accept a client-requested role. When an operator distinguishes roles, it assigns the role itself while creating the account. An operator MAY source an AI assistant's role from a configured IdP from 0.3, indirectly via the bound human's role: at the account-binding link ceremony the human's IdP role is captured and set as the bound AI assistant's role. Direct agent-IdP (ID-JAG) role assertion stays planned.

Reference note. In the reference implementation the operator-assigned role is set in the assistant_creation hook while the account is created.

Public keys — GET /kiosk/.well-known/jwks.json. The token is signed with the operator's own signing key; the matching public key is published as a JWKS document (RFC 7517) at GET /kiosk/.well-known/jwks.json, unauthenticated. Each key carries kty, use: "sig", alg: "RS256", a kid thumbprint, and the public parameters n/e — never private parameters. Anyone who needs to verify a Kiosk-issued token independently of the issuing operator (an AI assistant checking its own token, an audit consumer, a cross-operator mandate validator) fetches this document and selects the key by kid. This is the /kiosk-mounted key source, distinct from the origin-root discovery document at /.well-known/kiosk.json.

Token & key lifetime. Access tokens are short-lived (default 1 hour); refresh by calling /auth/login again — the private key is the durable credential, not the token. Multiple concurrent tokens for one identity stay valid at once (multiple devices/instances); a new login never invalidates its siblings. To sign every other session out, call /auth/revoke: it stamps a per-identity "revoked-before" watermark, so every token issued before that instant stops verifying, then returns a fresh token so the caller stays signed in. The identity keypair itself is long-lived — it has no calendar expiry and is replaced by rotation or revocation, not by an expiry clock.

TODO — durable revocation ledger. The revocation watermark is currently held in-process. A durable, cross-process token ledger (the provisioned agent_tokens table) lands in a follow-up so the watermark survives restarts and multi-worker deployments.
TODO — key rotation. Rotate an identity by signing the new public key with the old private key; the operator rebinds the user_id (and its reputation) to the new key, so rotation is not a reputation reset. Endpoint shape TBD.

Identity binding (the session contract)

Every authenticated verb call executes as the identity carried by its Bearer token — the {user_id, agent_id} pair minted at registration. This is the contract that makes an operator's data plane safe to expose to AI assistants:

How an operator implements the scoping is out of scope for the wire — application-layer filtering, database row-level security, or both. The requirement is the observable behavior: cross-identity reads and writes fail.

Reference note. The Ruby reference propagates the identity into Postgres as a transaction-scoped setting (kiosk.current_user_id()) inside a per-request session context; registered queries and actions read it for scoping, and opt-in row-level-security policies enforce it as defense-in-depth. The demos' isolation flows demonstrate the cross-AI-assistant denial end-to-end.

Account binding — claim & link

kiosk-pop registration creates a self-standing assistant account. When the human already has an account at the operator, Kiosk instead binds the AI assistant to it: a one-time ceremony creates a durable link between the AI assistant's public key and the human's account. Binding does not change how tokens work — after the ceremony the AI assistant calls /auth/login with its private key like any other identity, and the ceremony never repeats. There are two directions.

Claim (AI-assistant-initiated)

The RFC 8628 device-authorization shape, carrying the AI assistant's key. The request body is application/x-www-form-urlencoded per RFC 8628; the parameters are shown here as a JSON object for readability:

POST /kiosk/oauth/device_authorization
{ "client_id": "my-assistant",              // required (RFC 8628) — shown to the human
  "public_key": "-----BEGIN PUBLIC KEY-----\n…" }

→ 200 OK
{ "device_code": "GmRhm…", "user_code": "WDJB-MJHT",
  "verification_uri": "https://getgroceries.com/kiosk/oauth/device/verify",
  "verification_uri_complete": "https://getgroceries.com/kiosk/oauth/device/verify?user_code=WDJB-MJHT",
  "expires_in": 900, "interval": 5 }

The AI assistant shows the human the verification_uri and user_code. The human opens the page in their own browser, authenticated by the operator's normal web session, sees exactly what is being bound (the key's fingerprint, when it was requested), and approves or denies. Meanwhile the AI assistant polls the token endpoint. The poll MUST carry a possession proof: signed is the same challenge-response JWS over {aud, nonce, jti} as register/login — fetch a challenge for the same public key first.

POST /kiosk/oauth/token
grant_type=urn:ietf:params:oauth:grant-type:device_code
&device_code=GmRhm…&signed=eyJhbGciOiJSUzI1NiJ9…

→ while pending: 400 { "error": "authorization_pending" }   // keep polling at `interval`
→ polled too fast: 400 { "error": "slow_down" }             // back off, then resume
→ on approval:   200 { "access_token": "…kiosk RS256 JWT…", "token_type": "Bearer",
                       "expires_in": 3600 }

The success response is OAuth-pure — the bound identity is not repeated in the body: it rides inside the kiosk JWT's claims (sub = the human's user_id, plus agent_id), exactly as a login-minted token would carry it.

The OAuth-shaped exception. The two /kiosk/oauth/* endpoints follow the OAuth/RFC 8628 wire — requests are form-encoded (application/x-www-form-urlencoded) and errors use the OAuth object (authorization_pending, slow_down, expired_token, access_denied, invalid_grant, and invalid_client for a failed possession proof) — a deliberate exception to the Kiosk envelope, so the ceremony stays recognizable to standard device-grant tooling. Every other endpoint on this page speaks the envelope.

What approval creates. A binding is created only after BOTH the human's approval AND a valid possession proof — a failed proof answers invalid_client and binds nothing (the row is not consumed; retry with a valid proof). A key the operator has never seen is registered as a linked assistant account under the human's user_id. A key that already has a self-standing account is rebound: its agent_id is stable, its user_id becomes the human's, and the identity's reputation carries over — claiming is not a reputation reset (no whitewashing) and grants no inherited trust. Because a rebind is a principal change, the key's pre-link tokens (still carrying the old user_id) stop verifying — watermark-revoked exactly as unlink revokes; the AI assistant uses the token the claim returns, or re-runs /auth/login, for a token under the new principal. The old standalone account's operator-domain data is not moved; an operator MAY migrate it in its claim hook.

Link (human-initiated — Kiosk extension)

auth.md-style ceremonies are AI-assistant-initiated. Kiosk adds the reverse direction: the human, signed in on the operator's site, mints a short-lived single-use link code ("link an assistant") and hands it to their AI assistant, which redeems it with the register-shaped body — possession proof required. Unlike the claim ceremony's user_code (short, typed by a human), the link code travels by paste, so it is a long opaque token, device-code-grade:

POST /kiosk/auth/claim
{ "code": "3n9tQx7vRk2mW…",
  "public_key": "-----BEGIN PUBLIC KEY-----\n…",
  "signed": "eyJhbGciOiJSUzI1NiJ9…" }

→ 201 Created
{ "agent_id": "b2e9…", "user_id": "a7f3…", "access_token": "eyJ…" }

Fresh-vs-known key semantics are identical to the claim flow. The code-minting and unlink endpoints (/kiosk/auth/link, /kiosk/auth/unlink) authenticate with the operator's own web session — they are human-side surfaces, never AI assistant calls.

Unbinding & code hygiene

Unlink is registration-layer revocation, complementing the credential-layer /auth/revoke watermark: the human (or the operator) deactivates a key's binding — its tokens stop verifying, and /auth/login answers 404 (the operator no longer knows the key; re-register or re-claim is the remedy). An unlinked key does not revert to a standalone account. Assistants are independently revocable; the human's own credentials are untouched.

Codes are stored hashed, are single-use, and expire on a short TTL; code entry and polling are attempt-capped (slow_down). The verify page MUST require an authenticated session and MUST display what is being bound. The discovery layer advertises the AI-assistant-callable binding endpoints (auth.device_authorization_url, auth.claim_url in /.well-known/kiosk.json), and the operator's /auth.md describes the full auth surface.

Reference note. The reference persists ceremonies in a durable kiosk.device_authorizations store (an in-memory adapter exists for tests), ships minimal overridable engine views for the verify and link pages, and exposes assistant_claimed / assistant_unlinked hooks for operator-side reactions such as domain-data migration.

Proof-of-work

Operators MAY require proof-of-work before accepting queries or actions. There is no verb exemption: an operator MAY require PoW before any verb — schema, query, run, or pay — as well as POST /auth/register. The always-free entrypoint is the discovery layer (/.well-known/kiosk.json, agents.json/agents.txt), not the /kiosk/schema verb. When PoW is required, the server responds with HTTP 402:

HTTP 402 Payment Required / PoW Required
WWW-Authenticate: Kiosk-PoW realm="<issuer>"

{
  "ok": false,
  "error": {
    "code": "pow_required",
    "message": "solve the challenges and retry",
    "challenges": [
      {
        "id":     "9b1c…",
        "alg":    "equihash",
        "params": { "n": 168, "k": 7 },
        "salt":   "dGVzdC1zYWx0…",
        "exp":    1751846400,
        "sig":    "hmac-sha256…"
      }
    ]
  }
}

Each challenge is stateless and request-bound: the HMAC sig covers the challenge fields plus a fingerprint of the exact request, so the server stores nothing to trust it, and a proof cannot be replayed against a different request. Valid proofs are single-use (a spent-id set on the operator side).

Two kinds of 402. HTTP 402 carries two distinct errors — an AI assistant MUST branch on error.code, never on the status alone. pow_required (above) always carries an error.challenges array. The other, payment_setup_required, is returned only by POST /kiosk/pay when the identity has no card on file; it carries no challenges field and means "run payment_setup", not "solve proof-of-work" (see Card setup).

WWW-Authenticate disambiguates the two 402s at the header level. Following RFC 7235, each 402 also carries a WWW-Authenticate response header naming the gate — WWW-Authenticate: Kiosk-PoW realm="<issuer>" for the proof-of-work gate, and WWW-Authenticate: Payment realm="<issuer>", method="ap2" for the payment-setup gate (the IETF Payment scheme; Kiosk settles via AP2). A client MAY branch on this header to tell the two gates apart without parsing the body; it MUST still read the body for the challenge list / setup pointer (the header names the gate, the body carries the payload). The header is additive — clients that branch only on error.code are unaffected.

The AI assistant solves every challenge in the list and retries the same request body with a top-level pow field, echoing each challenge back verbatim:

POST /kiosk/query
{
  "name": "catalog",
  "pow": {
    "proofs": [
      {
        "challenge": { "id": "9b1c…", "alg": "equihash", "params": { "n": 168, "k": 7 },
                       "salt": "dGVzdC1zYWx0…", "exp": 1751846400, "sig": "hmac-sha256…" },
        "nonce":     { "indices": [3, 42, 17, "…128 u64 integers in canonical tree order"], "header_nonce": 0 }
      }
    ]
  }
}

For a single challenge, the shorthand "pow": {"challenge": {…}, "nonce": {…}} is also accepted. The retried body must be identical to the original apart from the added pow field — the proof is bound to the request fingerprint (for the registration toll, to the AI assistant public key instead).

Index ordering. The nonce.indices array MUST be in Zcash canonical (subtree/tree) order — the ordering a genuine Wagner solution produces, where at each collision level the left half's first index precedes the right half's. It is not a global ascending sort: the verifier explicitly rejects a globally-sorted array of indices, because that ordering is a trivial reshuffle that does not correspond to the algorithm-bound solution. Submit the indices exactly as the solver emits them. The optional nonce.header_nonce (u32, default 0) is folded into the PoW seed after the salt bytes as a little-endian u32 — an extensibility point, currently always 0; a proof solved for a non-zero header_nonce must carry it.

Default algorithm: Equihash (n=168, k=7). Memory-hard. Verification is cheap by design (milliseconds, a few KB) while solving is expensive. Parameters are operator-tunable; an operator raises n for a heavier toll.

Reference note. The shipped reference solver (pure Python + numpy) clears the default in about 10 s using ~1.3 GiB; the pure-Ruby verifier takes ~17 ms. The defaults came from a benchmark sweep as the largest parameters under a ~30 s / 1–2 GiB laptop budget. The reference ships frozen known-answer tests (KATs) at these production parameters — the oracle a ported verifier validates against.

What PoW is, and is not. It is a metered toll with a cheap verify, plus a rate-limiting knob — not a hardware wall. Equihash is not ASIC- or GPU-proof (it was ASIC'd on Zcash, and GPUs solve it well); no proof-of-work equalises a laptop against special-purpose hardware. What PoW buys the operator is real: a cheap verify (a flood of malformed proofs is rejected by the HMAC and expiry checks in microseconds, before any backend work; a full equihash check is a few milliseconds and a few KB), a non-zero, non-amortisable price per anonymous request, and — unlike a cryptocurrency — no block reward to subsidise attacker hardware. Abuse resistance comes from reputation and caps; PoW just makes free-riding cost something. The challenge carries {alg, params}, so an operator can raise parameters or swap the algorithm without a protocol change.

The operator requests N independent proofs for rate-limiting. Instead of a continuous difficulty dial, Kiosk escalates by proof count: a normal client solves 1, a suspicious one ~3, an abuser ~10. Each challenge has its own salt — no amortization across proofs. N prices throughput, not latency (a solver with enough memory runs them in parallel), so the cost lands on a sustained scraper. Because a slow honest client may solve N proofs sequentially, each challenge's expiry scales with N.

Catalog query (example)

POST /kiosk/query
Authorization: Bearer <jwt>
{"name": "catalog"}

→ 200 OK
{
  "ok":   true,
  "kind": "rows",
  "rows": [
    {"sku": "milk-1l",     "name": "Whole Milk 1L",        "price_cents": 199},
    {"sku": "bread-ww",    "name": "Whole Wheat Bread",     "price_cents": 299},
    {"sku": "eggs-12",     "name": "Free-Range Eggs 12-pack","price_cents": 449}
  ]
}

The query name ("catalog") and the shape of rows are operator-defined. Kiosk does not mandate a product schema. The AI assistant learns the available queries and their parameters from GET /kiosk/schema.

Payment (AP2 mandate chain)

Payment follows AP2 (Agent Payments Protocol) — an emerging open scheme in which an AI assistant authorises a purchase through a short chain of signed mandates rather than by handling card data. Every payment requires three cryptographically signed mandates. Each is a RS256 JWS (RFC 7515) signed with the AI assistant's private key. The iss field MUST match the operator's issuer from /.well-known/kiosk.json.

#MandateMeaningKey fields
1Intent"I plan to spend up to €X on Y"cap_amount_cents, scope, currency
2Cart"This is exactly what I ordered"intent_mandate_id, line_items, total_amount_cents, currency
3Payment"Charge my saved card"cart_mandate_id, amount_cents, currency (payment_method: "on_file" optional)

Each mandate references the previous one by ID, forming a cryptographically linked chain. The server verifies all three signatures against the AI assistant's registered public key. This provides non-repudiation: if a dispute arises, any party can present the signed chain as proof of what was agreed.

Required claims. Every mandate MUST carry id, user_id, agent_id, iss, iat, exp. The server rejects a mandate whose user_id/agent_id do not match the authenticated identity, whose iss is not its own issuer (copied verbatim from /.well-known/kiosk.json), or whose exp is missing or passed — a non-expiring mandate is rejected outright. Binding rules: cart.intent_mandate_id equals the intent's id and the cart total must not exceed the intent's cap; payment.cart_mandate_id equals the cart's id and payment.amount_cents must equal the cart total in the same currency.

POST /kiosk/pay
Authorization: Bearer <jwt>
{
  "intent_mandate_jws":  "eyJhbGciOiJSUzI1NiJ9...",
  "cart_mandate_jws":    "eyJhbGciOiJSUzI1NiJ9...",
  "payment_mandate_jws": "eyJhbGciOiJSUzI1NiJ9..."
}

→ 200 OK
{
  "ok":   true,
  "kind": "value",
  "value": {
    "settlement_id":      "f1b3e259-8c4d-4a7f-9e12-84b5c7d2a963",
    "psp_reference":      "pi_3Rz...",
    "settled_amount_cents": 2499,
    "currency":           "eur"
  }
}

Card setup

Payment uses Stripe's SetupIntent card-on-file model. The AI assistant calls payment_setup before every payment:

POST /kiosk/run
Authorization: Bearer <jwt>
{"name": "payment_setup"}

→ First time (no card saved):
{
  "ok":   true,
  "kind": "value",
  "value": {
    "status":    "setup_required",
    "setup_url": "https://checkout.stripe.com/c/pay/..."
  }
}

The AI assistant hands the setup_url to the human. Never automate Stripe forms — the human enters their card once on Stripe's hosted page. The AI assistant polls payment_setup until status: "ready", then proceeds to pay. Subsequent payments use the saved card without human intervention.

Per-assistant spending cap (optional). When a human has several assistants bound to one account, an operator MAY cap what each one may spend. If a cap is configured for the acting assistant and a pay would push its settled total past the cap, the operator rejects it with 403 spending_cap_exceeded before charging — a cap of 0 disables that assistant's payments. The AI assistant can't pay past the cap; it tells the human, who raises it. Caps are operator policy and off by default. The reference enforces this via a pay-hook seam and exposes it on the manage-assistants page.

The payment_setup_required 402. If the AI assistant skips setup and calls POST /kiosk/pay with no card on file, the server answers 402 with error code payment_setup_required and no challenges field (this is the second of the two 402s — it is not proof-of-work). This response also carries WWW-Authenticate: Payment realm="<issuer>", method="ap2" so a client can tell it apart from the PoW 402 at the header level. The AI assistant then runs payment_setup, hands the setup_url to the human, waits for status: "ready", and retries the pay call — re-signing the mandates first if their exp has passed.

POST /kiosk/pay        // no card on file yet

→ 402
WWW-Authenticate: Payment realm="<issuer>", method="ap2"
{
  "ok": false,
  "error": {
    "code":    "payment_setup_required",
    "message": "payment setup required"
  }
}
TODO — refunds & cancellations. The mandate chain covers authorization and settlement. Reversal currently rides on operator business logic — an operator could, for illustration, expose a cancel_order action (an ordinary operator-defined action, same mechanism as create_order; not a spec-defined verb and not shipped by the reference implementation), and money moves back through the PSP's normal refund flow. A first-class refund verb that references the settlement is under consideration; endpoint shape TBD.

KYC

Kiosk supports KYC brokering through Persona, Sumsub, or any existing provider. The AI assistant carries a signed attestation from the KYC provider, never raw identity documents — the operator learns that identity was verified without ever seeing the passport scan.

The attestation. The verified attestation is a compact RS256 JWS with a level claim. The AI assistant submits it to POST /kiosk/agents/kyc (Bearer); on a clean verify the operator stamps kyc_verified_at on the AI assistant record, which operator business logic can then gate on.

Named anonymized attributes. The attestation MAY additionally carry an attributes object of {name: true} booleans — for example age_over_18 or licence_a. These are anonymized: the operator learns only the booleans the KYC issuer signed, never the date of birth, licence number, or document behind them, and records only those booleans (never the documents). Only values that are literally true count as a grant. The field is additive — a bare level: "verified" attestation with no attributes still verifies (the binary path) and yields an empty attribute set.

// KYC attestation JWS payload (signed by the KYC provider, verified with its public key)
{
  "sub":   "a7f3…",                 // MUST equal the authenticated user_id
  "level": "verified",              // anything else is rejected
  "iss":   "https://kyc.example",   // MUST equal the configured KYC issuer
  "iat":   1751846340,
  "exp":   1751849940,              // REQUIRED — an expired attestation is rejected
  "attributes": {                   // OPTIONAL — named ANONYMIZED booleans
    "age_over_18": true,            // provider learns the boolean, never the DOB
    "licence_a":   true             //   …never the licence number
  }
}

POST /kiosk/agents/kyc
Authorization: Bearer <jwt>
{ "kyc_jws": "eyJhbGciOiJSUzI1NiJ9…" }

→ 200 OK
{ "kyc_verified": true, "attributes": { "age_over_18": true, "licence_a": true } }

The operator verifies the attestation signature against its configured KYC public key, checks the iss, checks that sub matches the authenticated identity, rejects anything expired or whose level is not exactly "verified", and records the granted attributes alongside the verification.

Attribute-gated actions. An action MAY be gated on a set of required attributes. If the calling AI assistant's recorded attributes do not include every required one as true, the operator rejects with 403 kyc_required, its hint naming what is needed. The reference skooti demo gates rent_motorcycle (a combustion-engine motorcycle) on age_over_18 and licence_a, while the licence-free electric scooter needs neither — the gate is per-action.

ROADMAP — richer attribute values. Today an attribute is a bare boolean the issuer vouches for. A future version broadens this to selectively-disclosed structured values — e.g. document: {type, country} or age_at_least: 21 — so an operator can require "has valid government ID from Germany" without receiving the underlying document. The shipped surface is the named-boolean attributes object above.

Reputation

Each AI assistant identity is a self-generated keypair, proven at registration/login by the challenge-response above. Reputation is a per-operator signal on that identity: successful transactions raise it, suspicious behaviour lowers it.

Reputation is enforced through proof-of-work cost. Rather than a continuous difficulty dial, Kiosk uses proof count as a function of reputation: an established identity solves 0–1 proofs, an unknown one ~3, a flagged abuser ~10 (each proof independent, no amortization). A legitimate-but-newly-suspicious AI assistant can cheaply re-establish itself; sustained abuse gets progressively more expensive.

Minting a fresh identity is not blocked — it is made unprofitable. A new keypair is a new identity that starts at the unknown tier and pays the corresponding proof-of-work up front and per action, earning lower cost only through verified good behaviour. Shedding a bad reputation therefore costs at least as much work as complying would, and forfeits any positive reputation already accrued. Whitewashing is a treadmill, not a reset — its effectiveness depends on how fast the operator detects abuse relative to its value per identity.

Reputation is operator-local; a keypair is unique per domain, so there is no cross-operator identifier. The kiosk-reputation gem provides policy hooks for the reputation→proof-count function.

TODO: Operator-sent events. Currently the protocol is AI-assistant-initiated: the AI assistant polls for state changes. A future version will add server-sent events or webhooks so the operator can push updates (order status changes, delivery notifications) to the AI assistant without polling.

Conformance — port Kiosk

The protocol is stack-neutral; Ruby is just the reference. This is the checklist for implementing Kiosk on any stack.

Operator profile

An implementation is a Kiosk operator when it serves the core plus whichever optional modules it advertises:

  1. Core — discovery: /.well-known/kiosk.json (issuer, endpoint, capabilities, auth block; optional skill pin) and the JWKS document at /kiosk/.well-known/jwks.json.
  2. Core — auth (kiosk-pop): challenge / register / login / revoke with proof-of-possession verification, origin-bound aud rejection, single-use server-held nonces, RS256 JWT access tokens, and the revoked-before watermark.
  3. Core — wire: schema (GET) and query/run (POST) with the response envelope, the error vocabulary, and the schema self-description format above.
  4. Core — identity binding: the session contract above — every verb scoped to the authenticated identity.
  5. Module pay (advertised via capabilities): AP2 mandate-chain verification — required claims, chain binding, cap/total/amount rules — plus the payment_setup convention and the payment_setup_required 402 with WWW-Authenticate: Payment.
  6. Module proof-of-work (MAY): the Equihash 402 gate — stateless HMAC-signed challenges, all-proofs verification, single-use spent set, canonical index-order rejection, WWW-Authenticate: Kiosk-PoW — and the optional registration toll binding proofs to the registering key.
  7. Module binding (MAY): the claim ceremony (device authorization + session-authenticated verify + possession-proof token poll) and/or the link-code redeem, the fresh/rebind semantics with reputation carry-over, and unlink (see Account binding).
  8. Module KYC (MAY): the attestation endpoint with issuer, sub, exp, and level verification, plus the optional named anonymized attributes booleans and the kyc_required gate on attribute-restricted actions.

AI assistant profile

A client is a Kiosk-compatible AI assistant when it: branches on error.code, never on the HTTP status alone; fills the proof's aud from the origin it dialed, never from server-supplied data; solves every challenge in a pow_required list and retries the identical body plus the pow field; runs payment_setup and hands setup_url to the human rather than automating card entry; performs the skill dual-check, never loading skill instructions from the operator; and, when the human owns an existing operator account, binds instead of registering — the claim ceremony (hand over user_code + verification_uri, poll with a possession proof) or a human-supplied link code redeemed with {code, public_key, signed}.

Conformance anchors

Two oracles pin behavior beyond this text: the reference's frozen Equihash known-answer tests at production parameters (n=168, k=7) — a ported verifier MUST reproduce them — and the reference's end-to-end AI-assistant harness (see Reference below), which exercises the golden path an independent implementation should survive: discovery → register → schema → query → run → pay, plus the error envelopes. Machine-readable JSON Schemas for every wire object ship with the formal specification (Section 17 lists each file) and are the schema-level oracle. A published stack-neutral black-box conformance suite does not exist yet.

Ports are invited. The reference implementation is deliberately one stack, done completely. A Go, Python, or Node implementation of this page is a Kiosk operator — start from the operator profile, validate proof-of-work against the KATs, then run an AI assistant through the golden path.

Reference