Merchant onboarding — 20 minutes to agent-ready
A step-by-step guide for adding Kiosk to an existing Rails app, based on the getgrocery demo provider.
If you have a working Rails + Postgres app with customers and products, this guide gets you from zero to agent-ready in ~20 minutes.
Step 1: Add the gems (2 min)
# 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 # migration + initializer
rails db:migrate
Step 2: Configure Kiosk (3 min)
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
c.issuer = ENV.fetch("KIOSK_ISSUER", "https://your-domain.com")
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=uuidand emits nine kiosk migrations, bakinguuidinto every one that touches a user id (thekiosk.agents.user_idFK and the mandate tables that reference it), and thestripe_customerstable below usest.uuid :user_id. 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: Mount the routes (1 min)
config/routes.rb:
require "json"
Rails.application.routes.draw do
# Kiosk wire surface — controllers shipped by kiosk-server (WireController).
get "/kiosk/schema", to: "kiosk/server/wire#schema"
post "/kiosk/query", to: "kiosk/server/wire#query"
post "/kiosk/run", to: "kiosk/server/wire#run"
post "/kiosk/pay", to: "kiosk/server/wire#pay"
# Proof-of-possession auth surface.
get "/kiosk/auth/challenge", to: "kiosk/server/auth#challenge"
post "/kiosk/auth/register", to: "kiosk/server/auth#register"
post "/kiosk/auth/login", to: "kiosk/server/auth#login"
post "/kiosk/auth/revoke", to: "kiosk/server/auth#revoke"
# JWKS — public keys agents use to verify Kiosk-issued JWTs.
get "/kiosk/.well-known/jwks.json", to: "kiosk/server/jwks#show"
# Discovery document, built on the fly from Kiosk.configuration.
# kiosk-server doesn't ship a controller for it yet, so mount a small
# Rack lambda (this is exactly what the demos do).
get "/.well-known/kiosk.json", to: ->(env) {
base_url = "#{env['rack.url_scheme']}://#{env['HTTP_HOST']}"
[200, { "content-type" => "application/json" },
[Kiosk::Server::WellKnown.build_json(base_url: base_url)]]
}
end
Step 4: Register your queries (5 min)
Queries are read-only data access. Agents use them to browse your catalog.
config/initializers/kiosk_queries.rb:
# What can the agent browse?
Kiosk::Server::Queries.register("catalog",
description: "Browse in-stock products") do |_params|
Product.where("stock > 0").select(:sku, :name, :price_cents).map(&:attributes)
end
# Delivery time slots
Kiosk::Server::Queries.register("delivery_slots",
description: "Available delivery time slots for a given date",
params: { date: "YYYY-MM-DD" }) do |params|
date = Date.parse(params[:date])
(8..20).step(2).map do |hour|
{ slot_at: Time.utc(date.year, date.month, date.day, hour, 0).iso8601,
label: "#{hour}:00–#{hour + 2}:00" }
end
end
# The agent can check its own orders
Kiosk::Server::Queries.register("my_orders",
description: "List this customer's orders") do |_params|
Order.where(user_id: ActiveRecord::Base.connection.execute(
"SELECT kiosk.current_user_id() AS uid"
).first["uid"]).order(created_at: :desc).map(&:attributes)
end
Step 5: Register your actions (5 min)
Actions mutate state — create orders, schedule delivery, set up payment.
config/initializers/kiosk_actions.rb:
# Check/setup saved payment card
Kiosk::Server::Actions.register("payment_setup",
description: "Check if the customer has a saved card on file",
params: {}) do |_args|
uid = current_user_id
provider = Kiosk.configuration.payment_provider
if provider.setup_required?(user_id: uid)
{ status: "setup_required", setup_url: provider.setup_url(user_id: uid) }
else
{ status: "ready" }
end
end
# Create an order
Kiosk::Server::Actions.register("create_order",
description: "Create a new order",
params: { items: "array of {sku, qty}" }) do |args|
uid = current_user_id
items = args[:items].map { |i| { sku: i[:sku], qty: i[:qty].to_i } }
total_cents = items.sum do |item|
Product.find_by!(sku: item[:sku]).price_cents * item[:qty]
end
order = Order.create!(user_id: uid, status: "created", total_cents: total_cents)
items.each do |item|
product = Product.find_by!(sku: item[:sku])
order.order_items.create!(product: product, qty: item[:qty])
end
{ order_id: order.id, total_cents: total_cents }
end
# Schedule delivery — gated on the order being PAID.
# There is no `payment_gated:` option. You gate inside the block 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 — exactly how the getgrocery demo gates schedule_delivery.
Kiosk::Server::Actions.register("schedule_delivery",
description: "Schedule delivery for a paid order",
params: { order_id: "uuid", delivery_slot_id: "integer", delivery_address: "string" }) do |args|
conn = ActiveRecord::Base.connection
order_id = args[:order_id].to_s
# Gate 1: order exists, belongs to this principal, not already scheduled.
order = conn.execute(
"SELECT id FROM orders " \
"WHERE id = #{conn.quote(order_id)}::uuid " \
"AND user_id = kiosk.current_user_id() " \
"AND status <> 'scheduled' LIMIT 1"
).first
raise Kiosk::Server::Errors::Forbidden.new("order not found, not yours, or already scheduled") if order.nil?
# Gate 2: a settlement (capture receipt) referencing this order must exist.
order_filter = [{ order_id: order_id }].to_json
paid = conn.execute(
"SELECT 1 FROM kiosk.settlements pm " \
"JOIN kiosk.cart_mandates cm ON cm.id = pm.cart_mandate_id " \
"WHERE pm.user_id = kiosk.current_user_id() " \
"AND cm.line_items @> #{conn.quote(order_filter)}::jsonb LIMIT 1"
).first
raise Kiosk::Server::Errors::Forbidden.new("no settlement for this order — pay first") if paid.nil?
slot_id = args[:delivery_slot_id].to_i
hour = 8 + (slot_id - 1) * 2
slot_at = Time.utc(Date.today.year, Date.today.month, Date.today.day + 1, hour, 0)
conn.execute(
"UPDATE orders SET status = 'scheduled', " \
"slot_at = #{conn.quote(slot_at.iso8601)}::timestamptz, " \
"address = #{conn.quote(args[:delivery_address].to_s)}, updated_at = now() " \
"WHERE id = #{conn.quote(order_id)}::uuid AND user_id = kiosk.current_user_id()"
)
{ order_id: order_id, scheduled_at: slot_at.iso8601 }
end
def current_user_id
ActiveRecord::Base.connection.execute(
"SELECT kiosk.current_user_id() AS uid"
).first["uid"]
end
Step 6: Seed some data (2 min)
# db/seeds.rb
Product.create!(sku: "milk-1l", name: "Whole Milk 1L", price_cents: 199, stock: 50)
Product.create!(sku: "bread-ww", name: "Whole Wheat Bread", price_cents: 299, stock: 30)
Product.create!(sku: "eggs-12", name: "Free-Range Eggs 12-pack", price_cents: 449, stock: 20)
Product.create!(sku: "butter-250g", name: "Salted Butter 250g", price_cents: 349, stock: 15)
# … your actual product catalog
rails db:seed
Step 7: Start the server and verify (2 min)
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 (the verbs your endpoint serves — schema, query, run, pay):
curl -s http://localhost:3000/.well-known/kiosk.json | jq '.kiosk | {issuer, capabilities}'
# => { "issuer": "http://localhost:3000", "capabilities": ["schema","query","run","pay"] }
Every wire verb — including schema — is authenticated, so 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 browse the catalog — nothing but curl + jq:
# Inspect the machine-readable surface (your registered queries + actions):
curl -s http://localhost:3000/kiosk/schema \
-H "Authorization: Bearer $TOKEN" | jq '.value.queries | map(.name)'
# => ["catalog","delivery_slots","my_orders"]
# Browse the catalog:
curl -s -X POST http://localhost:3000/kiosk/query \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"catalog"}' | jq '.rows'
The full register → order → pay → schedule 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, run getgrocery'sbin/demo(rake demo). Thepayleg additionally needs a realsk_test_…Stripe key and a saved card on file.
How agents find you (add 3 hooks, invisible to humans)
Agents need to discover that your site speaks Kiosk. Three hooks — two machine-readable, one 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="https://kiosk.tech/skill.md">
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", '<https://kiosk.tech/skill.md>; rel="kiosk"')
A HEAD request is enough — no page download needed.
3. Visual "Agents — over here" card (human-readable, subtle)
Add a small section at the bottom of your homepage. 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 — it's at the bottom, below the product categories. Regular users scroll past it. Agent users know where to look.
What the agent experience looks like
From the agent's perspective, after these 7 steps:
- Discovery:
GET /.well-known/kiosk.json→ finds your endpoint - Registration: generates RSA key, proves possession (
GET /auth/challenge→ sign →POST /auth/register) → getsaccess_token; returning keys refresh via/auth/login - Browse:
POST /query {name:"catalog"}→ sees your products - Order:
POST /run {name:"create_order", …}→ order created - Card setup:
POST /run {name:"payment_setup"}→ human enters card once on Stripe - Pay: agent signs 3 JWS mandates,
POST /pay→ payment settled - Schedule:
POST /run {name:"schedule_delivery", …}→ delivery booked
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 Hermes (or any Kiosk-compatible agent) at your local server and say "order groceries."
- Add more queries. Expose anything an agent might need — store locations, nutritional info, allergy filters.
- 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
- github.com/kiosk-hq/kiosk — OSS reference implementation
- kiosk-demo-getgrocery — the full runnable provider this guide is based on; its
getgrocery_flow.rbandbin/demo(rake demo) drive the entire register → order → pay → schedule flow end-to-end with curl output