Skip to content

React

This guide adds Bunkhouse to a React single-page app. React runs in the browser and cannot keep a secret, so the identity token and the server mint session come from your backend; the example uses the Express example backend, which also serves the built app. Every file shown below is part of a runnable example that is tested end to end against the real Bunkhouse API and embed.

Framework React 19.3.1 with React Router 8.5.1, built with Vite 8.3.5
Backend Express 5.3.0 (the Express example), serving the built app and the Bunkhouse endpoints from one origin
Runtime Node.js 24 (v24.22.2 in the tested image)
Example apps/docs/integrations/react/ and apps/docs/integrations/express/ in the Bunkhouse repository

You need:

  • Your application’s exact origin registered on the site (for example https://app.example.com). The app and its backend share that origin.
  • The site’s public key (pk_...).
  • For identity tokens (recommended): a signing secret (ss_...) and its key ID from the site’s Keys page, on the backend.
  • For server mint instead: a mint key (sk_...), on the backend.
  • Existing sign-in. The example’s backend uses express-session with two demo users and the React app signs in with fetch; your own authentication takes its place. Bunkhouse only needs the signed-in user’s stable ID on the server.
File Runs in Purpose
index.html Browser The page shell: the CSRF meta tag, the page hooks and the Bunkhouse script tags, before the app’s entry script
src/session.ts Browser Sign-in and sign-out calls; keeps the CSRF meta tag current and calls resetSession() after each account change
src/config.ts, src/agent.ts, src/csp.ts Express backend Configuration, GET /api/agent-identity and POST /api/agent-session, Content-Security-Policy
views/agent-embed-*.ejs, public/agent-identity.js, public/agent-auth-headers.js Express backend The script tags for one installation mode and the page hooks, loaded before the loader

Set the backend’s environment. Only AGENT_PUBLIC_KEY and the origins ever reach the browser:

Terminal window
AGENT_PUBLIC_KEY=pk_...
AGENT_SIGNING_SECRET=ss_... # server only, never in the React build
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

Never put the signing secret or mint key in a VITE_ variable or anywhere else in the React build: anything bundled is public.

src/config.ts (Express backend)
// 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.
function exactOrigin(name: string, value: string): string {
const url = new URL(value);
if (url.origin !== value) throw new Error(`${name} must be an exact origin like https://app.example.com`);
return value;
}
const publicKey = process.env.AGENT_PUBLIC_KEY ?? "";
if (!publicKey.startsWith("pk_")) throw new Error("AGENT_PUBLIC_KEY must be the site's public key (pk_...)");
const origins: unknown = JSON.parse(process.env.AGENT_ALLOWED_ORIGINS ?? "[]");
if (!Array.isArray(origins) || origins.length === 0) throw new Error('AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]');
export const agentConfig = {
publicKey,
allowedOrigins: origins.map((o) => exactOrigin("AGENT_ALLOWED_ORIGINS", String(o))),
apiBase: exactOrigin("AGENT_API_BASE", process.env.AGENT_API_BASE ?? "https://api.bunkhouse.ai"),
embedOrigin: exactOrigin("AGENT_EMBED_ORIGIN", process.env.AGENT_EMBED_ORIGIN ?? "https://embed.bunkhouse.ai"),
};

The backend router signs a ten-minute HS256 token for the user its session already authenticated. It rejects other methods (405), requests whose Sec-Fetch-Site is not same-origin (403) and signed-out users (401, never a login redirect), and marks every response Cache-Control: no-store. The subject comes only from the server session, never from the query, body or headers.

src/agent.ts (Express backend)
// 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 session already authenticated.
import { createHmac } from "node:crypto";
import { Router, type Request, type Response } from "express";
import { agentConfig } from "./config.ts";
/** Your existing authentication: the signed-in user of this request, or null. */
export type CurrentUser = (req: Request) => { id: string } | null;
const base64url = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url");
/** HS256 identity token: kid header, sub = stable opaque user ID, aud = public key, 10 minutes. */
export function signIdentityToken(userId: string, secret: string, kid: string): string {
const iat = Math.floor(Date.now() / 1000);
const header = base64url({ alg: "HS256", typ: "JWT", kid });
const payload = base64url({ sub: userId, aud: agentConfig.publicKey, iat, exp: iat + 600 });
const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
return `${header}.${payload}.${signature}`;
}
const methodNotAllowed = (allow: string) => (_req: Request, res: Response) => {
res.set({ Allow: allow, "Cache-Control": "no-store" }).status(405).json({ error: "method_not_allowed" });
};
function notConfigured(res: Response, variable: string) {
console.error(`Bunkhouse: ${variable} is not set`);
res.status(503).json({ error: "not_configured" });
}
export function agentRouter(currentUser: CurrentUser): Router {
const router = Router();
router
.route("/api/agent-identity")
.get((req, res) => {
res.set("Cache-Control", "no-store");
if (req.get("sec-fetch-site") !== "same-origin") return res.status(403).json({ error: "forbidden" });
const user = currentUser(req);
if (!user) return res.status(401).json({ error: "unauthorized" });
const { AGENT_SIGNING_SECRET: secret, AGENT_SIGNING_KID: kid } = process.env;
if (!secret) return notConfigured(res, "AGENT_SIGNING_SECRET");
if (!kid) return notConfigured(res, "AGENT_SIGNING_KID");
res.json({ identityToken: signIdentityToken(String(user.id), secret, kid) });
})
.all(methodNotAllowed("GET"));
router
.route("/api/agent-session")
.post(async (req, res) => {
res.set("Cache-Control", "no-store");
// The embed's POST carries no CSRF token; these three checks are this route's CSRF defense.
const origin = req.get("origin");
if (!origin || !agentConfig.allowedOrigins.includes(origin) || req.get("x-agent-embed") !== "1" || req.get("sec-fetch-site") !== "same-origin") {
return res.status(403).json({ error: "forbidden" });
}
const user = currentUser(req);
if (!user) return res.status(401).json({ error: "unauthorized" });
const key = process.env.AGENT_EMBED_SECRET_KEY;
if (!key) return notConfigured(res, "AGENT_EMBED_SECRET_KEY");
try {
const upstream = await fetch(`${agentConfig.apiBase}/v1/embed/sessions`, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ publicKey: agentConfig.publicKey, mode: "signed", userId: String(user.id), origin }),
signal: AbortSignal.timeout(15_000),
});
const body = Buffer.from(await upstream.arrayBuffer());
res.status(upstream.status).type(upstream.headers.get("content-type") ?? "application/json").send(body);
} catch {
res.status(502).json({ error: "session_service_unavailable" });
}
})
.all(methodNotAllowed("POST"));
return router;
}

Mount it after your session middleware. currentUser is the seam to your authentication: return the signed-in user, or null.

src/server.ts (Express backend, excerpt)
/** The signed-in user of this request (your existing authentication), or null. */
const currentUser = (req: Request) => req.session.user ?? null;
// Bunkhouse endpoints, behind the session middleware so they see the signed-in user.
app.use(agentRouter(currentUser));

The loader is a classic script in the document head, before the app’s entry module. Install it in index.html, not from a component or an effect: it must run before React starts making requests, exactly once per page. In the example, index.html loads the auth-headers hook (see below) and holds a marker where the backend renders the script tags for the configured mode, so both page hooks run before the loader:

index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>React example</title>
<!-- The app keeps the signed-in session's CSRF token here (src/session.ts). -->
<meta name="csrf-token" content="" />
<!-- Page hooks merge into window.AgentEmbedConfig before the loader runs. -->
<script src="/agent-auth-headers.js" nonce="__CSP_NONCE__"></script>
<!-- The server puts the Bunkhouse script tags here, before the app's entry script. -->
<!-- agent-embed -->
<script type="module" src="/src/main.tsx"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
src/server.ts (Express backend, excerpt)
// Built SPA: its index.html carries an <!-- agent-embed --> marker before the app's entry script.
const index = readFileSync(join(SPA_DIST, "index.html"), "utf8");
if (!index.includes("<!-- agent-embed -->")) throw new Error("SPA index.html lacks the <!-- agent-embed --> marker");
app.use(express.static(SPA_DIST, { index: false }));
app.get(["/", "/second"], async (req, res) => {
const locals = embedLocals(req, res);
const tags = await ejs.renderFile(join(VIEWS, `agent-embed-${locals.mode}.ejs`), locals);
res.type("html").send(index.replace("<!-- agent-embed -->", tags).replaceAll("__CSP_NONCE__", locals.nonce));
});
views/agent-embed-identity.ejs (Express backend)
<%# Identity token mode (recommended): the hook first, then the loader, before the app's scripts. %>
<script src="/agent-identity.js" nonce="<%= nonce %>"></script>
<script src="<%= agent.embedOrigin %>/embed.js" nonce="<%= nonce %>"
data-public-key="<%= agent.publicKey %>" data-hints-url="/agent-hints.json"></script>

The identity hook is an external same-origin script, so no inline script needs a CSP exception. It only fetches the token your backend signed:

public/agent-identity.js (Express backend)
// 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;
},
};

If you deploy the React build from a static host instead, put the same script tags with literal values in index.html, after the auth-headers hook and before the entry script, and serve /api/agent-identity from your backend on the same origin. The example renders them on the server only because it switches between the three modes with a test-only ?mode= parameter; your application uses exactly one.

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.

views/agent-embed-mint.ejs (Express backend)
<%# Server mint mode: no identity hook; the embed posts to the same-origin session endpoint. %>
<script src="<%= agent.embedOrigin %>/embed.js" nonce="<%= nonce %>"
data-public-key="<%= agent.publicKey %>" data-hints-url="/agent-hints.json"
data-session-endpoint="/api/agent-session"></script>

POST /api/agent-session in the backend’s src/agent.ts 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 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 backend. If your backend has CSRF middleware, exempt only /api/agent-session: the embed’s POST carries no CSRF token, and the three header checks are that 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.

views/agent-embed-anonymous.ejs (Express backend)
<%# Anonymous mode (enable it for the site first): the loader alone. %>
<script src="<%= agent.embedOrigin %>/embed.js" nonce="<%= nonce %>"
data-public-key="<%= agent.publicKey %>" data-hints-url="/agent-hints.json"></script>

A single-page app keeps its document, and with it the embed, when the user signs in, signs out or switches accounts. Call window.AgentEmbed?.resetSession() as soon as such an auth request succeeds, before the new user’s requests, so the previous user’s assistant session is dropped. Do it in the code that completes the auth action, not in an effect that reacts to state: an effect runs after rendering, and effects are for synchronizing with external systems, not for responding to an event.

The example’s login form posts JSON with fetch; the backend requires the app’s exact Origin on sign-in and sign-out and regenerates the session on login. login and logout reset the embed before returning the new session to the component:

src/session.ts
// Browser client for the app's existing sign-in (the Express backend's session). Every account change
// resets the Bunkhouse session: the page survives sign-in and sign-out, so the embed would otherwise
// keep the previous user's session.
declare global {
interface Window {
AgentEmbed?: { resetSession(): void };
}
}
export interface Session {
user: string | null;
csrfToken: string | null;
}
async function update(res: Response): Promise<Session> {
if (!res.ok) throw new Error(res.status === 401 ? "Invalid username or password" : `Request failed (${res.status})`);
const session = (await res.json()) as Session;
// The CSRF token the backend checks on every write; agent-auth-headers.js reads it for the assistant.
document.querySelector('meta[name="csrf-token"]')?.setAttribute("content", session.csrfToken ?? "");
return session;
}
const post = (path: string, body: object) =>
fetch(path, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).then(update);
export const loadSession = () => fetch("/api/session", { credentials: "same-origin", cache: "no-store" }).then(update);
export async function login(username: string, password: string): Promise<Session> {
const session = await post("/login", { username, password });
window.AgentEmbed?.resetSession(); // new user (or account switch): drop the previous assistant session
return session;
}
export async function logout(): Promise<Session> {
const session = await post("/logout", {});
window.AgentEmbed?.resetSession();
return session;
}

React Router navigation keeps the same document, so the loader, launcher and pane persist across routes; do not reset on route changes. Under StrictMode in development, React mounts components twice; because the loader is in index.html rather than a component, that never creates a second loader.

The assistant calls your API with the page’s cookies; supply any extra header your API requires with getAuthHeaders. The example’s backend session owns a random CSRF token and checks it on every write. The app receives the token with each session response (GET /api/session, sign-in and sign-out) and keeps it in the csrf-token meta tag of index.html (see src/session.ts above). The backend’s hook script, loaded in index.html before the loader, merges getAuthHeaders into window.AgentEmbedConfig and reads that tag on every call:

public/agent-auth-headers.js (Express backend)
// 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 embed calls the hook for each request to your API, so it always sends the current user’s token. 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 Vite production build emits only external scripts, so the backend’s policy with a fresh nonce per response needs no 'unsafe-inline' or 'unsafe-eval':

src/csp.ts (Express backend)
// 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 (res.locals.cspNonce)
// goes on the page's script tags.
import { randomBytes } from "node:crypto";
import type { NextFunction, Request, Response } from "express";
import { agentConfig } from "./config.ts";
export function contentSecurityPolicy(_req: Request, res: Response, next: NextFunction) {
const nonce = randomBytes(16).toString("base64");
res.locals.cspNonce = nonce;
res.set(
"Content-Security-Policy",
[
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' ${agentConfig.embedOrigin}`,
`style-src 'self' 'nonce-${nonce}'`,
`frame-src ${agentConfig.embedOrigin}`,
`connect-src 'self' ${agentConfig.apiBase}`,
"img-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
].join("; "),
);
next();
}

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

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

The run builds the React app and the Express backend into one 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 without a page load, client-side navigation with a single loader, discovery of the app’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 backend router is mounted before your session middleware, or currentUser does not find the user.
403 from either endpoint The request is cross-site or missing Sec-Fetch-Site: serve the app and the endpoints from one origin. For mint, Origin must exactly match AGENT_ALLOWED_ORIGINS and the loader must carry data-session-endpoint. If your backend has CSRF middleware, exempt only /api/agent-session.
503 The signing secret, key ID or mint key for the mode you use is not set on the backend.
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 be in index.html before the entry script, not added by a component.
Two launchers Render the loader once, in index.html; never from a component.
The previous user’s session after sign-in Call resetSession() right after the login, logout or switch request succeeds.
A secret in the bundle Only AGENT_PUBLIC_KEY and origins belong in the browser; keep ss_ and sk_ values in backend environment variables.