Skip to content

Ruby on Rails

This guide adds Bunkhouse to a server-rendered Rails application that navigates with Turbo Drive. 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 Ruby on Rails 8.1.4 in production mode: ERB views, Turbo Drive 8.0.23 (turbo-rails 2.0.23), importmap-rails 2.2, Propshaft, Puma 8, the Rails authentication generator’s sessions
Runtime Ruby 3.4 (3.4.12 in the tested image)
Example apps/docs/integrations/rails/ 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 uses the code from bin/rails generate authentication (Current.user from a signed session cookie) with 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/initializers/agent_embed.rb Validates the public Bunkhouse configuration at boot
app/controllers/agent_controller.rb GET /api/agent-identity and POST /api/agent-session
config/routes.rb Routes for both endpoints, 405 for other methods
config/initializers/content_security_policy.rb Your Content-Security-Policy with the Bunkhouse sources added
app/views/agent_embed/_*.html.erb The script tags for one installation mode
app/assets/javascripts/agent_identity.js, agent_auth_headers.js Page hooks, loaded before the loader
app/javascript/agent_embed_turbo.js Resets the assistant on account changes

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/initializers/agent_embed.rb
# Bunkhouse public configuration, validated at boot. Signing and mint credentials are read only by the
# endpoints that use them, so each mode needs only its own secret.
exact_origin = lambda do |name, value|
uri = URI.parse(value)
origin = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != uri.default_port}"
raise ArgumentError unless %w[http https].include?(uri.scheme) && uri.host && origin == value
value
rescue URI::InvalidURIError, ArgumentError
raise "#{name} must be an exact origin like https://app.example.com"
end
public_key = ENV.fetch("AGENT_PUBLIC_KEY", "")
raise "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", "[]"))
raise %(AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]) unless origins.is_a?(Array) && origins.any?
Rails.application.config.x.agent = ActiveSupport::InheritableOptions.new(
public_key:,
allowed_origins: origins.map { exact_origin.("AGENT_ALLOWED_ORIGINS", it.to_s) },
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")),
)

The controller signs a ten-minute HS256 token with Ruby’s OpenSSL::HMAC for the user your authentication already signed in. Its request checks run before authentication: other methods get 405, requests whose Sec-Fetch-Site is not same-origin get 403, and signed-out users get 401, never a redirect to a sign-in page. Every response is Cache-Control: no-store. The subject comes only from Current.user, never from the query, body or headers. The same controller contains the server mint endpoint used by the alternative below.

app/controllers/agent_controller.rb
require "net/http"
# Bunkhouse endpoints: GET /api/agent-identity (identity token mode) and POST /api/agent-session
# (server mint mode). Both vouch only for the user your existing authentication signed in (Current.user).
class AgentController < ApplicationController
# Request checks first (403), then your authentication (401 JSON, never a redirect).
allow_unauthenticated_access
# The embed's POST carries no CSRF token; the Origin, X-Agent-Embed and Fetch Metadata checks replace it.
skip_forgery_protection only: %i[mint method_not_allowed]
before_action :no_store
before_action :require_same_origin_fetch, except: :method_not_allowed
before_action :require_allowed_origin, only: :mint
before_action :require_authentication, except: :method_not_allowed
def identity
secret = ENV["AGENT_SIGNING_SECRET"].presence or return not_configured("AGENT_SIGNING_SECRET")
kid = ENV["AGENT_SIGNING_KID"].presence or return not_configured("AGENT_SIGNING_KID")
render json: { identityToken: identity_token(Current.user.id.to_s, secret, kid) }
end
def mint
key = ENV["AGENT_EMBED_SECRET_KEY"].presence or return not_configured("AGENT_EMBED_SECRET_KEY")
uri = URI("#{agent.api_base}/v1/embed/sessions")
body = { publicKey: agent.public_key, mode: "signed", userId: Current.user.id.to_s, origin: request.origin }.to_json
upstream = begin
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
return render json: { error: "session_service_unavailable" }, status: :bad_gateway
end
render body: upstream.body, status: upstream.code.to_i, content_type: upstream["content-type"] || "application/json"
end
def method_not_allowed
response.headers["Allow"] = params[:allow]
render json: { error: "method_not_allowed" }, status: :method_not_allowed
end
private
def agent = Rails.configuration.x.agent
def require_same_origin_fetch
render json: { error: "forbidden" }, status: :forbidden unless request.headers["Sec-Fetch-Site"] == "same-origin"
end
def require_allowed_origin
allowed = agent.allowed_origins.include?(request.origin) && request.headers["X-Agent-Embed"] == "1"
render json: { error: "forbidden" }, status: :forbidden unless allowed
end
def not_configured(variable)
logger.error "Bunkhouse: #{variable} is not set"
render json: { error: "not_configured" }, status: :service_unavailable
end
# HS256 identity token: kid header, sub = stable opaque user ID, aud = public key, 10 minutes.
def identity_token(user_id, secret, kid)
iat = Time.now.to_i
encode = ->(bytes) { Base64.urlsafe_encode64(bytes, padding: false) }
input = [{ alg: "HS256", typ: "JWT", kid: }, { sub: user_id, aud: agent.public_key, iat:, exp: iat + 600 }].map { encode.(it.to_json) }.join(".")
"#{input}.#{encode.(OpenSSL::HMAC.digest("SHA256", secret, input))}"
end
end
config/routes.rb (excerpt)
scope "api", defaults: { format: :json } do
get "agent-identity", to: "agent#identity"
post "agent-session", to: "agent#mint"
# Any other method: 405 with Allow.
match "agent-identity", to: "agent#method_not_allowed", via: :all, defaults: { allow: "GET" }
match "agent-session", to: "agent#method_not_allowed", via: :all, defaults: { allow: "POST" }
end

Current.user and require_authentication are the seam to your authentication. With the generator’s Authentication concern, make unauthenticated JSON requests answer 401 instead of redirecting; the routes above default to JSON:

app/controllers/concerns/authentication.rb (excerpt)
def request_authentication
if request.format.json?
render json: { error: "unauthorized" }, status: :unauthorized
else
redirect_to root_path
end
end

If you use Devise or another library, call its current-user method in the controller and render the same 401 when it returns nil.

In the layout’s <head>, render the page hooks and then the loader, before javascript_importmap_tags: the getAuthHeaders hook (below), then the mode partial with the identity hook and the loader. The hooks are external same-origin scripts; the loader carries the response’s CSP nonce:

app/views/agent_embed/_identity.html.erb
<%# Identity token mode (recommended): the hook first, then the loader, before the app's JavaScript. %>
<%= javascript_include_tag "agent_identity", "data-turbo-track": "reload" %>
<script src="<%= Rails.configuration.x.agent.embed_origin %>/embed.js" nonce="<%= content_security_policy_nonce %>"
data-public-key="<%= Rails.configuration.x.agent.public_key %>" data-hints-url="/agent-hints.json"
data-turbo-track="reload"></script>
app/assets/javascripts/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;
},
};
app/views/layouts/application.html.erb (excerpt)
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%# Page hooks merge into window.AgentEmbedConfig before the loader runs. %>
<%= javascript_include_tag "agent_auth_headers", "data-turbo-track": "reload" %>
<%= render "agent_embed/#{agent_embed_mode}" %>
<%# The application's own JavaScript, Turbo included, comes after the loader. %>
<%= javascript_importmap_tags %>

The example layout renders one of the three agent_embed partials, selected by a test-only ?mode= switch. Your application uses exactly one. The data-turbo-track="reload" attributes make Turbo load a full page if these tags ever change (a new public key, say), so a Turbo visit never adds a second loader.

Instead of the identity hook, add data-session-endpoint to the loader. Do not keep agent_identity.js on the page: when getIdentityToken is defined it takes precedence.

app/views/agent_embed/_mint.html.erb
<%# Server mint mode: no identity hook; the embed posts to the same-origin session endpoint. %>
<script src="<%= Rails.configuration.x.agent.embed_origin %>/embed.js" nonce="<%= content_security_policy_nonce %>"
data-public-key="<%= Rails.configuration.x.agent.public_key %>" data-hints-url="/agent-hints.json"
data-session-endpoint="/api/agent-session" data-turbo-track="reload"></script>

AgentController#mint 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.

Rails’ CSRF protection (protect_from_forgery) stays on for the rest of the app. The embed’s POST carries no authenticity token, so AgentController declares skip_forgery_protection only: %i[mint method_not_allowed]: the mint action, plus the 405 responder, which changes nothing. The three header checks are the mint route’s protection.

Anonymous sessions need only the loader. 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.

app/views/agent_embed/_anonymous.html.erb
<%# Anonymous mode (enable it for the site first): the loader alone. %>
<script src="<%= Rails.configuration.x.agent.embed_origin %>/embed.js" nonce="<%= content_security_policy_nonce %>"
data-public-key="<%= Rails.configuration.x.agent.public_key %>" data-hints-url="/agent-hints.json"
data-turbo-track="reload"></script>

With Turbo Drive, links and form submissions do not load a new document: the loader runs once per tab and Turbo merges each new page’s <head> without running the same scripts again. Turbo replaces <body> on every visit; the embed keeps its launcher and pane outside <body>, so they stay, and an open pane stays open, across visits with Turbo’s default rendering.

Signing in, signing out and switching accounts are Turbo form submissions, so the previous user’s assistant session would survive them. A module imported from app/javascript/application.js makes forms marked data-agent-auth call window.AgentEmbed?.resetSession() as soon as the server accepts the change, before the new user’s page renders.

app/javascript/agent_embed_turbo.js
// Turbo Drive keeps the document across visits and form submissions, so the embed loads once per tab.
// Sign-in, sign-out and account switches are Turbo form submissions, not new documents: drop the previous
// user's assistant session as soon as the server accepts the change, before the new user's page loads.
document.addEventListener("turbo:submit-end", (event) => {
if (event.detail.success && event.target.matches("form[data-agent-auth]")) window.AgentEmbed?.resetSession();
});
app/javascript/application.js
import "@hotwired/turbo-rails";
import "agent_embed_turbo";
// The application's startup code: its first API request on each page, which the loader (already installed) observes.
document.addEventListener("turbo:load", async () => {
const { items } = await (await fetch("/api/startup-items", { credentials: "same-origin" })).json();
document.getElementById("startup-items").textContent = `Startup items: ${items.map((item) => item.name).join(", ")}`;
});

The example’s demo sign-in, a stand-in for yours, starts a fresh Rails session (and CSRF token) for the new user with reset_session; the session cookie is HttpOnly and SameSite=Lax, and force_ssl marks it Secure in HTTPS deployments. Its sign-in and sign-out forms carry data: { agent_auth: true }:

app/controllers/sessions_controller.rb
# Demo sign-in and sign-out, a stand-in for your own (based on the Rails authentication generator).
class SessionsController < ApplicationController
allow_unauthenticated_access only: :create
def create
if (user = User.authenticate_by(params.permit(:username, :password)))
terminate_session if authenticated? # account switch
reset_session # fresh Rails session and CSRF token for the new user
start_new_session_for user
redirect_to return_path, status: :see_other
else
render "pages/show", locals: { heading: "Sign in failed" }, status: :unprocessable_entity
end
end
def destroy
terminate_session
reset_session
redirect_to return_path, status: :see_other
end
end

Render the loader once in the layout’s <head>, never in a view that Turbo replaces. If you turn Turbo Drive off (data-turbo="false" on <body>, or Turbo.session.drive = false), every visit and account change loads a new document, which starts a new embed with no session, and agent_embed_turbo.js is not needed.

The assistant calls your API with the page’s cookies; supply any extra header your API requires with getAuthHeaders. Rails checks the X-CSRF-Token header against the token csrf_meta_tags renders, and Turbo refreshes that <meta> tag on every visit, including after sign-in:

app/assets/javascripts/agent_auth_headers.js
// Rails' CSRF token (csrf_meta_tags; Turbo refreshes it on every visit) 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 layout loads this hook before the mode partial, so both hooks are defined before the loader runs. Each merges into window.AgentEmbedConfig, so their order relative to each other does not matter. The header values never reach the model or the sandbox.

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

Add the Bunkhouse sources to your existing policy rather than replacing it. Rails puts a fresh nonce on every response, used by the importmap tags, the loader and Turbo’s progress bar; no 'unsafe-inline' or 'unsafe-eval' is needed:

config/initializers/content_security_policy.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 goes on the importmap
# and the loader; no 'unsafe-inline' or 'unsafe-eval'.
Rails.application.configure do
agent = config.x.agent
config.content_security_policy do |policy|
policy.default_src :self
policy.script_src :self, agent.embed_origin
policy.style_src :self
policy.frame_src agent.embed_origin
policy.connect_src :self, agent.api_base
policy.img_src :self, :data
policy.object_src :none
policy.base_uri :self
policy.form_action :self
policy.frame_ancestors :self
end
config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
config.content_security_policy_nonce_directives = %w[script-src style-src]
end

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

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

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, Turbo navigation with a single loader and launcher, sign-out and account switches without a page load, 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, or the controller does not find your signed-in user.
A sign-in page instead of 401 request_authentication redirects JSON requests; render 401 for them.
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.
422 InvalidAuthenticityToken on /api/agent-session skip_forgery_protection only: %i[mint method_not_allowed] is missing from the controller.
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, and keep the nonce on the loader.
Startup requests are not discovered The loader must come before javascript_importmap_tags in the head.
Two launchers or loaders Render the loader once, in the layout’s <head>, with data-turbo-track="reload".
The previous user’s assistant after signing in The sign-in or sign-out form lacks data-agent-auth, or the reset listener is not imported.
A secret in page source Only AGENT_PUBLIC_KEY and origins belong in views; keep ss_ and sk_ values in server environment variables or credentials.