Operator onboarding — from install to agent-ready
A step-by-step guide for adding Kiosk to an existing Rails app, worked through the getgrocery demo operator: a grocery catalogue, delivery windows, and orders that are paid and can be moved once.
The controllers below are getgrocery's own, one per resource: Kiosk::CatalogController, Kiosk::DeliverySlotsController, Kiosk::OrdersController and Kiosk::DeliveriesController. Name yours after your own domain; nothing depends on the word "catalog".
A code block whose first line names a path is that file of getgrocery, copied whole from the main branch of the reference repository. A first line that ends in , an excerpt marks a part of the file, copied unchanged.
Check this first. kiosk-server declares railties, actionpack, actioncable, activerecord and activesupport at ~> 8.1, so this integration needs Rails 8.1: Rails 7.x and 8.0.x are excluded and bundler will refuse to resolve against an app on either. Older Rails lines are untested, so they are not claimed. You also need Ruby 4.0 or newer — the floor every Kiosk gemspec declares — and PostgreSQL, which is the only database Kiosk supports.
Two scopes, assuming a working Rails 8.1 + Postgres app you already know:
- Read-only, no payments — a short integration. Add the gem, run the install generator, draw the routes, declare a couple of read-only queries in a controller by adapting the examples below, name them in the
kioskroutes block, and verify discovery + one query. That's the minimal agent-ready surface. - Full transacting integration with payments — a bit longer. Everything below — an Ed25519 signing key, a Stripe test account and keys, the
kiosk_stripe_customersmigration and payment-adapter wiring, an action that gates on payment, PK-type matching across the kiosk migrations, and testing the mandate→pay→settlement flow end to end. Expect more if yourusers.idisn't a UUID or Stripe is new to you.
The steps below walk the full payments integration assuming you already have the accounts and keys in hand; they don't cover Stripe signup, or debugging your own catalog's queries.
Step 1: Add the gems
# Gemfile
gem "kiosk-all" # meta-gem: kiosk-core + kiosk-server
gem "kiosk-pay-stripe" # Stripe card-on-file payment adapter (this walkthrough uses it)
gem "jbuilder" # the views that shape each answer; `rails new` adds it
bundle install
rails generate kiosk:install # the kiosk migrations, initializer and the kiosk routes block
rails db:migrate
Step 2: Configure Kiosk
First, generate a signing key. Kiosk signs the JWTs it issues with an Ed25519 key (EdDSA); without it the server raises on the first registration. Generate one and put it in your environment (do not regenerate per boot — a fresh key invalidates every issued token, forcing agents to re-register and losing their saved-card associations):
# base64 so it fits on one line in .env / mise.toml / your secrets manager
openssl genpkey -algorithm ed25519 | base64
# → export KIOSK_SIGNING_KEY_B64="LS0tLS1CRUdJTi…"; the initializer below reads it
config/initializers/kiosk.rb:
Kiosk.configure do |c|
c.user_model = "User" # your existing account model
c.user_id_type = :uuid # MUST match your PK type — :uuid, :bigint, :integer, or :text
c.issuer = ENV.fetch("KIOSK_ISSUER", "https://your-domain.com")
c.signing_key = Base64.decode64(ENV.fetch("KIOSK_SIGNING_KEY_B64"))
# Role vocabulary your authz branches on. If you declare roles at all, your
# identity system MUST name one for EVERY human who can approve an assistant
# binding — a role for staff and nothing for customers is not a supported
# configuration (see "Roles are total" below). Declaring none is the other
# supported shape: then no binding carries a role and no token has one.
c.roles = %i[customer]
c.owner = { name: "Your Business", support: "help@your-domain.com" }
# The SHA-256 of the skill file that kiosk-server gives. An assistant checks
# the skill against it. Make it with:
# curl -fsS "$(bin/rails runner 'puts Kiosk.configuration.skill_url')" | shasum -a 256
c.skill_sha256 = "<the 64 hexadecimal characters that the command prints>"
# The DEFAULT ROLE — pinned to every self-registered agent (agents can't
# choose their own) and fallen back to by a binding that resolves none.
# REQUIRED whenever c.roles above is set, and it must be one of them: there
# is always a default role, so kiosk-server refuses to start without one.
# An operator that assigns roles to nobody sets neither line.
c.registration_role = :customer
# OPTIONAL — which identity provider verifies agent tokens. When omitted,
# kiosk-server uses its bundled DefaultAgentIdp: it verifies exactly the
# access tokens its OAuth token endpoint issues, so the
# zero-config install works end-to-end. Set this only to front another
# agent-identity system or to compose adapters. Your human sign-in (Devise
# etc.) is `c.user_idp`: the binding pages read it, the verbs never do.
# c.agent_idp = MyCustomAgentIdp.new
# Registration mints a fresh principal (an "assistant account").
# By default Kiosk would call User.create! with no attributes and 500 on any
# validated model, so supply a factory that creates the account satisfying
# YOUR validations and RETURNS its id. That returned id becomes the agent's
# principal — so this works on ANY PK type (bigint, uuid, …): the id is your
# own model's PK, whatever it is. `pubkey` is the agent's registered public
# key (bind the account to it if you like). NOTE: this is an ASSIGNMENT
# (attr_accessor) — a `do…end` block form silently no-ops.
c.assistant_creation = ->(pubkey) do
User.create!(email: "agent-#{SecureRandom.hex(6)}@assistants.your-domain.com").id
end
# Stripe (test mode to start)
c.payment_provider = Kiosk::PaymentProviders::Stripe.new(api_key: ENV["STRIPE_SECRET_KEY"])
end
Test the skill pin. Each kiosk-server release gives the skill of its own version, so after an upgrade yourskill_sha256can be the digest of the old file, and an assistant that checks the digest refuses the skill. Add the skill pin test ofkiosk-test-supportto your suite: it names the skill version of your digest, fails after an upgrade that changes the skill, and tells you how to make the new digest. Minitest:With RSpec,require "kiosk/test_helpers/skill_pin" class KioskSkillPinTest < ActiveSupport::TestCase include Kiosk::TestHelpers::SkillPin::Assertions test "skill_sha256 is the digest of the skill that kiosk-server gives" do assert_kiosk_skill_pin hashed_version: "X.Y.Z" # X.Y.Z from skill-vX.Y.Z.md end endrequire "kiosk/test_helpers/skill_pin/rspec"and writeexpect(Kiosk.configuration).to pin_the_served_skill(hashed_version: "X.Y.Z").
PK type must match. The install generator defaults to--user-id-type=uuid, and every kiosk migration that carries a user id bakes that type in — the identity tables (thekiosk_agents.user_idFK), device authorizations, the mandate tables, the KYC tables (kiosk_kyc_attributesandkiosk_kyc_requests) andkiosk_visits.kiosk_eventsbakes in nothing: itsidentity_keyis text the engine writes and reads and never joins to yourusers. Thekiosk_stripe_customersgenerator below takes the same--user-id-typeflag. If yourusers.idis a Rails-defaultbigint, regenerate the kiosk migrations withrails generate kiosk:install --user-id-type=bigintandrails generate kiosk:pay_stripe:install --user-id-type=bigint(so every kiosk table matches) and setc.user_id_type = :bigint— otherwise migrations fail with a datatype mismatch.
The adapter keeps each principal's Stripe Customer in a kiosk_stripe_customers table. The kiosk-pay-stripe generator writes the migration that creates it:
rails generate kiosk:pay_stripe:install --user-id-type=uuid
rails db:migrate
Step 3: Draw the routes
Step 1's generator already wrote a kiosk block at the top of your config/routes.rb, with an example query and action commented out, because it knows none of your verbs. Naming the five that Steps 4 and 5 declare is this step's work. getgrocery's routes:
# config/routes.rb
# frozen_string_literal: true
Rails.application.routes.draw do
# Human sign-in (Devise): the web session that approves assistant links.
devise_for :users
# Human storefront + the agent hook ("Agents → Kiosk here") on the homepage.
# Devise needs this as its post-sign-in destination too.
root 'home#index'
# What this demo stores and for how long.
get '/privacy', to: 'privacy#show'
# Crawlers: robots.txt names the sitemap.
get '/robots.txt', to: 'crawler#robots'
get '/sitemap.xml', to: 'crawler#sitemap'
# ── The Kiosk HTTP API surface ────────────────────────────────────────────────
kiosk do
queries :catalog, :delivery_slots, :my_orders
actions :create_order, :reschedule_delivery
end
# ─── Provider admin (read-only demo back-office) ──────────────────────────
# No auth required — demo provider only. Production would authenticate.
get '/admin/orders' => 'admin/orders#index', as: :admin_orders
end
The block does two things, in this order. It mounts the engine at the mount_path from Step 2's initializer — /kiosk unless you set another — which draws the protocol plane: the paths whose shape and answers are the specification's. Then it draws one route per verb you name, and the method follows the kind: GET /kiosk/<name> for each query and POST /kiosk/<name> for each action, each reaching the controller action that declares the verb. You draw no verb route by hand: one drawn straight at a handler action answers 404. Adding a verb is two edits from here on — the declaration in your controller, and its name in this block — and in exchange bin/rails routes prints your actual verbs.
Why your verbs are named and not inferred. A mount that drew your verbs as well would draw them by pattern: one catch-all segment under the prefix, resolved against the registry at request time. Kiosk does not draw them that way, because of what a pattern route costs —bin/rails routesprints the catch-all instead of your verbs, the HTTP method each verb answers is decided somewhere you cannot read, and a verb reaches the network the moment its class body loads, whether or not you meant to publish it yet. The protocol plane is the opposite case:schema,pay, the auth ceremonies, JWKS and the account-binding endpoints are not yours — their paths and their answers are the specification's. So you name what is yours, and the engine draws what is the protocol's.
Under the prefix, the mount draws:
- The reserved endpoints —
GET /kiosk/schema(the catalog — public, no token),POST /kiosk/pay, andPOST /kiosk/payment_setupwithGET /kiosk/payment_setup/return, the page the payment provider sends the human back to once they finish its setup page; with a payment provider configured,payment_setupis listed among your actions.payandpayment_setupare drawn whether or not you configured a payment provider: without one they refuse with a501 module_not_servedproblem document, and discovery leavespayout of the capabilities it advertises, so nothing invites an agent to call it. There is alsoGET /kiosk/openapi.json— the same catalog rendered as OpenAPI 3.1, also public. You draw none of these, and you cannot collide with one:Kiosk::Handlerrefuses a reserved verb name when the class body loads, naming the class, the method and the name that is taken. - Registration and tokens (OAuth) —
POST /kiosk/oauth/register(RFC 7591 client registration of the agent's public key),POST /kiosk/oauth/token(theclient_credentialsgrant with an RFC 7523 client assertion and a DPoP proof) andPOST /kiosk/auth/rotate(the agent replaces its key): the handshake theregister.rbscript in Step 6 performs. - JWKS —
GET /kiosk/.well-known/jwks.json, the public keys agents verify Kiosk-issued JWTs against. It sits under the mount, and that is the URL discovery publishes asjwks_uri. - KYC —
POST /kiosk/agents/kyc, where an agent presents a broker-signed identity fact, andPOST /kiosk/request_kycwithPOST /kiosk/kyc/callback, where the engine opens a verification at your KYC provider and the provider reports the human's approval. The engine records the attested attributes against the person, so you write only the gate:Kiosk::Server::Kyc.require!in the action, andc.kyc_provider(for exampleKiosk::KycProviders::Provelifromkiosk-kyc-proveli) withc.kyc_claimsin the initializer. The provider names the issuer, its public key and the audience that the engine checks each attestation against. Setc.kyc_claimswhenever you setc.kyc_provider: without it the app does not boot. Drawn unconditionally for the same reason aspay: without that configuration they answer501 module_not_served, and a provider that never advertises KYC loses nothing. - The event stream —
GET /kiosk/events, a WebSocket upgrade on theactioncable-v1-jsonsubprotocol that an agent holds open to be told when something happens. Drawn on the same terms as the two above: an origin that declares notopicserves no events module, so the path answers a501 module_not_servedproblem document — for a plain request and for an upgrade alike, and before any credential is read — and discovery publishes noevents_url, so nothing sends an agent there. Your firsttopicblock beside a verb in a handler controller is what turns it on; §8.5 of the protocol is the stream itself. - The account-binding ceremony — how an assistant gets attached to a human's existing account. That is the RFC 8628 claim ceremony (
POST /kiosk/oauth/device_authorization,POST /kiosk/oauth/token, each authenticated by the agent's client assertion), the link/claim/unlink endpoints (POST /kiosk/auth/link,/kiosk/auth/claim,/kiosk/auth/unlink), and two human-facing HTML pages: the consent page at/kiosk/oauth/device/verify, which shows the key's fingerprint and when it asked, and «Link an assistant» at/kiosk/auth/assistantswith its link/update/unlink form posts. Both pages are session-authenticated through your ownuser_idp— approving a binding is the human channel's job, not the agent's — and both render minimal engine views you override by shipping same-named templates underapp/views/kiosk/server/.
Roles are total, or you have none. The role a binding carries is the approving human's — the engine reads it from youruser_idpand never from anything the assistant sent. So if you declarec.rolesat all, your identity system MUST name a role for every human who can approve a binding. A role for staff and nothing for customers is not a supported configuration: on an origin with more than one role, a member of staff your identity system answers no role for gets an assistant at your default role instead of theirs, and it can see nothing they could see themselves. Declaring no roles at all is the other supported shape — then no binding carries a role and no token has one. If you write#kiosk_roleon your user model, make it total: return your least-privileged declared role rather thannil. Whether a Ruby method is total over your humans is not something a configuration file says, so kiosk-server cannot check that half at boot — it warns in your log the first time a ceremony resolves no role on a multi-role origin. The configuration half it does check, and refuses: declaringc.roleswithout ac.registration_rolestops the app from starting, because that default is the role an assistant lands on when nothing else resolves one — a self-registration, or a binding whose approving human you answer no role for. §6.3 of the protocol states the requirement.
The mount also installs the discovery documents, which live at the origin root — the agents.txt v1.0 standard and RFC 8615 put them there, outside any mount:
/agents.txtand/agents.json— the agents.txt envelope and its structured companion. They nameoauth2as the authorization method, with/.well-known/oauth-authorization-serveras its discovery document inagents.json, and the skill you pin./auth.md— the agent-auth handbook: which auth methods you accept and how each ceremony runs./.well-known/kiosk.json— the machine-readable handshake — plus/.well-known/api-catalog(RFC 9727),/.well-known/oauth-authorization-server(RFC 8414: the registration and token endpoints, and the device endpoint where you serve binding),/.well-known/oauth-protected-resource(RFC 9728) and/.well-known/ard.json, also at/.well-known/ai-catalog.json./.well-known/agent-skills/index.json— the skill you pin and its digest. It answers only when you setskill_sha256.
All of them come from kiosk-server's DiscoveryController, which renders every document from Kiosk::Server::WellKnown — the single generator seam. They're built on the fly from Kiosk.configuration and the live registry, so discovery can't drift from what you actually serve. The engine appends them to your app's route set, and only when it is mounted: bundling the gem without the kiosk block adds no routes at all.
The mount goes first, and a verb you forget to name is unreachable. Rails dispatches the first matching route, andkioskdraws the mount above your verbs, so every protocol path wins — no verb of yours can shadowschema,payor the auth plane. Below your verbs there is nothing: a path under the prefix naming no verb in the block matches no route, so it is the ordinary 404 Rails answers anywhere else — and so is a verb dialed with the other method. Nothing stands in for a name you left out, which is why a verb you declare and never name is simply unreachable. If you want the engine's own404 verb_not_foundthere, add a catch-all action of your own after the block. And the mount is how the protocol plane is drawn, not one of two ways: copying those paths into your routes gives you a table to keep in step with a specification that moves, while the mount above the copy answers first, so the line you typed is dead as well as yours to maintain. Name your verbs; the engine draws the rest.
Step 4: Declare your queries
Queries are read-only data access. Agents use them to browse your catalog. Write the controller in app/controllers/kiosk/: the engine loads that directory and registers every class that includes Kiosk::Handler.
They live in controllers you own. Kiosk ships a mixin — include Kiosk::Handler into a controller with whatever superclass you use, and that include is the whole of the contract on the class side. Each class-level macro records a declaration; the next def claims all pending ones and becomes a verb. A method with no declarations above it is not a verb, so private helpers stay invisible to agents. kind :query or kind :action says which verb reaches each one — a property of the declaration, so one controller may declare both. Inside a handler you are in an ordinary Rails action: params, before_action and rescue_from all apply, kiosk_identity is the principal the protocol resolved (its user_id is your own model's PK), and what the action renders is the verb's result. getgrocery's actions set instance variables and let Rails render the action's Jbuilder view in app/views/kiosk/; the engine renders the JSON template whatever Accept the caller sent. The handler runs inside the engine's scoped transaction, so per-principal SQL scoping (and RLS, where you opted in) applies without you wiring anything.
Two things to know. Handler controllers are not routable — do not draw a route at one. They are reached only through the Kiosk engine, which is where authentication, the toll and the transaction live; a direct request answers 404. And a handler outsideapp/controllers/kiosk/is not loaded in development, where nothing else references it. The engine re-registers handlers after every code reload, so an edited, added or removed verb lands without restarting the server. Registration is not reachability, though: a newly declared verb is in the catalog as soon as the file reloads, and answers requests once its name is in Step 3'skioskblock.
Every declaration is also a descriptor — what GET /kiosk/schema publishes, and the only thing a cold assistant reads before it calls you. Three macros carry it, and they split the work strictly:
descriptionis prose and carries meaning — what the verb is for, what its result means, what happens next, and which verb comes after.input_schemais a JSON Schema (draft 2020-12) and carries shape — every parameter name, type, range, enum and required/optional marker, declared once.output_schemadeclares the result the same way — what comes back, as a schema — so the assistant knows the return shape without a call-and-observe probe.example_paramsandexample_rowthen illustrate the two contracts with copyable values.
And one macro that is not about shape at all:reach. It answers “may this verb hand a caller somebody else's rows?” The default is:principal— only the calling principal's own rows, or rows that belong to no principal (a catalogue, a price list) — and it is what a verb that declares nothing means, so per-principal scoping costs you no ceremony and stays the absolute requirement. Declarereach :published,:consentedor:roleonly when the verb really does cross a principal boundary: an open board, a list somebody was invited into, a staff view your own role assignment widens. Declaring it does not make the reach correct — it makes it reviewable, and it is what lets an assistant tell an intentional public surface from a scoping bug. See the descriptor house style for which value to pick, and note that apublishedrow may never carry an identifier your accounts authenticate with.
A parameter name written in prose is checked by nobody: it drifts away from the handler that consumes it, an assistant sends exactly what your sentence told it to send, and the call 400s. In the schema it is stated in the one place that can be checked. (There is no params: hint macro, for the same reason — a hint is either a constraint, which belongs in the schema, or a meaning, which belongs in the description — and a descriptor may not publish the key in the catalogue either, not even as null.)
Both schemas are REQUIRED, and the input one is enforced.input_schemaandoutput_schemaare protocol requirements: a verb declared without either fails the boot, naming the verb and the missing macro. Andinput_schemais not merely published — the engine validates every request against it before your handler runs, unconditionally. A parameter you did not declare is refused with a typed400 bad_requestnaming it (object property at `/user_id` is a disallowed additional property), which is whyadditionalProperties: falsebelow is load-bearing rather than tidy. The full rules are the descriptor house style.
Errors are Rails' idiom, end to end. The protocol's error code vocabulary is a closed table of seventeen, and three Rails-native moves cover it: render the code — render json: { error: { code: "bad_request", message: "…" } }, status: :bad_request — a body naming an in-vocabulary code that agrees with the rendered status travels to the assistant verbatim; raise what you would raise anyway — params.require, ActiveRecord::RecordNotFound, a failed create! — and the one rescue_from the include installs maps it by the status Rails already knows for it (a missing param answers bad_request, a lookup miss not_found — the addressed-thing-is-absent 404, which is a different fact from the unregistered-name verb_not_found in the vocabulary — a validation failure bad_request, carrying the model's own messages); and only a code a bare status cannot name (rls_denied, a specific 402) needs the explicit { error: { code: … } } body. No Kiosk error classes in handler code.
What the assistant actually receives is not that hash.{ error: { code: … } }is the handler-side spelling; the engine renders every refusal as an RFC 9457 problem document, served asapplication/problem+json, with the code as a flat member — so the two are never confused:Your{"type":"https://kiosk.tech/problems/bad_request", "title":"Malformed request","status":400, "detail":"unknown sku(s): nope","code":"bad_request"}messagebecomesdetail, yourcodestayscode, andtypeishttps://kiosk.tech/problems/<code>— an identifier, not a page to fetch. An assistant branches oncode.
On the raise-and-map path the CODE travels and the exception's own SENTENCE does not. That branch exists for exceptions you did not author — “no Kiosk error classes in handler code” is its whole purpose — so its message would be some library's wording (a sentence in the shape of actionpack's “param is missing or the value is empty or invalid: sku”) and it moves when a dependency moves. What the assistant receives instead is this engine's own sentence for the code the seam decided, plus ahint— forbad_request,check the arguments against this verb's input_schema — GET .../schema publishes it.— while the exception's class, message and backtrace go to yourRails.logger, where the diagnosis belongs and where the assistant could not have acted on it anyway. A failed validation (ActiveRecord::RecordInvalid,ActiveModel::ValidationError) is the exception: its messages are written for the caller, so they travel asdetail.
So when you mean to say something to the assistant, say it deliberately. Two routes never reach that branch and carry your words verbatim: render the envelope with your ownmessage, or raise aKiosk::Server::Errorsclass with your ownmessage:andhint:— the seam re-raises those untouched. “No Kiosk error classes in handler code” is the default, not a prohibition: it is what you write when you have nothing of your own to say.
A delivery window is offered by one verb and booked by two, so its arithmetic lives in one place — app/models/delivery_slots.rb, a plain Ruby module Rails autoloads. A window is named by its start, slot_at: an ISO 8601 time with its offset, which delivery_slots publishes and the two actions take back unchanged.
# app/models/delivery_slots.rb
# frozen_string_literal: true
# Two-hour delivery windows from 08:00, on the clock of the delivery district.
# Every verb that offers, books, moves or reads a window computes it here.
module DeliverySlots
FIRST_HOUR = 8
WINDOW_HOURS = 2
COUNT = 6
# After payment the shop picks the basket, then the courier drives it over.
PICKING = 20..30 # minutes
DRIVE = 5.minutes
# The origin's zone, used where no address is involved: published examples.
DEFAULT_ZONE_NAME = 'Europe/Dublin'
module_function
def default_zone = Time.find_zone!(DEFAULT_ZONE_NAME)
# The clock of the address's district; the origin's for an address it does not serve.
def zone_at(address) = Time.find_zone!(DublinZones::ZONES.fetch(DublinZones.check(address).district, DEFAULT_ZONE_NAME))
# Today, or tomorrow once today's last window has closed.
def soonest_date(zone)
today = zone.today
bookable(today, zone).empty? ? today + 1 : today
end
# Each window's start on `date`, on the clock of `zone`.
def starts(date, zone = default_zone)
Array.new(COUNT) { date.in_time_zone(zone).change(hour: FIRST_HOUR + (it * WINDOW_HOURS)) }
end
def start?(time, zone) = starts(time.in_time_zone(zone).to_date, zone).include?(time)
# "08:00–10:00 (Europe/Dublin)".
def label(time, zone = default_zone)
hour = time.in_time_zone(zone).hour
format('%<from>02d:00–%<to>02d:00 (%<zone>s)', from: hour, to: hour + WINDOW_HOURS, zone: zone.name)
end
# A window takes orders while a basket paid now still reaches the door inside it.
def closed?(slot_at)
(slot_at + WINDOW_HOURS.hours - PICKING.max.minutes - DRIVE).past?
end
def bookable(date, zone) = starts(date, zone).reject { closed?(it) }
# "14:35 (Europe/Dublin)".
def clock_label(time, zone) = "#{time.in_time_zone(zone).strftime('%H:%M')} (#{zone.name})"
end
Which zone? The place the service happens. For a table, a room or a chair that is the venue's address; for a delivery it is the customer's door. getgrocery keeps a clock per served district inDublinZones::ZONESand reads it withzone_at(address);DEFAULT_ZONE_NAMEfills the published examples, which name no address. The specification forbids answering from one zone configured on the origin, because an operator may run stores in many time zones: record the zone against the thing it belongs to.
Every row that carries a wall clock publishes its zone — atimezonefield, and the IANA name inside the label (08:00–10:00 (Europe/Dublin)), because nobody reads an offset out loud. "Today" iszone.today, the day at the delivery address, never the server process's.
Adatean assistant sends is a day at the same place, the delivery address. The assistant reads the zone from the row and resolves its human's words on that clock. A day that has ended at the address is a400naming the zone and the earliest day it can ask for.
Tell the caller when the clocks differ. An assistant declares its human's zone in theKiosk-Timezonerequest header, andKiosk.reader_zonereads it. The header never moves a service time. When the place's zone differs from the declared one, say so in the answer, briefly and clearly: the place, its zone, how far it is from the caller, and that every time in the answer is the place's.Kiosk.clock_gap_note(name, zone, Kiosk.reader_zone)writes the first sentence (the place, its zone and the hours between the two clocks), or answersnilwhen the clocks are the same now. hoteling adds a second sentence and sends both in atimezone_noteon itshotel_detailrow: every date and time in the row is the hotel's.
app/controllers/kiosk/catalog_controller.rb:
# app/controllers/kiosk/catalog_controller.rb
# frozen_string_literal: true
# The shop's shelf.
module Kiosk
class CatalogController < ActionController::API
include Kiosk::Handler
kind :query
description 'Every product, at the price a checkout is signed at. One with nothing left carries `sold_out`; ' \
'the shelves are restocked every morning. Alcohol is marked `age_restricted`: `create_order` ' \
'takes it only after an 18+ check (`request_kyc`).'
input_schema type: 'object', additionalProperties: false, properties: {}, required: []
output_schema type: 'array',
description: 'Products, by name.',
items: {
type: 'object', additionalProperties: false,
properties: {
sku: { type: 'string', description: "The product's handle in an order." },
name: { type: 'string' },
price_cents: { type: 'integer', description: 'EUR cents.' },
price_eur: { type: 'string', description: 'The price as a human reads it, e.g. "€4.49".' },
currency: { type: 'string' },
low: { type: 'boolean', description: 'Present when stock is running out.' },
sold_out: { type: 'string', enum: [Product::SOLD_OUT],
description: 'Present when nothing is left; offer the customer another product.' },
age_restricted: { type: 'boolean', description: 'Present on alcohol.' }
},
required: %w[sku name price_cents price_eur currency]
}
example_params({})
example_row({
sku: 'sourdough-bread', name: 'Sourdough Bread', price_cents: 449,
price_eur: '€4.49', currency: 'eur'
})
def catalog
@products = Product.order(:name)
end
end
end
The action loads the products; its view shapes the answer. ignore_nil! leaves low and age_restricted out unless they are true, and sold_out is there only on a product with nothing left, as the output_schema says:
# app/views/kiosk/catalog/catalog.json.jbuilder
# frozen_string_literal: true
json.ignore_nil!
json.array! @products do |product|
json.extract! product, :sku, :name, :price_cents
json.price_eur Product.format_eur(product.price_cents)
json.currency 'eur'
json.low product.low_stock? || nil
json.sold_out(Product::SOLD_OUT) if product.sold_out?
json.age_restricted product.age_restricted? || nil
end
app/controllers/kiosk/delivery_slots_controller.rb:
# app/controllers/kiosk/delivery_slots_controller.rb
# frozen_string_literal: true
# The delivery windows at an address.
module Kiosk
class DeliverySlotsController < ActionController::API
include Kiosk::Handler
kind :query
description 'The delivery windows still bookable on a day at a Dublin address in a served ' \
'district. Ask the customer for their real address first; an empty answer means ' \
'that day is full, so try a later one.'
input_schema type: 'object',
additionalProperties: false,
properties: {
date: { type: 'string', format: 'date',
description: "A day on the delivery address's clock. Omit it " \
'for the soonest day the shop delivers.' },
delivery_address: { type: 'string', description: 'A Dublin address naming its postal district, e.g. "Dublin 2".' }
},
required: ['delivery_address']
output_schema type: 'array',
description: 'Bookable windows, earliest first.',
items: {
type: 'object', additionalProperties: false,
properties: {
slot_at: { type: 'string', description: "The window's start; pass it to create_order." },
label: { type: 'string', description: 'The window as a human reads it, e.g. ' \
"\"08:00–10:00 (#{DeliverySlots::DEFAULT_ZONE_NAME})\"." },
timezone: { type: 'string', description: "The delivery address's IANA zone." },
district: { type: 'string', description: 'The postal district the address routed to, e.g. "D02".' }
},
required: %w[slot_at label timezone district]
}
example_params({ date: -> { DeliverySlots.default_zone.tomorrow.iso8601 },
delivery_address: '42 Camden Street, Dublin 2' })
example_row({ slot_at: -> { DeliverySlots.starts(DeliverySlots.default_zone.tomorrow).first.iso8601 },
label: "08:00–10:00 (#{DeliverySlots::DEFAULT_ZONE_NAME})",
timezone: DeliverySlots::DEFAULT_ZONE_NAME, district: 'D02' })
def delivery_slots
@search = SlotSearch.new(delivery_address: params[:delivery_address], date: params[:date])
@search.validate!
end
end
end
# app/views/kiosk/delivery_slots/delivery_slots.json.jbuilder
# frozen_string_literal: true
zone = @search.zone
json.array! DeliverySlots.bookable(@search.day, zone) do |slot_at|
json.slot_at slot_at.iso8601
json.label DeliverySlots.label(slot_at, zone)
json.timezone zone.name
json.district @search.district
end
The handler checks nothing itself. Its arguments are validated in a model, the way an order is in Step 5: search.validate! raises on a refusal, and the engine answers 400 with the model's own messages. served_address: true is getgrocery's ServedAddressValidator, which accepts a Dublin address in a district the shop serves.
# app/models/slot_search.rb
# frozen_string_literal: true
# The arguments of a delivery-slots search: an address, and a day on that
# address's clock.
class SlotSearch
include ActiveModel::Model
include ActiveModel::Attributes
attribute :delivery_address, :string
attribute :date, :date
validates :delivery_address, served_address: true
validate :date_not_ended, if: :date
def zone = DeliverySlots.zone_at(delivery_address)
def district = DublinZones.check(delivery_address).district
# The named day at the address, never before the soonest day the shop delivers.
def day
soonest = DeliverySlots.soonest_date(zone)
date ? [date, soonest].max : soonest
end
private
def date_not_ended
return unless date < zone.today
errors.add(:date, "#{date.iso8601} is in the past at the delivery address (#{zone.name}); " \
"the earliest day you can ask for is #{zone.today.iso8601}.")
end
end
Step 5: Declare your actions
Actions mutate state — place an order, move a paid delivery. They are the same shape as Step 4's queries, marked kind :action. getgrocery groups its verbs by resource. The orders controller holds the my_orders query beside the create_order action. The deliveries controller holds the reschedule_delivery action and the order_delivery event topic.
The rules an order is held to live on the model, in validation contexts: :place for create_order, :reschedule for reschedule_delivery. window_bookable takes a slot_at only when it is the start of a window on the address's clock that is still open.
# app/models/order.rb
# frozen_string_literal: true
# One basket, its delivery window and address. `status` is the basket,
# `payment_status` the money. `timezone` is the clock of the delivery district
# the window was quoted on.
class Order < ApplicationRecord
include Kiosk::Owned
include Kiosk::Payable
MAX_ITEMS = 50
enum :status, {
placed: 'placed',
rescheduled: 'rescheduled',
out_for_delivery: 'out_for_delivery',
delivered: 'delivered',
cancelled: 'cancelled'
}
enum :payment_status, { unpaid: 'unpaid', paying: 'paying', paid: 'paid', refunded: 'refunded' }
belongs_to :user
has_many :order_items, dependent: :destroy, autosave: true
# What a caller placing or moving an order is held to.
validates :order_items, length: { in: 1..MAX_ITEMS, too_short: 'must name a product',
too_long: 'are at most %{count} lines' }, on: :place # rubocop:disable Style/FormatStringToken -- Rails I18n interpolates %{count}
validates :address, served_address: true, on: %i[place reschedule]
validate :window_bookable, on: %i[place reschedule]
# Paid and not yet with the courier.
scope :awaiting_courier, -> { paid.where(status: %i[placed rescheduled]) }
# One move per order, and none once the courier has the basket.
scope :reschedulable, -> { placed }
def self.readable_by?(order_id, user_id) = exists?(id: order_id, user_id: user_id)
def zone = Time.find_zone!(timezone)
def items_by_product = order_items.sort_by { it.product.name }
# What the shelf cannot fill, e.g. ["milk-1l: Sold out. Check back tomorrow.", "banana: 3 ordered, 2 left"];
# empty when it can.
def shortfall
order_items.group_by(&:product).filter_map do |product, items|
qty = items.sum(&:qty)
next if qty <= product.stock
product.sold_out? ? "#{product.sku}: #{Product::SOLD_OUT}" : "#{product.sku}: #{qty} ordered, #{product.stock} left"
end
end
def awaiting_courier? = paid? && (placed? || rescheduled?)
private
# A window takes orders while a basket paid now still reaches the door inside it.
def window_bookable
if slot_at.nil? || !DeliverySlots.start?(slot_at, zone)
errors.add(:slot_at, 'must be the slot_at of a delivery_slots row')
elsif DeliverySlots.closed?(slot_at)
errors.add(:slot_at, "#{window_name} is no longer bookable; call delivery_slots for the open windows")
end
end
# "08:00–10:00 (Europe/Dublin) on 2026-09-01".
def window_name = "#{DeliverySlots.label(slot_at, zone)} on #{slot_at.in_time_zone(zone).to_date.iso8601}"
end
Kiosk::Owned gives the model own, the signed-in customer's rows. Kiosk::Payable gives each order its payment_state (unpaid, pending while a capture is in flight, paid or refunded), checkout(lines), the checkout it signs for the assistant to pay, and settlement. with_settlement reads the payment state of many orders in one query from Kiosk::Settlement, the engine's model over the capture receipts it records after a successful pay.
app/controllers/kiosk/orders_controller.rb:
# app/controllers/kiosk/orders_controller.rb
# frozen_string_literal: true
# The customer's orders: placing and reading them. The work is in app/operations.
module Kiosk
class OrdersController < ActionController::API
include Kiosk::Handler
ORDER_ID = 'e2b1c0d4-5f6a-4b3c-8d2e-1f0a9b8c7d6e'
kind :query
description "The signed-in customer's orders, with their delivery window and where the money " \
'stands. Re-read it after a payment whose answer never arrived.'
input_schema type: 'object', additionalProperties: false, properties: {}, required: []
output_schema type: 'array',
description: 'Orders, newest first.',
items: {
type: 'object', additionalProperties: false,
properties: {
order_id: { type: 'string' },
status: { type: 'string', enum: Order.statuses.keys,
description: 'Where the basket stands.' },
total_cents: { type: 'integer', description: 'EUR cents.' },
slot_at: { type: 'string', description: "The delivery window's start." },
slot_label: { type: 'string', description: 'The window as a human reads it.' },
address: { type: 'string' },
payment_state: Kiosk::Payable::STATE_SCHEMA,
refunded_cents: { type: 'integer',
description: 'EUR cents returned to the card for items that sold out ' \
'before the basket was packed; all of it on a `cancelled` order.' },
credited_cents: { type: 'integer',
description: 'EUR cents the shop owes your account for items that sold out ' \
'before the basket was packed, when the payment processor ' \
'refused to return them to the card.' }
},
required: %w[order_id status total_cents slot_at slot_label address payment_state refunded_cents
credited_cents]
}
def my_orders
@orders = Order.own.with_settlement(Kiosk::Settlement.own).order(created_at: :desc)
end
kind :action
description 'Place an order for delivery in a window at an address. The answer carries the ' \
'checkout to pay. Alcohol needs a completed 18+ check (`request_kyc`); an order is at ' \
"most #{Order::MAX_ITEMS} lines of at most #{OrderItem::MAX_QTY} each."
input_schema type: 'object',
additionalProperties: false,
properties: {
items: {
type: 'array', minItems: 1, maxItems: Order::MAX_ITEMS,
items: {
type: 'object', additionalProperties: false,
properties: {
sku: { type: 'string', description: 'A catalog sku.' },
qty: { type: 'integer', minimum: 1, maximum: OrderItem::MAX_QTY }
},
required: %w[sku qty]
}
},
slot_at: { type: 'string', format: 'date-time',
description: 'The `slot_at` of a delivery_slots row.' },
delivery_address: { type: 'string', description: 'The address delivery_slots was asked about.' }
},
required: %w[items slot_at delivery_address]
output_schema type: 'object',
description: 'The order, priced.',
additionalProperties: false,
properties: {
order_id: { type: 'string', description: '`my_orders` lists it.' },
total_cents: { type: 'integer', description: 'EUR cents.' },
total_eur: { type: 'string', description: 'The total as a human reads it.' },
currency: { type: 'string' },
slot_at: { type: 'string', description: "The delivery window's start." },
slot_label: { type: 'string', description: 'The window as a human reads it.' },
timezone: { type: 'string', description: "The delivery address's IANA zone." },
checkout_jwt: Kiosk::Payable::CHECKOUT_SCHEMA
},
required: %w[order_id total_cents total_eur currency slot_at slot_label timezone checkout_jwt]
example_params({
items: [{ sku: 'sourdough-bread', qty: 2 }, { sku: 'greek-yogurt', qty: 1 }],
slot_at: -> { DeliverySlots.starts(DeliverySlots.default_zone.tomorrow)[2].iso8601 },
delivery_address: '42 Camden Street, Dublin 2'
})
example_row({
order_id: ORDER_ID, total_cents: 1287, total_eur: '€12.87', currency: 'eur',
slot_at: -> { DeliverySlots.starts(DeliverySlots.default_zone.tomorrow)[2].iso8601 },
slot_label: "12:00–14:00 (#{DeliverySlots::DEFAULT_ZONE_NAME})",
timezone: DeliverySlots::DEFAULT_ZONE_NAME,
checkout_jwt: 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJjdXJyZW5jeSI6ImV1ciIsInRvdGFsX2Ftb3VudF9jZW50cyI6MTI4N30.c2lnbmF0dXJl'
})
def create_order
@order = CreateOrderOperation.call(
customer_id: kiosk_identity.user_id,
items: params[:items].map { it.permit(:sku, :qty).to_h.symbolize_keys },
slot_at: params[:slot_at],
delivery_address: params[:delivery_address]
)
end
end
end
One view per verb, in a directory named after its controller:
# app/views/kiosk/orders/my_orders.json.jbuilder
# frozen_string_literal: true
json.array! @orders do |order|
json.order_id order.id
json.extract! order, :status, :total_cents
json.slot_at order.slot_at.in_time_zone(order.zone).iso8601
json.slot_label DeliverySlots.label(order.slot_at, order.zone)
json.extract! order, :address, :payment_state, :refunded_cents, :credited_cents
end
# app/views/kiosk/orders/create_order.json.jbuilder
# frozen_string_literal: true
json.order_id @order.id
json.total_cents @order.total_cents
json.total_eur Product.format_eur(@order.total_cents)
json.currency 'eur'
json.slot_at @order.slot_at.in_time_zone(@order.zone).iso8601
json.slot_label DeliverySlots.label(@order.slot_at, @order.zone)
json.timezone @order.timezone
json.checkout_jwt @order.checkout(@order.order_items.map { { sku: it.product.sku, qty: it.qty, price_cents: it.price_cents } })
app/controllers/kiosk/deliveries_controller.rb moves a paid order and declares the courier's topic:
# app/controllers/kiosk/deliveries_controller.rb
# frozen_string_literal: true
# An order's delivery: moving it to another window, and following the courier. The work is in app/operations.
module Kiosk
class DeliveriesController < ActionController::API
include Kiosk::Handler
eta = DeliverySlots.starts(DeliverySlots.default_zone.tomorrow).first + 40.minutes
topic :order_delivery do
description "The order is on its way (`out_for_delivery`, with the courier's ETA), at the " \
'door (`delivered`), or `cancelled`: everything in it sold out before it was packed, and the ' \
'payment went back to the card. `credited_cents` (on `placed` or `cancelled`): items sold out ' \
'before packing, the payment processor refused to return their price to the card, and the shop ' \
'owes it to your account. A `placed` order is delivered without them.'
payload_schema type: 'object', additionalProperties: false,
properties: { order_id: { type: 'string', format: 'uuid' },
status: { enum: %w[placed out_for_delivery delivered cancelled] },
credited_cents: { type: 'integer', minimum: 1,
description: 'EUR cents the shop owes your account.' },
eta: { type: 'string', format: 'date-time',
description: 'When the courier arrives.' },
eta_label: { type: 'string', description: 'The ETA as a human reads it.' },
timezone: { type: 'string', description: "The delivery address's IANA zone." } },
required: %w[order_id status]
example order_id: OrdersController::ORDER_ID, status: 'out_for_delivery', eta: eta.utc.iso8601,
eta_label: DeliverySlots.clock_label(eta, DeliverySlots.default_zone),
timezone: DeliverySlots::DEFAULT_ZONE_NAME
example order_id: OrdersController::ORDER_ID, status: 'delivered'
example order_id: OrdersController::ORDER_ID, status: 'cancelled'
example order_id: OrdersController::ORDER_ID, status: 'placed', credited_cents: 349
subject_reachable ->(order_id, identity) { Order.readable_by?(order_id, identity.user_id) }
end
kind :action
description 'Move a paid order to another delivery window, optionally to another address. The ' \
'payment already made covers it. Once per order.'
input_schema type: 'object',
additionalProperties: false,
properties: {
order_id: { type: 'string', format: 'uuid' },
slot_at: { type: 'string', format: 'date-time',
description: 'The `slot_at` of a delivery_slots row.' },
delivery_address: { type: 'string', description: "The new address; the order's own if omitted." }
},
required: %w[order_id slot_at]
output_schema type: 'object',
description: 'The moved order.',
additionalProperties: false,
properties: {
order_id: { type: 'string' },
rescheduled_at: { type: 'string', description: "The new window's start." },
rescheduled_label: { type: 'string', description: 'The new window as a human reads it.' },
timezone: { type: 'string', description: "The delivery address's IANA zone." }
},
required: %w[order_id rescheduled_at rescheduled_label timezone]
example_params({ order_id: OrdersController::ORDER_ID,
slot_at: -> { DeliverySlots.starts(DeliverySlots.default_zone.tomorrow)[2].iso8601 } })
example_row({ order_id: OrdersController::ORDER_ID,
rescheduled_at: -> { DeliverySlots.starts(DeliverySlots.default_zone.tomorrow)[2].iso8601 },
rescheduled_label: "12:00–14:00 (#{DeliverySlots::DEFAULT_ZONE_NAME})",
timezone: DeliverySlots::DEFAULT_ZONE_NAME })
def reschedule_delivery
@order = RescheduleDeliveryOperation.call(
order_id: params[:order_id],
slot_at: params[:slot_at],
delivery_address: params[:delivery_address]
)
end
end
end
# app/views/kiosk/deliveries/reschedule_delivery.json.jbuilder
# frozen_string_literal: true
json.order_id @order.id
json.rescheduled_at @order.slot_at.in_time_zone(@order.zone).iso8601
json.rescheduled_label DeliverySlots.label(@order.slot_at, @order.zone)
json.timezone @order.timezone
The actions hand their work to an operation each, which returns the order the view renders:
# app/operations/create_order_operation.rb
# frozen_string_literal: true
# Places one order for the customer: the basket, its delivery window and
# address, each line at today's catalogue price. Returns the order; raises a Kiosk error on a refusal.
class CreateOrderOperation
def self.call(customer_id:, items:, slot_at:, delivery_address:)
order = Order.new(user_id: customer_id, slot_at: slot_at, address: delivery_address,
timezone: DeliverySlots.zone_at(delivery_address).name, order_items: lines(items))
order.validate!(:place)
refuse_short!(order)
Kiosk::Server::Kyc.require! if age_restricted?(order)
order.update!(total_cents: order.order_items.sum { it.price_cents * it.qty })
order
end
def self.refuse_short!(order)
short = order.shortfall
return if short.empty?
raise Kiosk::Server::Errors::Conflict.new("not enough in stock — #{short.join('; ')}",
hint: 'Call catalog for what is in stock, then order again.')
end
def self.age_restricted?(order) = order.order_items.any? { it.product.age_restricted? }
# One order line per requested item, each with its catalogue product and price.
def self.lines(items)
products = Product.where(sku: items.pluck(:sku)).index_by(&:sku)
items.map do |item|
product = products[item[:sku]]
OrderItem.new(sku: item[:sku], product:, qty: item[:qty], price_cents: product&.price_cents)
end
end
end
# app/operations/reschedule_delivery_operation.rb
# frozen_string_literal: true
# Moves a paid order to another delivery window, and optionally another
# address, on the payment it already has. Returns the order; raises a Kiosk error on a refusal.
class RescheduleDeliveryOperation
def self.call(order_id:, slot_at:, delivery_address:)
order = Order.own.reschedulable.find_by(id: order_id)
# One answer for absent, foreign and already moved, so ids cannot be probed.
refuse 'no order of yours with this id can be moved: it was moved once already or is with the courier' unless order
refuse "this order's payment is still in progress; reschedule once my_orders reads `paid`" if order.paying?
refuse 'this order is not paid; only a paid order can be moved' unless order.paid?
address = delivery_address.presence || order.address
zone = DeliverySlots.zone_at(address)
order.assign_attributes(status: :rescheduled, address: address, timezone: zone.name, slot_at: slot_at)
order.save!(context: :reschedule)
CourierDispatchJob.arm!(order)
order
end
def self.refuse(message)
raise Kiosk::Server::Errors::Forbidden, message
end
end
Paid is the order's ownpayment_status. The orders table carries it:t.string :payment_status, null: false, default: "unpaid". The reschedule operation readsorder.paid?.paidis written by the engine'sKiosk::Server::PaymentClaim, which wraps the payment provider: it moves the order'spayment_statusfromunpaidtopayingbefore the capture, checks the checkout against your price, writespaidonce the capture returns, and tells the customer on thepaymenttopic. There is nopayment_gated:option:PaymentClaimwrites thepayment_statusthat an action gates on. getgrocery configures it in place of Step 2's bare provider:# config/initializers/kiosk.rb, an excerpt # Payments: the card setup, the capture, and what follows it. Kiosk.configure do |c| c.payment_provider = Kiosk::Server::PaymentClaim.new( # STRIPE_MOCK_URL names a local stripe-mock in development and test. Kiosk::PaymentProviders::Stripe.new(api_key: ENV.fetch('STRIPE_SECRET_KEY'), api_base: ENV.fetch('STRIPE_MOCK_URL', nil)), currency: 'eur', model: 'Order', reference: 'order_id', query: 'my_orders' ) c.checkout_price_checker = PriceChecker c.after_payment = ->(order_id) { FulfilOrderOperation.call(order_id) } endc.checkout_price_checkeranswers the order's total once the checkout mirrors it; getgrocery's isPriceChecker. Refund through the same object:claim.refund(psp_reference:, amount_cents:, key:)records the refund inkiosk.refunds, so that money stops counting as recent spend. When the whole payment went back, writepayment_status: :refundedand callclaim.announce(order.id, Kiosk::Payable::REFUNDED). A refund made only in the processor's dashboard still counts as spend.c.after_paymentruns once the engine records the settlement. getgrocery'sFulfilOrderOperationtakes the basket off the shelf there, and refunds a line that the shelf cannot fill.
Topics. Atopicblock declares an event an assistant can follow onGET /kiosk/events(Step 3): adescription, apayload_schema, andsubject_reachable, which says whether a subscriber may follow a given order. getgrocery declaresorder_deliveryin its deliveries controller, above. Eachexamplecall adds one sample payload; it is checked againstpayload_schemawhen the class loads and published asexampleson the topic's entry inGET /kiosk/schema. Your code emits the event when it happens:# app/jobs/courier_dispatch_job.rb, an excerpt def announce_departure(order, arrival) Kiosk::Server::Events.emit( topic: :order_delivery, subject: order.id, identity_scope: [order.user_id], data: { 'order_id' => order.id, 'status' => 'out_for_delivery', 'eta' => arrival.utc.iso8601, 'eta_label' => DeliverySlots.clock_label(arrival, order.zone), 'timezone' => order.timezone } ) end
Step 6: Start the server and verify
Set the issuer, the signing key, and a Stripe test key, then boot. The issuer is config-driven (it is the iss of the checkouts you sign) — the request host only selects among the origins you configure and never adds one, so set KIOSK_ISSUER to the URL agents will actually reach.
export KIOSK_ISSUER="http://localhost:3000"
export KIOSK_SIGNING_KEY_B64="$(openssl genpkey -algorithm ed25519 | base64)"
export STRIPE_SECRET_KEY=sk_test_…
rails s
Another hostname: same business, redirect it; another business, list it. Theissueryou just exported is compared to theaudan assistant signed by strict equality, and every origin you serve is its own operator (§4.1 of the protocol): its own discovery document, its own assistant accounts. So if a vanity or legacy hostname of the same business also answers today, redirect it to the canonical origin — a301at the edge, in front of Rails, before anything under/kiosk; otherwise an assistant that dialed the alias signed the alias, and it is forbidden to re-sign your advertised issuer to paper the mismatch over. If the hostname is a second business on the same deployment, list it instead:c.additional_origins = ["https://second.example"]inconfig/initializers/kiosk.rb. (Rails'config.hostsanswers a different question — whichHostheaders you accept, not which origin you are.)
Verify discovery works and reports the issuer you configured, plus the capability list. capabilities names the modules your endpoint serves — schema, queries, actions, pay, and events when a topic is declared. It is computed from the live registry, so a list that comes back short is telling you something you meant to serve is not registered: no queries means no query is registered, no actions means no action, no pay means no payment provider configured. See the note under the /kiosk/schema call below:
curl -s http://localhost:3000/.well-known/kiosk.json | jq '.kiosk | {issuer, capabilities}'
# => { "issuer": "http://localhost:3000", "capabilities": ["schema","queries","actions","pay","events"] }
Now check the surface the mount installed, not only the path this page walks. The two calls below exist because the walkthrough is a bad test of the walkthrough: everything from here to the end of Step 6 exercises discovery-plus-register-plus-one-verb, so an app serving nothing else Step 3 promised would still get a clean run. Discovery is ten documents, and the mount also draws the account-binding ceremony — so verify those directly, before you trust a green walkthrough:
# The discovery documents, at the ORIGIN ROOT (not under /kiosk).
for p in /agents.txt /agents.json /auth.md /.well-known/kiosk.json \
/.well-known/api-catalog /.well-known/oauth-authorization-server \
/.well-known/oauth-protected-resource /.well-known/ard.json /.well-known/ai-catalog.json; do
printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000$p")" "$p"
done
# => 200 for all nine. A 404 means the engine is bundled but NOT mounted: these
# routes are appended to your app only when Step 3's kiosk block is present.
# The tenth, /.well-known/agent-skills/index.json, answers 200 once you pin
# skill_sha256.
# The account-binding ceremony is mounted too. Ask for it with no parameters and
# it must REFUSE — a 401 is the route answering; a 404 is the route missing.
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/kiosk/oauth/device_authorization
# => 401 (invalid_client: the request carries no client assertion)
The catalog is public — GET /kiosk/schema takes no token and asks no toll, so you can read it before you have one. Every other endpoint under the mount is authenticated, so to call a verb first register and get a token, on OAuth standards: register your public key (RFC 7591), then ask the token endpoint with the client_credentials grant, a client assertion that your key signs (RFC 7523, aud = the origin you dialed) and a DPoP proof (RFC 9449). A Kiosk-compatible agent does this for you, but here it is as a self-contained script (Ruby stdlib only: OpenSSL signs Ed25519, so the script builds each JWS itself) so registration is followable without any external CLI:
# register.rb — OAuth client registration and a token, against a local Kiosk provider. Ruby stdlib only.
require "openssl"; require "json"; require "net/http"; require "uri"
require "securerandom"; require "base64"
origin = ENV.fetch("KIOSK_ISSUER", "http://localhost:3000") # the origin you dial == aud
key = OpenSSL::PKey.generate_key("ED25519") # your identity keypair
File.write("key.pem", key.private_to_pem) # dpop.rb below signs with it
b64 = ->(bytes) { Base64.urlsafe_encode64(bytes, padding: false) }
jwk = { kty: "OKP", crv: "Ed25519", x: b64.(key.raw_public_key) }
jws = lambda do |header, claims| # a compact JWS, signed EdDSA (RFC 8037)
input = [header, claims].map { |part| b64.(JSON.generate(part)) }.join(".")
"#{input}.#{b64.(key.sign(nil, input))}"
end
# 1. Register the public key (RFC 7591) → 201 Created { client_id, … }.
res = Net::HTTP.post(URI("#{origin}/kiosk/oauth/register"),
{ jwks: { keys: [jwk] } }.to_json, "Content-Type" => "application/json")
client_id = JSON.parse(res.body).fetch("client_id") # = your agent_id; keep it
# 2. Ask for a token: a client assertion (RFC 7523) and a DPoP proof with no `ath`.
token_url = "#{origin}/kiosk/oauth/token"
now = Time.now.to_i
assertion = jws.({ alg: "EdDSA", typ: "client-authentication+jwt" },
{ iss: client_id, sub: client_id, aud: origin, exp: now + 60, jti: SecureRandom.uuid })
proof = jws.({ alg: "EdDSA", typ: "dpop+jwt", jwk: jwk },
{ jti: SecureRandom.uuid, htm: "POST", htu: token_url, iat: now })
req = Net::HTTP::Post.new(URI(token_url), "DPoP" => proof)
req.set_form_data(grant_type: "client_credentials",
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: assertion)
res = Net::HTTP.start(req.uri.host, req.uri.port, use_ssl: req.uri.scheme == "https") { |http| http.request(req) }
puts "export TOKEN=#{JSON.parse(res.body).fetch("access_token")}" # token_type "DPoP", expires_in 3600
# When it expires, repeat step 2 with the same key.pem and client_id.
The token works only with the key that registered it (DPoP, RFC 9449): each call sends it as Authorization: DPoP $TOKEN with a DPoP header, a proof that the key signs for that method and URL. This script prints one:
# dpop.rb METHOD URL — the DPoP proof for one call, signed EdDSA by key.pem. Reads $TOKEN.
require "openssl"; require "json"; require "securerandom"; require "digest"; require "base64"
key = OpenSSL::PKey.read(File.read("key.pem"))
b64 = ->(bytes) { Base64.urlsafe_encode64(bytes, padding: false) }
header = { alg: "EdDSA", typ: "dpop+jwt", jwk: { kty: "OKP", crv: "Ed25519", x: b64.(key.raw_public_key) } }
claims = { jti: SecureRandom.uuid, htm: ARGV[0], htu: ARGV[1].split("?").first,
iat: Time.now.to_i, ath: b64.(Digest::SHA256.digest(ENV.fetch("TOKEN"))) }
input = [header, claims].map { |part| b64.(JSON.generate(part)) }.join(".")
puts "#{input}.#{b64.(key.sign(nil, input))}"
With that access_token exported as $TOKEN, confirm your surface and read data back — nothing but curl + jq. There is nothing to seed: point a query at a table your app already has and expect your own rows. The names below are this walkthrough's getgrocery example — substitute the queries you declared in Step 4:
# Inspect the machine-readable surface (your registered queries + actions).
# No token: the catalog is public.
curl -s http://localhost:3000/kiosk/schema | jq '.queries | map(.name)'
# => ["catalog","delivery_slots","my_orders"]
# Browse the catalog. One endpoint per verb: a query is a GET at its own name,
# and a query ALWAYS answers a bare JSON array — no envelope to unwrap.
curl -s http://localhost:3000/kiosk/catalog -H "Authorization: DPoP $TOKEN" \
-H "DPoP: $(ruby dpop.rb GET http://localhost:3000/kiosk/catalog)" | jq '.[0]'
# => { "sku": "sourdough-bread", "name": "Sourdough Bread",
# "price_cents": 449, "price_eur": "€4.49", "currency": "eur" }
# Arguments travel in the QUERY STRING, checked against the input_schema
# before your handler runs. `date` is OPTIONAL here: omit it and the operator
# answers for the soonest day IT can deliver — the answer you want, and the one
# a caller cannot work out for itself. Keep the row you get back.
SLOT=$(curl -s -G http://localhost:3000/kiosk/delivery_slots \
--data-urlencode "delivery_address=42 Camden Street, Dublin 2" -H "Authorization: DPoP $TOKEN" \
-H "DPoP: $(ruby dpop.rb GET http://localhost:3000/kiosk/delivery_slots)" | jq '.[0]')
echo "$SLOT"
# => { "slot_at": "…", "label": "08:00–10:00 (Europe/Dublin)",
# "timezone": "Europe/Dublin", "district": "D02" }
# An action is a POST at its own name, with a JSON body. `slot_at` comes
# straight from the row above.
curl -s -X POST http://localhost:3000/kiosk/create_order -H "Authorization: DPoP $TOKEN" \
-H "DPoP: $(ruby dpop.rb POST http://localhost:3000/kiosk/create_order)" -H "Content-Type: application/json" \
-d "$(jq -n --argjson slot "$SLOT" \
'{items: [{sku: "sourdough-bread", qty: 2}],
slot_at: $slot.slot_at,
delivery_address: "42 Camden Street, Dublin 2"}')"
Got an empty catalog back? Your controllers are not inapp/controllers/kiosk/. That is the state where a booted app serves no verbs: every/kiosk/<name>answers a404problem document whosehintlists what IS registered, and thecapabilitiesyou checked above come back withoutschema,queriesoractions. kiosk-server says the same thing on the log at boot when nothing at all is registered.
Two shapes worth seeing once. A long list paginates by RFC 8288: the body is still a bare array, the total is inX-Total-Count, and a truncated page carriesLink: <…>; rel="next". The absence of that link is the only signal there is no more — there is nonextfield in the body. And a GET at an action's name (or a POST at a query's) matches no route: it is the ordinary404of Rails, with no problem document, as for a name you never registered.GET /kiosk/schemagives each verb's method. Add-ito any call above to see the headers.
The full register → order → pay flow (including the 2 signed AP2 mandates) is what a Kiosk-compatible agent does for you — point one at your local server rather than hand-signing mandates with curl. Registration itself is exactly theregister.rbscript above; to watch the whole flow driven end-to-end, runbin/rails test test/stories/shop_test.rbin getgrocery; it pays a local stripe-mock.
Running a demo needs one thing this integration does not. Every demo setsc.registration_pow_count = 1and theRecentSpendcall toll, so each assistant it registers solves an Equihash toll with the bundled solver. The same toll prices a link-code claim and the first binding poll of each device code. The assistant then solves one more toll on every Nth call: N = ceil(10 / share), with share = (cap − spend) / cap, where spend is what its customer spent there recently. With no recorded spend N is 10, and at the cap no call pays. That solver is Python, so a demo needs python3 with numpy on PATH (python3 -c "import numpy"). A demo'sbin/rails db:resetis also destructive: it drops and recreates that demo's database every time it runs. Your own integration needs no Python: the engine's tolls default to none, and verifying a proof is pure Ruby. Each demo's README states its own prerequisites, derived from that demo's files.
Pointers from your human site (optional, invisible to humans)
Discovery is already done: the mount in Step 3 installed the machine-readable documents agents actually read — /agents.txt, /agents.json, /auth.md and the /.well-known/ documents — at your origin root, and nothing below is needed for an agent to find them. What follows is the layer on top: three optional pointers you can drop into the HTML site your human customers already browse, so an agent that lands on a page rather than at the root is sent to the root. Two are machine-readable, one is a visual cue for the curious. None interfere with your existing site.
1. HTML <link> tag (machine-readable)
Add to your <head>:
<link rel="kiosk" href="<%= Kiosk.configuration.skill_url %>">
An agent scanning the page sees this and knows it can transact here.
2. HTTP Link header (for agents that don't parse HTML)
In your page controller:
include Kiosk::SkillLink
A HEAD request is enough — no page download needed. The same header also names your /.well-known/api-catalog with rel="api-catalog" (RFC 9727).
Do not put a bot challenge in front of the agent paths. A CDN or firewall bot rule that challenges/kiosk/*,/agents.txt,/agents.json,/auth.mdor/.well-known/*stops every assistant: an assistant cannot solve a browser challenge. Exempt these paths from bot challenges. The proof-of-work toll already prices the calls that cost you CPU.
Both point at the versioned cut, not atskill.md. The spec requires it: thehrefmust namehttps://kiosk.tech/skill-vMAJOR.MINOR.PATCH.md— currentlyhttps://kiosk.tech/skill-v0.6.28.md— and must not name the mutable alias, because the alias tracks whatever kiosk.tech publishes next and nothing can be hash-pinned against it. That is why both snippets readKiosk.configuration.skill_urlinstead of a literal: it is the same URL your/.well-known/kiosk.jsonalready advertises underskill, so the two signals cannot disagree. kiosk-server gives that URL, and each kiosk-server release gives the cut of its own version: do not setskill_url.
3. Visual "Agents — over here" card (human-readable, subtle)
Add a small section somewhere unobtrusive on your homepage — a thin bar above the header, or a card below the fold. It tells the visitors and customers who send an AI assistant that your store speaks Kiosk, without distracting the others:
<section style="background:#0f2a1c;color:#fff;border-radius:16px;
padding:26px 28px;max-width:880px;margin:8px auto 56px">
<h2 style="font-size:19px">🤖 Agents — over here. This store speaks Kiosk.</h2>
<p style="font-size:14px;opacity:.92">
Your assistant can order and pay directly — no human account needed.
Start at <code>/.well-known/kiosk.json</code>, then <code>/kiosk/schema</code>.
</p>
<a href="/.well-known/kiosk.json">/.well-known/kiosk.json</a>
<a href="/kiosk/schema">/kiosk/schema</a>
</section>
See getgrocery's homepage for a live example — a thin "For agents" bar above the storefront header, pointing at /.well-known/kiosk.json. One line a human's eye slides off; the line an agent is looking for.
What the agent experience looks like
From the agent's perspective, once the steps above are done:
- Discovery:
GET /.well-known/kiosk.json→ finds your endpoint - Registration, then binding when the human has an account: the assistant registers its key (
POST /kiosk/oauth/register→client_id), then asksPOST /kiosk/oauth/tokenfor anaccess_tokenwith a client assertion and a DPoP proof, and asks again when the token expires. When the human already has an account with you, the registered assistant then binds to it: the claim ceremony it opens itself (POST /kiosk/oauth/device_authorization→ the human approves theuser_codeat/kiosk/oauth/device/verify→POST /kiosk/oauth/token), or a link code the human generated on «Link an assistant» (/kiosk/auth/assistants→POST /kiosk/auth/link) and handed over, which the assistant redeems atPOST /kiosk/auth/claimand then asks the token endpoint. After a binding the token is the human's account's; only whose rows it sees differs, and every step below is the same either way. - Read the contract:
GET /kiosk/schema(no token) → learns every verb's name, prose and both schemas before it spends anything - Browse:
GET /kiosk/catalogandGET /kiosk/delivery_slots?delivery_address=…→ sees your products and when you can deliver - Order:
POST /kiosk/create_order→ order created, delivery window booked with it - Card setup:
POST /kiosk/payment_setup→ human enters card once on Stripe - Pay: agent signs 2 AP2 mandates for the order's
checkout_jwt,POST /kiosk/pay→ payment settled - Move it, if the human changes their mind:
POST /kiosk/reschedule_delivery→ the paid order's window moves, with no second charge
The agent never sees your UI. It never creates an account for the customer. It transacts entirely through the REST surface you just added.
What to do next
- Test with your own agent. Point an AI assistant that can fetch a skill file and make its own HTTP calls at your local server and say "order groceries." (Hermes and Claude Code have each driven this loop end to end against the hosted demos on protocol 0.6. No other assistant has been run against them, so treat your first run as a test of your integration and of your assistant.)
- Add more queries. Expose anything an agent might need — store locations, nutritional info, allergy filters. A new verb needs a method in a handler controller and its name in the
kioskblock ofconfig/routes.rb. - Add more actions. Reservations, cancellations, loyalty points — anything your app does today.
- Go to production. Swap
STRIPE_SECRET_KEYfor a live key, add your domain toKIOSK_ISSUER, and deploy.
Reference
- kiosk.tech — landing page + agent skill
- kiosk.tech/skill.md — the universal agent skill
- kiosk.tech/skills.md — the index of published cuts: which
skill-vX.Y.Z.mdtargets which protocol, so you can tell whether the cut you pinned still matches the protocol you serve - descriptor house style — how to write the
description+input_schemapair well, with a worked search verb and a checklist - github.com/kiosk-hq/kiosk — OSS reference implementation
- kiosk-server — draw the routes — the engine behind Step 3: the
kioskblock, everything the mount draws, the root-relative discovery documents it installs, and what answers a path you drew no route for - kiosk-server — declaring queries and actions — the
Kiosk::Handlermixin this page's controllers use: the full macro list, where handlers live, the dispatch mechanics, and the error seam - kiosk-demo-getgrocery — the full runnable provider this guide is based on; its
test/stories/shop_test.rb(bin/rails test) drives the entire register → order → pay flow end-to-end over HTTP, as an assistant does. This page shows the parts of its controllers, models and operations that the walkthrough needs.