Skip to content

Sinatra

This guide adds Bunkhouse to a server-rendered Sinatra application. Every file shown below is part of a runnable example app that is tested end to end against the real Bunkhouse API and embed.

Framework Sinatra 4.2.1 (modular Sinatra::Base app), ERB views, Rack::Session::Cookie sessions and Rack::Protection 4.2.1, served by Puma 8.0.2
Runtime Ruby 3.4 (3.4.12 in the tested image)
Example apps/docs/integrations/sinatra/ in the Bunkhouse repository

You need:

  • Your application’s exact origin registered on the site (for example https://app.example.com).
  • The site’s public key (pk_...).
  • For identity tokens (recommended): a signing secret (ss_...) and its key ID from the site’s Keys page.
  • For server mint instead: a mint key (sk_...).
  • Existing sign-in. The example keeps a user ID in Sinatra’s cookie session for two demo users; your own authentication takes its place. Bunkhouse only needs one thing from it: the signed-in user’s stable ID on the server.
File Purpose
config.rb Validates the public Bunkhouse configuration at startup
agent.rb Sinatra extension with GET /api/agent-identity and POST /api/agent-session
csp.rb Your Content-Security-Policy with the Bunkhouse sources added
views/agent_embed_*.erb The script tags for one installation mode
public/agent-identity.js, public/agent-auth-headers.js Page hooks, loaded before the loader

Set the server environment. Only AGENT_PUBLIC_KEY and the origins ever reach the browser:

Terminal window
AGENT_PUBLIC_KEY=pk_...
AGENT_SIGNING_SECRET=ss_... # server only
AGENT_SIGNING_KID=... # key ID shown with the signing secret
AGENT_ALLOWED_ORIGINS='["https://app.example.com"]'
# AGENT_API_BASE and AGENT_EMBED_ORIGIN default to https://api.bunkhouse.ai and https://embed.bunkhouse.ai
config.rb
# Bunkhouse public configuration, validated once at startup. Signing and mint credentials are read
# only by the endpoints that use them, so each mode needs only its own secret.
require "json"
require "uri"
module AgentConfig
def self.exact_origin(name, value)
uri = URI.parse(value) rescue nil
return value if uri.is_a?(URI::HTTP) && uri.origin == value
raise ArgumentError, "#{name} must be an exact origin like https://app.example.com"
end
PUBLIC_KEY = ENV.fetch("AGENT_PUBLIC_KEY", "")
raise ArgumentError, "AGENT_PUBLIC_KEY must be the site's public key (pk_...)" unless PUBLIC_KEY.start_with?("pk_")
origins = JSON.parse(ENV.fetch("AGENT_ALLOWED_ORIGINS", "[]"))
unless origins.is_a?(Array) && !origins.empty?
raise ArgumentError, 'AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]'
end
ALLOWED_ORIGINS = origins.map { |o| exact_origin("AGENT_ALLOWED_ORIGINS", o.to_s) }.freeze
API_BASE = exact_origin("AGENT_API_BASE", ENV.fetch("AGENT_API_BASE", "https://api.bunkhouse.ai"))
EMBED_ORIGIN = exact_origin("AGENT_EMBED_ORIGIN", ENV.fetch("AGENT_EMBED_ORIGIN", "https://embed.bunkhouse.ai"))
end

The extension signs a ten-minute HS256 token with Ruby’s OpenSSL::HMAC for the user your session already authenticated. A before filter on each route rejects every other method, including TRACE and OPTIONS, with 405 and an Allow header. The route itself rejects requests whose Sec-Fetch-Site is not same-origin (403) and signed-out users (401, never a login redirect). Every response is marked Cache-Control: no-store. The subject comes only from the server session, never from the query, body or headers. The same file contains the server mint endpoint used by the alternative below.

agent.rb
# Bunkhouse endpoints as a Sinatra extension: GET /api/agent-identity (identity token mode) and
# POST /api/agent-session (server mint mode). Both vouch only for the user your existing session
# already authenticated: the app defines a `current_user` helper returning { id: ... } or nil.
require "json"
require "net/http"
require "openssl"
require "sinatra/base"
require_relative "config"
module AgentEmbed
IDENTITY_PATH = "/api/agent-identity"
SESSION_PATH = "/api/agent-session"
# Rack::Protection's CSRF token check must not run on the mint route: the embed's POST carries no
# token (its Origin, X-Agent-Embed and Fetch Metadata checks replace it).
CSRF_EXEMPT = ->(env) { env["PATH_INFO"] == SESSION_PATH }
# base64url without padding
def self.base64url(bytes) = [bytes].pack("m0").tr("+/", "-_").delete("=")
# HS256 identity token: kid header, sub = stable opaque user ID, aud = public key, 10 minutes.
def self.sign_identity_token(user_id, secret, kid)
iat = Time.now.to_i
header = base64url(JSON.generate({ alg: "HS256", typ: "JWT", kid: kid }))
payload = base64url(JSON.generate({ sub: user_id, aud: AgentConfig::PUBLIC_KEY, iat: iat, exp: iat + 600 }))
signature = base64url(OpenSSL::HMAC.digest("SHA256", secret, "#{header}.#{payload}"))
"#{header}.#{payload}.#{signature}"
end
module Helpers
def agent_error(code, error) = halt(code, JSON.generate({ error: error }))
def not_configured(variable)
warn "Bunkhouse: #{variable} is not set"
agent_error 503, "not_configured"
end
end
def self.registered(app)
app.helpers Helpers
{ IDENTITY_PATH => "GET", SESSION_PATH => "POST" }.each do |path, allowed|
app.before(path) do
cache_control :no_store
content_type :json
unless (request.head? ? "GET" : request.request_method) == allowed
headers "Allow" => allowed
agent_error 405, "method_not_allowed"
end
end
end
app.get IDENTITY_PATH do
agent_error 403, "forbidden" unless request.env["HTTP_SEC_FETCH_SITE"] == "same-origin"
user = current_user or agent_error(401, "unauthorized")
secret = ENV["AGENT_SIGNING_SECRET"].to_s
kid = ENV["AGENT_SIGNING_KID"].to_s
not_configured "AGENT_SIGNING_SECRET" if secret.empty?
not_configured "AGENT_SIGNING_KID" if kid.empty?
JSON.generate({ identityToken: AgentEmbed.sign_identity_token(user[:id].to_s, secret, kid) })
end
app.post SESSION_PATH do
origin = request.env["HTTP_ORIGIN"]
unless AgentConfig::ALLOWED_ORIGINS.include?(origin) &&
request.env["HTTP_X_AGENT_EMBED"] == "1" &&
request.env["HTTP_SEC_FETCH_SITE"] == "same-origin"
agent_error 403, "forbidden"
end
user = current_user or agent_error(401, "unauthorized")
key = ENV["AGENT_EMBED_SECRET_KEY"].to_s
not_configured "AGENT_EMBED_SECRET_KEY" if key.empty?
uri = URI("#{AgentConfig::API_BASE}/v1/embed/sessions")
body = JSON.generate({ publicKey: AgentConfig::PUBLIC_KEY, mode: "signed", userId: user[:id].to_s, origin: origin })
begin
upstream = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 15) do |http|
http.post(uri.path, body, "Authorization" => "Bearer #{key}", "Content-Type" => "application/json")
end
rescue StandardError
agent_error 502, "session_service_unavailable"
end
headers "Content-Type" => upstream["content-type"] || "application/json"
[upstream.code.to_i, upstream.body]
end
end
end

Register it in your application after enabling sessions. The current_user helper is the seam to your authentication: return the signed-in user (with an :id), or nil.

app.rb (excerpt)
helpers do
# The signed-in user of this request (your existing authentication), or nil.
def current_user = USERS.values.find { |user| user[:id] == session[:user_id] }
end
# Bunkhouse endpoints; they read the user through current_user.
register AgentEmbed

Rack::Protection stays on. The example adds its authenticity-token check for every unsafe request and exempts only the Bunkhouse mint route: the embed’s mint POST carries no CSRF token (its Origin, X-Agent-Embed and Fetch Metadata checks are that route’s protection). The identity route keeps the check, so a wrong-method POST may get Rack::Protection’s 403 instead of the 405, which is fine:

app.rb (excerpt)
# Rack::Protection's defaults plus its CSRF token check (authenticity_token field or X-CSRF-Token
# header) on every unsafe request, answering 403. Only the Bunkhouse mint route is exempt.
set :protection, use: %i[authenticity_token], reaction: :deny, allow_if: AgentEmbed::CSRF_EXEMPT

window.AgentEmbedConfig must be defined before the loader. In the layout’s head, load the identity hook, then the auth-headers hook (see Auth headers), then the loader, all before your application’s own scripts. The hooks are external same-origin scripts, so no inline script needs a CSP exception:

views/agent_embed_identity.erb
<%# Identity token mode (recommended): both page hooks, then the loader, before the app's scripts. %>
<script src="/agent-identity.js" nonce="<%= csp_nonce %>"></script>
<script src="/agent-auth-headers.js" nonce="<%= csp_nonce %>"></script>
<script src="<%= h AgentConfig::EMBED_ORIGIN %>/embed.js" nonce="<%= csp_nonce %>"
data-public-key="<%= h AgentConfig::PUBLIC_KEY %>" data-hints-url="/agent-hints.json"></script>
public/agent-identity.js
// Identity token mode: the embed calls this hook whenever it starts a session for the signed-in user.
window.AgentEmbedConfig = {
...window.AgentEmbedConfig,
getIdentityToken: async () => {
const res = await fetch("/api/agent-identity", { credentials: "same-origin", cache: "no-store" });
if (!res.ok) throw new Error("Not signed in");
return (await res.json()).identityToken;
},
};
views/layout.erb (excerpt)
<% if current_user %><meta name="csrf-token" content="<%= h csrf_token %>" /><% end %>
<%# Page hooks and the loader for one installation mode. %>
<%= erb :"agent_embed_#{mode}", layout: false %>
<!-- The application's own JavaScript comes after the loader. -->
<script src="/app.js" nonce="<%= csp_nonce %>" defer></script>

The example layout renders one of the three agent_embed_* partials, selected by a test-only ?mode= switch. Each partial holds that mode’s page hooks and loader. Your application uses exactly one.

Instead of the identity hook, add data-session-endpoint to the loader; the auth-headers hook still comes first. Do not keep agent-identity.js on the page: when getIdentityToken is defined it takes precedence.

views/agent_embed_mint.erb
<%# Server mint mode: no identity hook; the embed posts to the same-origin session endpoint. %>
<script src="/agent-auth-headers.js" nonce="<%= csp_nonce %>"></script>
<script src="<%= h AgentConfig::EMBED_ORIGIN %>/embed.js" nonce="<%= csp_nonce %>"
data-public-key="<%= h AgentConfig::PUBLIC_KEY %>" data-hints-url="/agent-hints.json"
data-session-endpoint="/api/agent-session"></script>

POST /api/agent-session in agent.rb above accepts only requests with an Origin from AGENT_ALLOWED_ORIGINS, X-Agent-Embed: 1 and Sec-Fetch-Site: same-origin (403 otherwise), authenticates the session (401), then creates the session with the mint key over Net::HTTP and returns the API’s status and body unchanged with Cache-Control: no-store. If the API is unreachable it returns 502 without details. Set AGENT_EMBED_SECRET_KEY=sk_... on the server; the identity endpoint does not need it.

Rack::Protection’s authenticity-token check would reject this POST, which is why AgentEmbed::CSRF_EXEMPT excludes it; every other unsafe request in the app still needs the token.

Anonymous sessions need no identity hook or session endpoint: only the loader, after the auth-headers hook your API uses. Enable anonymous access for the site in the dashboard first and set its monthly token and spend limits; usage is charged to your account’s credit and stops at a limit. Anyone who can load your page can start one, so it is not proof of identity.

views/agent_embed_anonymous.erb
<%# Anonymous mode (enable it for the site first): no identity hook; the auth-headers hook, then the loader. %>
<script src="/agent-auth-headers.js" nonce="<%= csp_nonce %>"></script>
<script src="<%= h AgentConfig::EMBED_ORIGIN %>/embed.js" nonce="<%= csp_nonce %>"
data-public-key="<%= h AgentConfig::PUBLIC_KEY %>" data-hints-url="/agent-hints.json"></script>

The example signs in and out with ordinary form posts (carrying Rack::Protection’s authenticity token) that redirect back to the same page. Every account change therefore loads a new document, and a new document starts a new embed with no session, so the previous user’s session can never carry over. Sinatra’s session cookie is HttpOnly and SameSite, and login clears the session and renews it before storing the new user ID:

app.rb (excerpt)
set :session_secret, SESSION_SECRET
# Secure everywhere except the loopback HTTP test origin.
set :sessions, key: "example.session", httponly: true, same_site: :lax, secure: APP_ORIGIN.start_with?("https://")

If your Sinatra app changes the signed-in user without a full page load (a fetch login, a single-page frontend), call window.AgentEmbed?.resetSession() right after the login, logout or account switch succeeds and before the new user’s requests. Render the loader once in views/layout.erb, not in per-page templates.

The assistant calls your API with the page’s cookies; supply any extra header your API requires with getAuthHeaders. The example uses Rack::Protection’s own CSRF token: the layout renders Rack::Protection::AuthenticityToken.token(session) into a <meta name="csrf-token"> tag for signed-in users, and the middleware checks the X-CSRF-Token header on every write:

public/agent-auth-headers.js
// The app's own CSRF token (rendered by the server for the signed-in session) for the assistant's calls
// to this app's API. Values never reach the model or the sandbox.
window.AgentEmbedConfig = {
...window.AgentEmbedConfig,
getAuthHeaders: () => {
const token = document.querySelector('meta[name="csrf-token"]')?.content;
return token ? { "X-CSRF-Token": token } : {};
},
};

The hook merges into window.AgentEmbedConfig (so its order relative to the identity hook doesn’t matter) and loads before the loader in every mode. Its values never reach the model or the sandbox.

Send a stable, opaque user ID (the example’s fixture-alice), not a name or email address.

Add the Bunkhouse sources to your existing policy rather than replacing it. The example sets a fresh nonce per response in a before filter and puts it on its script tags; no 'unsafe-inline' or 'unsafe-eval' is needed:

csp.rb
# The app's existing Content-Security-Policy with the Bunkhouse sources added: the embed origin to
# script-src and frame-src, the API to connect-src. A fresh nonce per response (csp_nonce) goes on the
# page's script tags.
require "securerandom"
require "sinatra/base"
require_relative "config"
module ContentSecurityPolicy
def self.registered(app)
app.helpers { attr_reader :csp_nonce }
app.before do
@csp_nonce = SecureRandom.base64(16)
headers "Content-Security-Policy" => [
"default-src 'self'",
"script-src 'self' 'nonce-#{@csp_nonce}' #{AgentConfig::EMBED_ORIGIN}",
"style-src 'self' 'nonce-#{@csp_nonce}'",
"frame-src #{AgentConfig::EMBED_ORIGIN}",
"connect-src 'self' #{AgentConfig::API_BASE}",
"img-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
].join("; ")
end
end
end

From a checkout of the Bunkhouse repository, with Docker and local Supabase running:

Terminal window
node tests/framework-integrations/run.ts sinatra

The run builds the example image, starts it against a fresh local site and drives the real pane in Chromium: identity, mint and anonymous sessions, endpoint guards, sign-out and account switches, discovery of the page’s startup request, a CSRF-protected write through getAuthHeaders, CSP and secret checks. In your own app, sign in and open the Assistant launcher: the first message creates a signed session.

Symptom Check
/api/agent-identity returns 401 The session cookie is not sent (for example Secure on plain HTTP, or a different session_secret across processes), or current_user does not find the user.
403 from either endpoint The request is cross-site or missing Sec-Fetch-Site; for mint, Origin must exactly match AGENT_ALLOWED_ORIGINS and the loader must carry data-session-endpoint. A plain-text Forbidden comes from Rack::Protection: pass allow_if: AgentEmbed::CSRF_EXEMPT in your :protection setting.
503 The signing secret, key ID or mint key for the mode you use is not set.
The pane reports a CSP block Add the embed origin to script-src and frame-src and the API to connect-src.
Startup requests are not discovered The loader must come before your application’s scripts in the head.
Two launchers Render the loader once, in the layout.
A secret in page source Only AGENT_PUBLIC_KEY and origins belong in views; keep ss_ and sk_ values in server environment variables.