← kiosk.tech

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=uuid and emits nine kiosk migrations, baking uuid into every one that touches a user id (the kiosk.agents.user_id FK and the mandate tables that reference it), and the stripe_customers table below uses t.uuid :user_id. If your users.id is a Rails-default bigint, don't just edit the stripe_customers column — regenerate the kiosk migrations with rails generate kiosk:install --user-id-type=bigint (so every kiosk table matches), set c.user_id_type = :bigint, and make the stripe_customers.user_id column t.bigint to 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 the register.rb script above; to watch the whole flow scripted end-to-end, run getgrocery's bin/demo (rake demo). The pay leg additionally needs a real sk_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:

  1. Discovery: GET /.well-known/kiosk.json → finds your endpoint
  2. Registration: generates RSA key, proves possession (GET /auth/challenge → sign → POST /auth/register) → gets access_token; returning keys refresh via /auth/login
  3. Browse: POST /query {name:"catalog"} → sees your products
  4. Order: POST /run {name:"create_order", …} → order created
  5. Card setup: POST /run {name:"payment_setup"} → human enters card once on Stripe
  6. Pay: agent signs 3 JWS mandates, POST /pay → payment settled
  7. 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


Reference