Operator onboarding — from install to agent-ready
A step-by-step guide for adding Kiosk to an existing Rails app. The controller shape below is kiosk-server's own — the two class names rails generate kiosk:install writes into your initializer as its worked example — and the domain it is worked through is the getgrocery demo provider's: a grocery catalogue, delivery windows, a settlement-gated reschedule.
So the class names here are illustrative, and the demo's are different. Kiosk::CatalogController and Kiosk::OrdersController are what the generator writes into a fresh initializer; getgrocery names its own handlers for its own domain and ships Kiosk::StorefrontController (the read half — catalog, delivery_slots, my_orders, kyc_status) beside Kiosk::OrdersController. There is no CatalogController in that demo to grep for. Name yours after your own domain too — the engine registers whatever c.handlers lists, and nothing depends on the word "catalog".
Check this first. kiosk-server declares railties, actionpack, 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 3.2.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 that controller in the initializer, and verify discovery + one query. That's the minimal agent-ready surface.
- Full transacting integration with payments — a bit longer. Everything below — an RSA signing key, a Stripe test account and keys, the
stripe_customerstable/model and payment-adapter wiring, actions that gate on a settlement, 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
These lines install from git, and that is not a preference. No Kiosk gem is on RubyGems yet, so bundle add kiosk-all and a bare gem "kiosk-all" do not resolve. Publication status and the canonical install are stated once, in the reference monorepo's Install section; when the gems ship, that is where the github: qualifier comes off.
# Gemfile
gem "kiosk-all", github: "kiosk-hq/kiosk" # meta-gem: kiosk-core + kiosk-server
gem "kiosk-pay-stripe", github: "kiosk-hq/kiosk" # Stripe card-on-file payment adapter (this walkthrough uses it)
bundle install
rails generate kiosk:install # the kiosk migrations + initializer
rails db:migrate
Step 2: Configure Kiosk
First, generate a signing key. Kiosk signs the JWTs it issues with an RSA key; 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 genrsa 2048 | base64
# → export KIOSK_SIGNING_KEY_B64="LS0tLS1CRUdJTi…" (or set KIOSK_SIGNING_KEY_PEM to the raw PEM)
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.user_id_column = :id
# The handler controllers that carry your wire verbs. You write them in
# Steps 4 and 5 below — this line comes first because it belongs in the
# initializer, and it is repeated at the end of Step 5 once both classes
# exist. It is not optional: a verb registers when its controller's class
# body is read, and nothing in your app ever references a handler controller
# (the wire reaches it THROUGH the registry), so an origin that names none of
# them serves no verbs at all in development. Name them and the engine loads
# and registers them — once at boot in production, again after every code
# reload in development. Strings, not constants.
c.handlers = %w[Kiosk::CatalogController Kiosk::OrdersController]
c.issuer = ENV.fetch("KIOSK_ISSUER", "https://your-domain.com")
# 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" }
# OPTIONAL — role pinned to every self-registered agent (agents can't
# choose their own). By default no role is assigned, and single-role
# providers like this one can leave it out entirely. Alternatively, assign
# the role yourself when creating the account in the assistant_creation
# hook below — that's where provider-owned role assignment belongs.
c.registration_role = :customer
# OPTIONAL — which identity provider verifies agent tokens. When omitted,
# kiosk-server uses its bundled kiosk-pop engine (DefaultAgentIdp): it
# verifies exactly the JWTs the register/login endpoints mint, so the
# zero-config install works end-to-end. Set this only to front another
# agent-identity system or to compose adapters; `c.user_idp` additionally
# lets your existing session auth (Devise etc.) call the same endpoints.
# c.agent_idp = MyCustomAgentIdp.new
# Registration mints a fresh principal (an "assistant account", not a human).
# 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"],
customer_resolver: ->(uid) { StripeCustomer.find_by(user_id: uid)&.customer_id },
customer_saver: ->(uid, cid) { StripeCustomer.create!(user_id: uid, customer_id: cid) },
)
end
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 schema helper functions, the identity tables (thekiosk.agents.user_idFK), reservations, device authorizations and the mandate tables. Onlykiosk.kyc_attributescarries no user id, so it is the one that does not. Thestripe_customerstable below usest.uuid :user_idto match. If yourusers.idis a Rails-defaultbigint, don't just edit thestripe_customerscolumn — regenerate the kiosk migrations withrails generate kiosk:install --user-id-type=bigint(so every kiosk table matches), setc.user_id_type = :bigint, and make thestripe_customers.user_idcolumnt.bigintto match — otherwise migrations fail with a datatype mismatch.
You need a stripe_customers table (referenced by the resolver/saver above):
# db/migrate/…_create_stripe_customers.rb
create_table :stripe_customers do |t|
t.uuid :user_id, null: false
t.string :customer_id, null: false
t.timestamps
end
add_index :stripe_customers, :user_id, unique: true
…and the matching model (the resolver/saver above reference it; kiosk-pay-stripe stays agnostic and never touches it directly):
# app/models/stripe_customer.rb
class StripeCustomer < ApplicationRecord
end
Step 3: Draw the routes
Your wire has two halves, and the split is by whose surface it is. Keep both in a routes file of their own and reach it from config/routes.rb with Rails' own draw, so the whole Kiosk surface reads as one file instead of being interleaved with your own pages:
# config/routes.rb
Rails.application.routes.draw do
root "home#index"
# ...your own pages...
draw(:kiosk)
end
The file draw reaches holds the mount first, then one explicit line per verb you serve. The six below are the ones Steps 4 and 5 declare:
# config/routes/kiosk.rb
# 1. THE PROTOCOL PLANE, mounted the ordinary Rails way. These paths and their
# answers are the specification's, not yours, so you do not write them.
mount Kiosk::Server::Engine => Kiosk.configuration.mount_path
# 2. YOUR OWN VERBS, one explicit route each, and the METHOD FOLLOWS THE KIND:
# GET for a `kind :query`, POST for a `kind :action`.
get "/kiosk/catalog", to: "kiosk/server/verb#show", defaults: { kiosk_verb: "catalog" }
get "/kiosk/delivery_slots", to: "kiosk/server/verb#show", defaults: { kiosk_verb: "delivery_slots" }
get "/kiosk/my_orders", to: "kiosk/server/verb#show", defaults: { kiosk_verb: "my_orders" }
post "/kiosk/create_order", to: "kiosk/server/verb#create", defaults: { kiosk_verb: "create_order" }
post "/kiosk/payment_setup", to: "kiosk/server/verb#create", defaults: { kiosk_verb: "payment_setup" }
post "/kiosk/reschedule_delivery", to: "kiosk/server/verb#create", defaults: { kiosk_verb: "reschedule_delivery" }
The prefix is the mount_path from Step 2's initializer — /kiosk unless you set another. defaults: { kiosk_verb: … } hands the name to the shipped VerbController: nothing is inferred from the path, and you never point a route at a handler controller of your own. Adding a verb is two edits in two files from here on — the declaration in your controller, and its line in this one — and in exchange bin/rails routes prints your actual wire.
Why one half is mounted and the other is typed out. 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 wire, 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 wire are not yours — their paths and their answers are the specification's, and hand-copying them into your file would buy you a table to keep in step with a protocol that moves. So the line between the two halves is ownership, not mechanism: you write 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) andPOST /kiosk/pay.payis drawn whether or not you configured a payment provider: without one it refuses 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 three, 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. - The proof-of-possession auth plane —
GET /kiosk/auth/challengeplusPOST /kiosk/auth/register,/kiosk/auth/loginand/kiosk/auth/revoke: 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 rather than at the origin root, and that is the URL discovery publishes asjwks_uri. - KYC attestation —
POST /kiosk/agents/kyc, where an agent presents a broker-signed identity fact. Drawn unconditionally for the same reason aspay: with no KYC key configured the verifier rejects with a501 module_not_servedproblem document, and a provider that never advertises KYC loses nothing. - The account-binding ceremony — how an assistant gets attached to a human's existing account instead of a fresh assistant account. That is the RFC 8628 claim wire (
POST /kiosk/oauth/device_authorization,POST /kiosk/oauth/token), 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, an assistant that already holds the privileged one keeps it while its principal changes to a human who holds none. 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. kiosk-server cannot check this at boot — whether a method is total over your humans is not something a configuration file says — so it warns in your log the first time a ceremony resolves no role on a multi-role origin, and §6.3 of the protocol states the requirement.
The mount also installs the discovery documents, which live at the origin root rather than under the prefix — 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./auth.md— the agent-auth handbook: which auth methods you accept and how each ceremony runs./.well-known/agent-configurationlinks to it by URL, and/agents.txtnamesauth-mdamong the authorization methods you support./.well-known/kiosk.json— the machine-readable handshake — plus/.well-known/agent-configurationand/.well-known/api-catalog.
All six 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 mount line adds no routes at all.
The mount goes first, and a verb you forget to route fails loudly. Rails dispatches the first matching route, so drawing the mount above your verb lines is what keeps every protocol path winning over anything you write below it — no verb of yours can shadowschema,payor the auth plane. Below your lines there is nothing: a path under the prefix naming no verb you drew 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 line you forgot, which is why a verb you declare and never route is simply unreachable;bin/check-verb-routesin the reference repo is what holds your routes to your declarations. If you want the wire's own404 verb_not_foundthere, add a catch-all action of your own at the end of this file. And the mount is how the protocol plane is drawn, not one of two ways: copying those paths into this file 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. Write your verbs; mount the rest.
Step 4: Declare your queries — write the controller, then name it
Queries are read-only data access. Agents use them to browse your catalog. Two moves, both required: write the controller here, and name it in c.handlers (Step 2's line, repeated at the end of Step 5) — that second line is what puts the verbs on the wire.
They live in controllers you own. Kiosk ships a mixin, not a base class — 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 wire 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 wire resolved (its user_id is your own model's PK), and whatever you render json: is the verb's result. The handler runs inside the wire'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 wire, which is where authentication, the PoW gate and the transaction live; a direct request answers 404. And a handler you have not named is not there at all. Declaration happens when the class body is read; production eager-loadsapp/controllers, but development (config.eager_load = false) autoloads on first reference and nothing ever references a handler controller — the wire reaches it through the registry, which is the thing that would be missing. An origin that names none of them serves no verbs:GET /kiosk/schemaanswers with an empty catalog, every/kiosk/<name>answers a404problem document, and/.well-known/kiosk.jsonadvertises"capabilities": []— those are computed from the live registry. Step 2'sc.handlersline is what closes it: the engine registers the classes you name in both load modes and rebuilds them after every code reload, so an edited, added or removed verb lands without restarting the server. You never write reload plumbing, and the catalog is identical in every environment. Registration is not reachability, though: a newly declared verb is in the catalog as soon as the file reloads, and answers on the wire once Step 3's routes file carries its line.
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 on the wire either, not even as null.)
Both schemas are REQUIRED, and the input one is enforced.input_schemaandoutput_schemaare protocol requirements, not house style: a verb declared without either fails the boot, naming the verb and the missing macro. Andinput_schemais not merely published — the wire 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 wire's error code vocabulary is a closed table of seventeen, not a class hierarchy, 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); 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 wire 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.
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.
Delivery slots come up in both a query and an action below, so the arithmetic that says what a slot id means lives in one place — app/models/delivery_slots.rb, a plain Ruby module Rails autoloads; nothing to require:
# app/models/delivery_slots.rb
#
# The query that OFFERS a window and the action that BOOKS it must derive the
# time from the same (date, slot_id) pair. When they each did their own
# arithmetic, getgrocery booked deliveries for a day the assistant was never
# shown. Keep it here and call it from both.
module DeliverySlots
FIRST_HOUR = 8 # slot 1 opens at 08:00
WINDOW_HOURS = 2 # each window is two hours
COUNT = 6 # so slot 6 (18:00-20:00) is the last
ZONE_NAME = "Europe/Dublin" # the zone of the PLACE THE SERVICE HAPPENS.
# A DEFAULT, not the answer: the clock belongs
# to the thing being SERVED, so the moment you
# serve two places you record it per place and
# this is only what fills that column.
# A real IANA zone, so DST is handled
module_function
def zone
Time.find_zone!(ZONE_NAME)
end
# "Now" at the service place — the reference point for every date question
# below. Never `Date.today`, which reads the SERVER PROCESS's zone: around
# midnight a server-zone answer differs from the service-place one, and then
# delivery_slots refuses a day create_order still accepts. The process zone
# is a property of the box you deployed to, and nobody chose it.
def now
zone.now
end
# THE DAY A PUBLISHED EXAMPLE NAMES: tomorrow, at the service place.
#
# `example_params` says "copy this verbatim", so a calendar literal written
# there stops being true on a day nobody notices — a date before today is
# refused, so a frozen literal ages into a 400. Tomorrow rather than today
# because every window of a future day is still bookable, and because
# tomorrow is what an omitted date already means to the write verbs.
def example_date
now.to_date + 1
end
# A DATE ON THE WIRE IS `YYYY-MM-DD`, AND NOTHING ELSE. One declared type
# admits one spelling — the same rule the wire already applies to an
# `integer` and to a `boolean`, and the reason every verb here that takes a
# day declares `format: "date"`. A machine is on the other end of the call and
# every row you hand it carries the day in this spelling, so a second way to
# write one buys nothing and costs the ambiguous case: `09/01/2026` is
# day-first to some senders and month-first to others.
#
# `Date.iso8601` BEHIND the pattern and not instead of it. ISO 8601 is a
# FAMILY: the basic form with no separators, a full datetime, an ISO week
# date and an ordinal date all parse through it, so on its own it would accept
# four spellings your description does not name — and one of them silently
# discards an hour the sender may have meant. What the parse is still for is
# the value that has the right shape and is not a day: a 30th of February.
#
# Nil rather than an exception, because the caller owns the sentence: each
# verb names the row an assistant gets a right value from, and those are
# different rows. Note what this method does NOT read: any clock. A partial
# value has nowhere to get the rest of itself from, so it is refused rather
# than completed, and the same string means the same day on every server.
ISO_DATE = /\A\d{4}-\d{2}-\d{2}\z/
def iso_date(raw)
value = raw.to_s
return nil unless ISO_DATE.match?(value)
begin
Date.iso8601(value)
rescue ArgumentError, TypeError
nil
end
end
def slot_at(date, slot_id)
hour = FIRST_HOUR + (slot_id.to_i - 1) * WINDOW_HOURS
zone.local(date.year, date.month, date.day, hour, 0)
end
# The window a HUMAN is read out, with the zone it is written in beside it.
# `slot_at` carries the resolved offset and is unambiguous already; nobody
# says an offset out loud, so the zone goes where the wall clock goes.
def label(slot_id)
hour = FIRST_HOUR + (slot_id.to_i - 1) * WINDOW_HOURS
format("%02d:00-%02d:00 (%s)", hour, hour + WINDOW_HOURS, ZONE_NAME)
end
# Still-bookable windows for a date: all of them on a later day; on TODAY only
# the ones that have not started, so an assistant is never offered a window it
# cannot actually have.
def bookable_ids(date)
(1..COUNT).reject { |slot_id| slot_at(date, slot_id) <= now }
end
end
Which zone isZONE_NAME? The place the service happens — and that place is a property of the thing being served, not of you. The rule is one line: the time of the service, at the place the service happens. For a table, a room, a chair, a workshop bay, the service is rendered at the address of that restaurant, hotel or salon. For a delivery it is rendered at the customer's door, and your zone is merely where your warehouse sits. Answer the question before you copy the constant.
A constant is only ever a default, and it stops being right the day you serve a second place. The specification is explicit: an operator MUST NOT answer from one zone configured on the origin, because an operator may run many stores in many time zones. So record the zone against the thing it belongs to — atimezonecolumn on your restaurants, properties or served districts,NOT NULL, backfilled from the constant you already have — and read it off the row you are answering about. If you serve exactly one place today, you have one zone by arithmetic rather than by design, and the column costs you one migration now instead of a silent wrong answer later. Never DERIVE it from a city string, a postcode or a lat/long: a guess nobody wrote down is one nobody can check from the other side of the wire.
getgrocery delivers only inside Dublin, so its map of served districts to clocks has one value in it today. What matters is that it is a map.
Two more things follow, and both are cheap. Publish the zone in every row that carries a wall clock — atimezonefield, and the IANA name inside the label too (08:00-10:00 (Europe/Dublin), never a bare08:00-10:00) — because the offset insideslot_atis already unambiguous and nobody says an offset out loud; the field a customer actually hears is the label. And never compute "today" from the server process's zone, which is whatnowabove exists for: a box in another zone rolls its date over at a different moment from the place the service happens, and no environment variable is a statement about where your tables are.
And the other direction: adatean assistant SENDS is a day on its human's calendar. The assistant declares that calendar in theKiosk-Timezonerequest header, as an IANA name; you read a caller-named day in it, and when the header is absent you read it on the service place's clock and say so in the row. A calendar day is an INTERVAL, so a day the caller is still IN is not "in the past" even when you have already rolled over — that is the midnight case the rule exists for. A day the caller has entirely finished is past, and is a400naming the earliest day you can serve.
app/controllers/kiosk/catalog_controller.rb:
# app/controllers/kiosk/catalog_controller.rb
class Kiosk::CatalogController < ApplicationController # your base class, your call
include Kiosk::Handler
# What can the agent browse?
kind :query
description "Browse everything currently in stock. Out-of-stock products are hidden, " \
"so anything visible here is something that can actually be ordered. The " \
"whole in-stock catalogue comes back in one call — this is the complete " \
"set, not a page of a larger one. create_order prices every line from " \
"these rows, so a cart built here totals to what it will actually cost."
input_schema type: "object", additionalProperties: false,
properties: {}, required: []
output_schema type: "array",
items: { type: "object",
properties: { sku: { type: "string" },
name: { type: "string" },
price_cents: { type: "integer",
description: "EUR cents." },
currency: { type: "string" } } }
example_params({})
example_row({ sku: "sourdough-bread", name: "Sourdough Bread",
price_cents: 449, currency: "eur" })
def catalog
rows = Product.where(stock: 1..).map do |p|
{ sku: p.sku, name: p.name, price_cents: p.price_cents, currency: "eur" }
end
render json: rows
end
# Delivery time slots. Each row carries the id create_order books, under the
# SAME name that action takes — so the assistant copies it straight through
# instead of guessing that your `id` is its `delivery_slot_id`.
kind :query
description "Get the delivery windows still bookable on a chosen day at a chosen " \
"address. Whichever window the human picks is booked as part of the order " \
"itself, so get their choice before ordering, not after. OMIT the date for " \
"the soonest day this operator can deliver — you cannot work that out " \
"yourself, because the day rolls over in the operator's locale and not in " \
"yours; send one only when your human named a day. An EMPTY array means " \
"every window on that day has already begun: ask for a later one. Get the " \
"REAL address from your human before calling — create_order needs the same " \
"address again, so an invented one books a delivery to nowhere."
input_schema type: "object", additionalProperties: false,
properties: {
date: { type: "string", format: "date",
description: "OPTIONAL. Delivery day, YYYY-MM-DD, read in " \
"YOUR own calendar when you declare it in " \
"`Kiosk-Timezone`. OMIT IT for the soonest day " \
"this operator can deliver. A day you have " \
"entirely finished is refused; every row says " \
"which day and which clock it is for." },
delivery_address: { type: "string",
description: "Where to deliver — the same address " \
"create_order will be given." },
},
required: ["delivery_address"]
output_schema type: "array",
items: { type: "object",
properties: { delivery_slot_id: { type: "integer" },
date: { type: "string", format: "date" },
slot_at: { type: "string", format: "date-time" },
label: { type: "string" },
timezone: { type: "string" } } }
# THE DAY IS RESOLVED, NOT WRITTEN DOWN. A `Proc` in a declaration is called
# lazily, memoized and re-resolved as the day rolls over, so a published
# example stays correct every morning with no deploy. A calendar literal here
# would be a `date` this very verb refuses, on a day nobody is watching.
example_params({ delivery_address: "42 Camden Street, Dublin 2",
date: -> { DeliverySlots.example_date.iso8601 } })
example_row({ delivery_slot_id: 3,
date: -> { DeliverySlots.example_date.iso8601 },
slot_at: -> { DeliverySlots.slot_at(DeliverySlots.example_date, 3).iso8601 },
label: "12:00-14:00 (Europe/Dublin)",
timezone: DeliverySlots::ZONE_NAME })
def delivery_slots
# ADDRESS-UPFRONT, asked BEFORE the date: it is what forces the assistant to
# obtain the address from its human before it can see any window at all, so
# the address that got slots is the address create_order will be handed.
address = params[:delivery_address].to_s
return reject_bad_request("missing param: delivery_address") if address.strip.empty?
# `date` IS OPTIONAL, AND OMITTING IT IS THE CORRECT CALL for "the soonest
# you can deliver". The caller cannot compute YOUR today: you deliver in
# your own locale, and for an hour either side of midnight the assistant's
# own date is a different day. Requiring the field makes that gap the
# CALLER's problem and gives it no way to solve one — it would have to trust
# a timezone named in a description string, carry tzdata, and still race the
# boundary. You know your own date, so use it. One step is enough: every
# window of a future day is still bookable, so the day after today always
# has slots.
unless params.key?(:date)
soonest = DeliverySlots.now.to_date
soonest += 1 if DeliverySlots.bookable_ids(soonest).empty?
return render json: slot_rows(soonest)
end
# A date that IS sent is validated exactly as before, past dates included:
# "deliver on Friday" is a different request from "deliver as soon as you
# can", and only the caller knows which one it is making. The wire has
# ALREADY checked this against `input_schema` above — it is a
# `format: "date"` string or the call never reached this method. What a
# schema cannot say is that the string names a real calendar day (a
# February 31st passes `format: "date"` under draft 2020-12), so the
# SEMANTIC check stays the handler's job.
date = DeliverySlots.iso_date(params[:date])
if date.nil?
# A malformed date is the agent's mistake, not your server's — answer a
# typed 400, never a Date::Error 500 that just points at your logs.
return reject_bad_request("invalid date: #{params[:date]} — use YYYY-MM-DD")
end
# A day already gone is outside this verb's DOMAIN, so it is refused by
# name rather than answered `200 []` — an empty list for a past date is
# byte-identical to the honest empty answer for today once the last window
# has begun, and the assistant cannot tell the two apart.
today = DeliverySlots.now.to_date
if date < today
return reject_bad_request("date #{date.iso8601} is in the past — " \
"this operator delivers from #{today.iso8601} onwards")
end
render json: slot_rows(date)
end
# The agent can check its own orders
kind :query
description "List this principal's own orders, newest first — the operator scopes them " \
"to the authenticated assistant account, so an assistant only ever sees its " \
"own. It is how an assistant finds the order reschedule_delivery should " \
"move, and how it re-reads where an order actually got to after a call whose " \
"response never arrived, instead of retrying blind."
input_schema type: "object", additionalProperties: false,
properties: {}, required: []
output_schema type: "array",
items: { type: "object",
properties: { order_id: { type: "string", format: "uuid" },
status: { type: "string" },
total_cents: { type: "integer",
description: "EUR cents." },
slot_at: { type: "string", format: "date-time" } } }
example_params({})
example_row({ order_id: "e2b1c0d4-5f6a-4b3c-8d2e-1f0a9b8c7d6e", status: "created",
total_cents: 1287,
slot_at: -> { DeliverySlots.slot_at(DeliverySlots.example_date, 3).iso8601 } })
def my_orders
# Name the fields rather than `map(&:attributes)`: what the wire publishes
# is a decision this handler makes, and `output_schema` above is the
# published statement of it. A blanket dump would put every future column
# on the wire the day it is added.
render json: Order.where(user_id: kiosk_identity.user_id)
.order(created_at: :desc)
.map { |o| { order_id: o.id, status: o.status,
total_cents: o.total_cents,
slot_at: o.slot_at.iso8601 } }
end
private
# ONE place that renders a slot row, because the date-supplied and the
# date-omitted paths must answer in exactly the same shape: a caller that
# omitted the date is not getting a lesser response, it is getting the same
# one for the day you picked. `date` on each row is what create_order books,
# so it is how that caller learns which day it got.
def slot_rows(date)
DeliverySlots.bookable_ids(date).map { |slot_id|
{ delivery_slot_id: slot_id,
date: date.iso8601,
slot_at: DeliverySlots.slot_at(date, slot_id).iso8601,
label: DeliverySlots.label(slot_id),
# EVERY ROW THAT CARRIES A WALL CLOCK SAYS WHICH CLOCK IT IS. Read off
# the thing being served, never off the origin — here one served area,
# so one value, and a second area is a second row in that map.
timezone: DeliverySlots::ZONE_NAME }
}
end
# The one guard idiom for this controller, same as the orders one next door.
def reject_bad_request(message)
render json: { error: { code: "bad_request", message: message } },
status: :bad_request
end
end
Step 5: Declare your actions
Actions mutate state — create orders, move a paid delivery, set up payment. They are the same shape as Step 4's queries, marked kind :action instead of kind :query. They go in a second controller here because orders and the catalogue are different things — not because they have to: one controller may declare both.
One of the actions below gates on the order being paid, and the receipts live in tables the Kiosk engine writes (kiosk.settlements, linked to the signed cart mandate it settled). Read them the Rails way — two small models over the engine's tables, no SQL by hand:
# app/models/kiosk_settlement.rb — a read window onto the capture receipts
# the Kiosk engine records after a successful /pay.
class KioskSettlement < ApplicationRecord
self.table_name = "kiosk.settlements"
belongs_to :cart_mandate, class_name: "KioskCartMandate"
end
# app/models/kiosk_cart_mandate.rb — the signed cart mandate a settlement
# points back at; its line_items mirror the order that was paid.
class KioskCartMandate < ApplicationRecord
self.table_name = "kiosk.cart_mandates"
end
app/controllers/kiosk/orders_controller.rb:
# app/controllers/kiosk/orders_controller.rb
class Kiosk::OrdersController < ApplicationController
include Kiosk::Handler
# The canonical 8-4-4-4-12 uuid shape your app hands out. Checked BEFORE an
# agent-supplied id is used, so a malformed one is answered as the agent's
# typed 400 — not whatever your database says to a bad uuid.
ORDER_ID_FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
# The widest total one order can carry: the width of PostgreSQL `integer`,
# which is the type `orders.total_cents` is declared with. THE BOUND IS THE
# COLUMN'S, not a basket size you invented — it refuses exactly the carts that
# cannot be REPRESENTED and leaves every one that can. `qty` gets the same
# width as a declared `maximum` below, because `order_items.qty` is the same
# column type; a different number reaches each of them, which is why both are
# here. See the guard in create_order.
MAX_TOTAL_CENTS = 2_147_483_647
# Check/setup saved payment card. A verb that takes nothing still declares the
# empty closed object — "this verb takes no arguments" then becomes a published
# fact instead of an absence the assistant has to interpret.
kind :action
description "Say whether this principal already has a card on file, or hand back a " \
"Stripe-hosted link for the human to add one. Nothing is charged and no " \
"card data touches your app. Call it before every pay on a new device or " \
"session, and again once the human says they are done. While one setup is " \
"outstanding this returns the SAME link, so relay that one rather than a " \
"fresh link per check. Poll on a bounded schedule — every ~5 seconds for " \
"the first minute, then every ~15 seconds, and give up after about 5 " \
"minutes: tell the human it is unfinished rather than polling forever."
input_schema type: "object", additionalProperties: false,
properties: {}, required: []
output_schema type: "object",
properties: { status: { type: "string",
enum: ["setup_required", "ready"] },
setup_url: { type: "string",
description: "Present only while setup is required." } }
example_params({})
example_row({ status: "setup_required",
setup_url: "https://checkout.stripe.com/c/pay/cs_test_..." })
def payment_setup
uid = kiosk_identity.user_id
provider = Kiosk.configuration.payment_provider
if provider.setup_required?(user_id: uid)
render json: { status: "setup_required", setup_url: provider.setup_url(user_id: uid) }
else
render json: { status: "ready" }
end
end
# Create an order. Delivery is PART of the order — the window and the address
# are inputs here, not a later step (getgrocery's shape: an assistant that
# cannot deliver has not finished shopping).
kind :action
description "Place an order for the authenticated principal and book its delivery in " \
"the same call — an order with nowhere and no time to go is not something " \
"this operator can fulfil, so get both from the human first. The order " \
"comes back UNPAID and nothing is charged here: paying is a separate step, " \
"with a cart mandate that mirrors this order. Once it is paid, " \
"reschedule_delivery moves the delivery without a second payment. A cart " \
"whose catalogue total is larger than this operator can put on one order " \
"is refused outright, naming the maximum, rather than partly taken."
input_schema type: "object", additionalProperties: false,
properties: {
items: {
type: "array", minItems: 1,
description: "The complete cart, one entry per product.",
items: {
type: "object", additionalProperties: false,
properties: {
sku: { type: "string", description: "A sku from the catalog query." },
# THE CEILING IS DECLARED, because a refusal the published
# schema does not predict is its own defect. `order_items.qty`
# is a PostgreSQL `integer`, so this is that column's own width
# and not an invented basket size: declare it and an oversized
# quantity is a schema 400 the assistant can correct, leave it
# out and the same value reaches `create!` and comes back as a
# 500 about your database.
qty: { type: "integer", minimum: 1, maximum: 2_147_483_647,
description: "How many of it. The order's total — each " \
"line's catalogue price times its qty, summed " \
"— is bounded too; a cart too large to price " \
"is refused, not partly taken." },
},
required: ["sku", "qty"],
},
},
delivery_slot_id: { type: "integer", minimum: 1, maximum: 6,
description: "The delivery_slot_id of a row delivery_slots returned." },
# `format: "date"` because the handler behind it is exactly
# that strict, and the two go together: an assistant reads the
# declaration as the contract, and the strictest of the two
# layers is what it actually meets. Declare a `format` when it
# is true, never as decoration — a keyword stricter than the
# handler makes the WIRE refuse values your own code accepts,
# and a handler stricter than the keyword refuses values you
# told the assistant to send.
#
# WHAT THE KEYWORD STILL CANNOT SAY, and why the handler
# checks anyway: `format: "date"` says the string is shaped
# like a calendar date, not that it names a real day (a
# 30th of February passes it) and not that the day is still
# bookable — a horizon that rolls forward every midnight is
# not expressible in a declaration. Both are
# `DeliverySlots.iso_date` and the two floors below.
delivery_date: { type: "string", format: "date",
description: "The date of that same delivery_slots row, YYYY-MM-DD. " \
"Optional; omitting it books tomorrow." },
delivery_address: { type: "string",
description: "Where to deliver it." },
},
required: ["items", "delivery_slot_id", "delivery_address"]
output_schema type: "object",
properties: { order_id: { type: "string", format: "uuid" },
total_cents: { type: "integer", description: "EUR cents." },
currency: { type: "string" },
slot_at: { type: "string", format: "date-time" } }
# Resolved rather than written down, for delivery_slots' reason: a literal
# here would publish a `delivery_date` this action refuses as past. `slot_at`
# derives from the SAME day and the slot id beside it, so the two examples
# cannot drift apart either.
example_params({
items: [{ sku: "sourdough-bread", qty: 2 }, { sku: "greek-yogurt", qty: 1 }],
delivery_slot_id: 3,
delivery_date: -> { DeliverySlots.example_date.iso8601 },
delivery_address: "42 Camden Street, Dublin 2",
})
example_row({ order_id: "e2b1c0d4-5f6a-4b3c-8d2e-1f0a9b8c7d6e", total_cents: 1287,
currency: "eur",
slot_at: -> { DeliverySlots.slot_at(DeliverySlots.example_date, 3).iso8601 } })
def create_order
# Wire input again — guard before use (see the delivery_slots note): typed
# 400s the assistant can correct, never a NoMethodError/Date::Error 500.
items = params[:items]
return reject_bad_request("items must be a non-empty array") unless items.is_a?(Array) && items.any?
lines = items.map do |item|
# SHAPE BEFORE CONTENT. `items` is declared as an array of OBJECTS, and
# the next line indexes one — so a well-formed request carrying the wrong
# shape (`items: ["bread"]`, which an assistant does write) would raise
# inside your handler and reach the agent as a 500 about your server. It
# is the agent's mistake and it deserves the agent's error: a typed 400
# naming the shape you wanted. `additionalProperties`/`required` in the
# schema above constrain the OBJECT's members; nothing there makes the
# element an object at all once the array itself validated.
return reject_bad_request("each item must be a {sku, qty} object — got " \
"#{item.class}; e.g. {\"sku\": \"sourdough-bread\", \"qty\": 2}") unless item.is_a?(Hash)
sku = item[:sku].to_s
return reject_bad_request("each item needs a sku") if sku.empty?
# `whole_number`, never `.to_i` — see the note beside it below.
qty = whole_number(item[:qty])
return reject_bad_request("qty must be a whole number >= 1 — got #{item[:qty].inspect}") if qty.nil?
return reject_bad_request("qty must be >= 1") if qty < 1
# An unknown sku is a bad cart line, not a missing page: answer the typed
# 400. (A find_by! would answer `not_found` — true, but useless to an
# assistant that needs to fix ONE line of its cart.)
product = Product.find_by(sku: sku)
return reject_bad_request("unknown sku: #{sku} — use skus the catalog query returned") if product.nil?
{ product: product, qty: qty }
end
delivery_address = params[:delivery_address].to_s
return reject_bad_request("missing param: delivery_address") if delivery_address.strip.empty?
slot_id = whole_number(params[:delivery_slot_id])
return reject_bad_request("delivery_slot_id must be a whole number 1-#{DeliverySlots::COUNT} — got #{params[:delivery_slot_id].inspect}") if slot_id.nil?
return reject_bad_request("delivery_slot_id must be 1-#{DeliverySlots::COUNT}") unless (1..DeliverySlots::COUNT).cover?(slot_id)
# `delivery_date` IS OPTIONAL, for delivery_slots' reason one verb over: the
# caller cannot compute YOUR today, so a blank one means TOMORROW in your
# locale — the same day `DeliverySlots.example_date` publishes, so an
# assistant that copies the example gets exactly what omitting the argument
# would have given it. Requiring it here while `delivery_slots` leaves its
# own `date` optional would make the two verbs disagree about the same
# question, which is the drift this page exists not to teach.
delivery_date = if params[:delivery_date].to_s.strip.empty?
DeliverySlots.now.to_date + 1
else
day = DeliverySlots.iso_date(params[:delivery_date])
if day.nil?
return reject_bad_request("invalid delivery_date: #{params[:delivery_date]} — use YYYY-MM-DD from the delivery_slots row you chose")
end
day
end
# A DAY THAT HAS GONE, AND A WINDOW THAT HAS BEGUN, ARE TWO REFUSALS — and
# this verb owes both even though `delivery_slots` above already hides
# both. The offer and the booking are separate calls minutes apart: nothing
# stops an assistant sending a day it read yesterday, or a window that
# started while its human was deciding. Skip these and you book a slot your
# own read verb would never have offered, and nobody finds out until a van
# does not arrive. The DAY first, because "that day is gone" is the more
# useful sentence than "that window has started" when both are true.
if delivery_date < DeliverySlots.now.to_date
return reject_bad_request("delivery_date is in the past: #{delivery_date.iso8601} — " \
"choose a current/future delivery slot")
end
slot_at = DeliverySlots.slot_at(delivery_date, slot_id)
if slot_at <= DeliverySlots.now
return reject_bad_request("delivery slot #{slot_id} on #{delivery_date.iso8601} has already " \
"started (#{slot_at.iso8601}) — choose a later slot; call " \
"delivery_slots again for the still-bookable windows")
end
# A CART NOBODY CAN PRICE IS A 400, NOT A 500 — and it is a SECOND bound,
# not a restatement of the `maximum` on `qty` above. Every quantity that
# declaration calls valid is a body the wire hands you, and the ORDER'S
# TOTAL is a different number: `price_cents * qty` summed over the cart
# passes `orders.total_cents` while every single line is still well inside
# its own ceiling. Leave it unguarded and `create!` raises
# ActiveModel::RangeError in RUBY, before any SQL — which reaches the
# assistant as a 500 about your server for an argument it simply got wrong.
#
# WHY THIS ONE IS NOT IN `input_schema`, where `qty`'s ceiling is: the bound
# is on a SUM of YOUR OWN catalogue prices, and no per-property JSON Schema
# keyword can express one. So the published contract splits — the schema
# declares the half it can, and the verb description above states this half
# in words, so no refusal is one your contract failed to predict. Ask it as
# soon as the prices are resolved and BEFORE anything is written.
total_cents = lines.sum { |l| l[:product].price_cents * l[:qty] }
if total_cents > MAX_TOTAL_CENTS
return reject_bad_request("this cart totals #{total_cents} cents, more than this " \
"operator can put on one order (max #{MAX_TOTAL_CENTS}) — " \
"order fewer units, or split the cart across several orders")
end
order = Order.create!(user_id: kiosk_identity.user_id, status: "created",
total_cents: total_cents,
slot_at: slot_at, address: delivery_address)
lines.each { |line| order.order_items.create!(product: line[:product], qty: line[:qty]) }
render json: { order_id: order.id, total_cents: order.total_cents, currency: "eur",
slot_at: slot_at.iso8601 }
end
# Move a paid order's delivery — gated on the order being PAID.
# There is no `payment_gated:` option. You gate inside the action by checking
# for a settlement: after a successful /pay, Kiosk records the capture in
# kiosk.settlements (linked to the signed cart mandate). Look it up and refuse
# if it's absent — the same settlement gate the getgrocery demo enforces on
# reschedule_delivery.
kind :action
description "Move an ALREADY-PAID order to a different delivery window, and optionally " \
"to a new address. The existing payment carries over: there is nothing new " \
"to sign and this does not charge again. An unpaid order cannot be moved " \
"this way — there is no payment to carry over, so place the order you " \
"wanted instead. One move per order; after that the human has to talk " \
"to the operator."
input_schema type: "object", additionalProperties: false,
properties: {
# `pattern` BESIDE `format`, and both deliberately. ORDER_ID_FORMAT
# above is a Ruby Regexp no declaration can name, so the same
# 8-4-4-4-12 shape is written out here in the language a schema
# speaks — which is what makes your handler's guard a PUBLISHED
# contract rather than a private one, and lets the wire refuse a
# malformed id before your handler is reached at all.
order_id: { type: "string", format: "uuid",
pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
description: "The order_id create_order returned (my_orders lists them too)." },
delivery_slot_id: { type: "integer", minimum: 1, maximum: 6,
description: "The delivery_slot_id of the new window." },
# Same declaration as create_order's, for the same reason.
delivery_date: { type: "string", format: "date",
description: "The date of that new window, YYYY-MM-DD. " \
"Optional; omitting it books tomorrow." },
delivery_address: { type: "string",
description: "A new address; the order keeps its current one when omitted." },
},
required: ["order_id", "delivery_slot_id"]
output_schema type: "object",
properties: { order_id: { type: "string", format: "uuid" },
rescheduled_at: { type: "string", format: "date-time" } }
# Resolved for the reason DeliverySlots.example_date gives.
example_params({ order_id: "e2b1c0d4-5f6a-4b3c-8d2e-1f0a9b8c7d6e",
delivery_slot_id: 5,
delivery_date: -> { DeliverySlots.example_date.iso8601 } })
example_row({ order_id: "e2b1c0d4-5f6a-4b3c-8d2e-1f0a9b8c7d6e",
rescheduled_at: -> { DeliverySlots.slot_at(DeliverySlots.example_date, 5).iso8601 } })
def reschedule_delivery
# This id names a row: check its shape first, so a malformed one is a
# typed 400 with the fix in the message — not a database error.
order_id = params[:order_id].to_s
return reject_bad_request("order_id #{order_id.inspect} is not a uuid — pass the order_id from my_orders or create_order") unless ORDER_ID_FORMAT.match?(order_id)
# Gate 1: order exists, belongs to this principal, not already moved.
order = Order.where.not(status: "rescheduled")
.find_by(id: order_id, user_id: kiosk_identity.user_id)
if order.nil?
return render json: { error: { code: "forbidden",
message: "order not found, not yours, or already rescheduled" } },
status: :forbidden
end
# Gate 2: a settlement (capture receipt) referencing this order must exist.
# The containment check (line_items @> [{order_id: …}]) is the load-bearing
# bit: it matches the exact order inside the signed cart mandate.
paid = KioskSettlement.joins(:cart_mandate)
.where(user_id: kiosk_identity.user_id)
.where("cart_mandates.line_items @> ?::jsonb",
[{ order_id: order_id }].to_json)
.exists?
unless paid
return render json: { error: { code: "forbidden",
message: "no settlement for this order — pay first" } },
status: :forbidden
end
# Same wire-input guards as create_order: a bad window is a typed 400.
slot_id = whole_number(params[:delivery_slot_id])
return reject_bad_request("delivery_slot_id must be a whole number 1-#{DeliverySlots::COUNT} — got #{params[:delivery_slot_id].inspect}") if slot_id.nil?
return reject_bad_request("delivery_slot_id must be 1-#{DeliverySlots::COUNT}") unless (1..DeliverySlots::COUNT).cover?(slot_id)
# Optional here too, and it means the same thing: blank books tomorrow.
new_date = if params[:delivery_date].to_s.strip.empty?
DeliverySlots.now.to_date + 1
else
day = DeliverySlots.iso_date(params[:delivery_date])
if day.nil?
return reject_bad_request("invalid delivery_date: #{params[:delivery_date]} — use YYYY-MM-DD")
end
day
end
# The SAME two refusals create_order takes, for the same reason: a move is
# a booking too, and moving a paid order onto a day that is gone or a
# window that has started is the one outcome nobody can undo without
# touching the money. Repeating the pair per verb rather than sharing a
# helper is this page's shape, not a recommendation — in your own app put
# them in one place and call it from both.
if new_date < DeliverySlots.now.to_date
return reject_bad_request("delivery_date is in the past: #{new_date.iso8601}")
end
slot_at = DeliverySlots.slot_at(new_date, slot_id)
if slot_at <= DeliverySlots.now
return reject_bad_request("delivery slot #{slot_id} on #{new_date.iso8601} has already " \
"started (#{slot_at.iso8601}) — choose a later slot; call " \
"delivery_slots again for the still-bookable windows")
end
order.update!(status: "rescheduled", slot_at: slot_at,
address: params[:delivery_address].to_s.presence || order.address)
render json: { order_id: order.id, rescheduled_at: slot_at.iso8601 }
end
private
# One guard idiom for the whole controller — helpers are something a handler
# in an initializer block could never share. The body names an in-vocabulary
# code that agrees with the status, so it reaches the assistant verbatim; the
# dispatch seam renders it as the RFC 9457 problem document the wire speaks
# ({"type", "title", "status", "detail", "code"}, code flat).
def reject_bad_request(message)
render json: { error: { code: "bad_request", message: message } },
status: :bad_request
end
# JSON Schema's `integer`, in Ruby — and NOTHING LOOSER. This is the whole of
# why the obvious `params[:delivery_slot_id].to_i` is not above.
#
# `.to_i` answers every String, Integer and Float, and it DISAGREES with the
# `{type: "integer", minimum: 1, maximum: 6}` declared in front of it:
# `"1.5".to_i` is 1, so a fractional slot comes out of that line INSIDE the
# declared range and is quietly booked as slot 1 rather than refused. Every
# other hostile shape collapses to 0 and the range check catches it, which is
# exactly what makes `.to_i` feel safe — the one value it gets wrong is the
# one nothing else is looking at. A guard that only holds while the layer in
# front of it holds is not a second layer at all.
#
# NOT `is_a?(Integer)` either, and the difference is measurable rather than
# academic: draft 2020-12 defines `integer` NUMERICALLY, not by wire type, so
# a JSON `2.0` IS a valid integer and your published schema accepts it. A bare
# class test would refuse a call your own contract allows. JSON parsing yields
# Integer or Float and nothing else, so those are the two cases; nil, booleans,
# strings, arrays, hashes and every fractional Float are not quantities.
def whole_number(raw)
return raw if raw.is_a?(Integer)
return nil unless raw.is_a?(Float) && raw.finite?
raw == raw.truncate ? raw.truncate : nil
end
end
Now put them on the wire
Both classes exist, so Step 2's c.handlers line finally has something to name. If you skipped it there, add it now — it is the line that turns two controllers into a served surface:
# config/initializers/kiosk.rb — the line from Step 2, now that both classes exist
Kiosk.configure do |c|
c.handlers = %w[Kiosk::CatalogController Kiosk::OrdersController]
end
Name them as strings, not constants: the list is re-resolved by name on every reload, and a constant written here is the boot generation of the class — stale the moment Rails reloads it. A name that does not resolve, or a class that includes neither mixin, fails the boot loudly rather than serving a silent half-catalog.
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's the AP2 iss anchor) — it is not derived from the request host, so set KIOSK_ISSUER to the URL agents will actually reach.
export KIOSK_ISSUER="http://localhost:3000"
export KIOSK_SIGNING_KEY_B64="$(openssl genrsa 2048 | base64)" # or KIOSK_SIGNING_KEY_PEM
export STRIPE_SECRET_KEY=sk_test_…
rails s
Verify discovery works and reports the issuer you configured, plus the capability list. capabilities names the modules your endpoint serves — schema, queries, actions, pay — never the verb names themselves. 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"] }
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 six documents, and the mount also draws the account-binding ceremony — so verify those directly, before you trust a green walkthrough:
# All six discovery documents, at the ORIGIN ROOT (not under /kiosk).
for p in /agents.txt /agents.json /auth.md \
/.well-known/kiosk.json /.well-known/agent-configuration /.well-known/api-catalog; do
printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000$p")" "$p"
done
# => 200 for all six. A 404 means the engine is bundled but NOT mounted: these
# routes are appended to your app only when Step 3's mount line is present.
# The account-binding ceremony is mounted too. Ask for it with no parameters and
# it must REFUSE — a 400 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
# => 400 (invalid_request: "client_id parameter required")
The catalog is public — GET /kiosk/schema takes no token and pays 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 obtain a token by registering. Registration is a proof-of-possession handshake: fetch a challenge for your public key, sign it as an RS256 JWS whose payload is {aud, nonce, jti} (aud = the origin you dialed, nonce = the challenge), then POST the public key + signature. A Kiosk-compatible agent does this for you, but here it is as a self-contained script (Ruby stdlib + the jwt gem — the same gem kiosk-server uses) so registration is followable without any external CLI:
# register.rb — PoP handshake against a local Kiosk provider. `gem install jwt`
require "openssl"; require "json"; require "net/http"; require "uri"
require "securerandom"; require "jwt"
origin = ENV.fetch("KIOSK_ISSUER", "http://localhost:3000") # the origin you dial == aud
key = OpenSSL::PKey::RSA.new(2048) # your identity keypair (save key.to_pem!)
pub = key.public_key.to_pem
# 1. GET a single-use challenge nonce for this public key.
chal = JSON.parse(Net::HTTP.get(URI(
"#{origin}/kiosk/auth/challenge?public_key=#{URI.encode_www_form_component(pub)}")))
# 2. Sign {aud, nonce, jti} as a compact RS256 JWS with the PRIVATE key.
signed = JWT.encode(
{ aud: origin, nonce: chal.fetch("challenge"), jti: SecureRandom.uuid },
key, "RS256")
# 3. POST public_key + signed → 201 Created { user_id, agent_id, access_token }.
res = Net::HTTP.post(URI("#{origin}/kiosk/auth/register"),
{ public_key: pub, signed: signed }.to_json, "Content-Type" => "application/json")
puts res.code # => 201
puts JSON.parse(res.body)["access_token"] # export this as $TOKEN
# (Returning key? Challenges are single-use — fetch a FRESH one, sign {aud, nonce, jti},
# POST to /kiosk/auth/login → 200 { access_token }. No new user_id: login just refreshes the token.)
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: Bearer $TOKEN" | jq '.[0]'
# => { "sku": "sourdough-bread", "name": "Sourdough Bread",
# "price_cents": 449, "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: Bearer $TOKEN" | jq '.[0]')
echo "$SLOT"
# => { "delivery_slot_id": 1, "date": "…", "slot_at": "…", "label": "08:00-10:00" }
# An action is a POST at its own name, with a JSON body. The window comes
# STRAIGHT from the row above — never a day you typed here, which ages into a
# 400 the morning after you wrote it.
curl -s -X POST http://localhost:3000/kiosk/create_order \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "$(jq -n --argjson slot "$SLOT" \
'{items: [{sku: "sourdough-bread", qty: 2}],
delivery_slot_id: $slot.delivery_slot_id,
delivery_date: $slot.date,
delivery_address: "42 Camden Street, Dublin 2"}')"
Got an empty catalog back? Your controllers are not named inc.handlers— Step 2's line, repeated at the end of Step 5. 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) is a405 method_not_allowedthat tells you which method to use — a different fact from "no such verb", which is a404. Add-ito any call above to see both.
The full register → order → pay flow (including the 3 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 scripted end-to-end, runrake demoin getgrocery. Thepayleg additionally needs a realsk_test_…Stripe key and a saved card on file.
Running a demo needs two things this integration does not. Every demo setsc.registration_pow_count = 1, so each assistant it registers pays an Equihash toll that the bundled solver solves — that solver is Python, so the demo tasks need python3 with numpy on PATH (python3 -c "import numpy"). And each demo'sdemo:setupcreates theapp_rolegroup role throughpsql, so it needs a Postgres login permitted toCREATE ROLE— managed Postgres usually refuses.demo:setupis also destructive: it drops and recreates that demo's database every time it runs. Your own integration needs none of this: the engine's registration toll defaults to zero andapp_roleis opt-in, so verifying a proof is pure Ruby and nothing here asks for a role grant. 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 six machine-readable documents agents actually read — /agents.txt, /agents.json, /auth.md and the /.well-known/ trio — 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 controller:
response.set_header("Link", %(<#{Kiosk.configuration.skill_url}>; rel="kiosk"))
A HEAD request is enough — no page download needed.
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.4.14.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 and adopting a newer cut is one edit inconfig/initializers/kiosk.rb—skill_urlandskill_sha256together — rather than a hunt through your views.
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 agent users that your store speaks Kiosk without distracting regular customers:
<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 or binding — one of two, not always the first: a new assistant account is proof-of-possession (
GET /kiosk/auth/challenge→ sign →POST /kiosk/auth/register) →access_token, and a returning key refreshes atPOST /kiosk/auth/login. When the human already has an account with you, the assistant binds to it instead: 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/claim. Both end in a token; 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?date=…→ 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 3 JWS mandates,
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 user. 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 has driven this loop end to end against a hosted demo, on the 0.3 wire; the 0.4 surface has not been re-verified with a live assistant — so treat the first run as a test of your integration and of the claim.)
- Add more queries. Expose anything an agent might need — store locations, nutritional info, allergy filters. A new verb in a controller you already named needs nothing else; a new controller goes in
c.handlers. - 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 wire 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 two halves of the wire file, 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, thec.handlersline that puts them on the wire, the dispatch mechanics, and the error seam - kiosk-demo-getgrocery — the full runnable provider this guide is based on; its
script/getgrocery_flow.rb(rake demo) drives the entire register → order → pay flow end-to-end over plainNet::HTTP, printing one JSON line the rake task asserts on. Read it as the runnable original rather than as a copy of the code above: its own handler classes are named for its domain —Kiosk::StorefrontControllercarries the read half, not theKiosk::CatalogControllerthe generator's example names — and it factors the argument guards this page inlines into one sharedWireArgumentsmodule. Same shape, different names, a different factoring.