Hono
This guide adds Bunkhouse to a server-rendered Hono application running on Node.js. Every file shown below is part of a runnable example app that is tested end to end against the real Bunkhouse API and embed.
Tested setup
Section titled “Tested setup”| Framework | Hono 4.13.14 on @hono/node-server 2.1.5, server-rendered hono/jsx pages |
| Runtime | Node.js 24 (v24.22.2 in the tested image); TypeScript and JSX compiled with tsc |
| Example | apps/docs/integrations/hono/ 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 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, as a context variable set by middleware.
| File | Purpose |
|---|---|
src/config.ts |
Validates the public Bunkhouse configuration at startup |
src/agent.ts |
GET /api/agent-identity and POST /api/agent-session |
src/csp.ts |
Hono’s secureHeaders with the Bunkhouse sources added |
src/agent-embed-*.tsx |
The page hooks and loader for one installation mode |
public/agent-identity.js, public/agent-auth-headers.js |
Page hooks, loaded before the loader |
Identity token (recommended)
Section titled “Identity token (recommended)”Set the server environment. Only AGENT_PUBLIC_KEY and the origins ever reach the browser:
AGENT_PUBLIC_KEY=pk_...AGENT_SIGNING_SECRET=ss_... # server onlyAGENT_SIGNING_KID=... # key ID shown with the signing secretAGENT_ALLOWED_ORIGINS='["https://app.example.com"]'# AGENT_API_BASE and AGENT_EMBED_ORIGIN default to https://api.bunkhouse.ai and https://embed.bunkhouse.ai// 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 agent sub-app signs a ten-minute HS256 token for the user your session already authenticated. It rejects other methods (405 with Allow), 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. The same file contains the server mint endpoint used by the alternative below.
// 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 { Hono, type Context } from "hono";import { agentConfig } from "./config.ts";
/** Your existing authentication: middleware sets `user` to the signed-in user of the request, or null. */export type AgentEnv = { Variables: { user: { 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) => (c: Context) => { c.header("Allow", allow); c.header("Cache-Control", "no-store"); return c.json({ error: "method_not_allowed" }, 405);};
function notConfigured(c: Context, variable: string) { console.error(`Bunkhouse: ${variable} is not set`); return c.json({ error: "not_configured" }, 503);}
export const agent = new Hono<AgentEnv>();
agent .get("/api/agent-identity", (c) => { c.header("Cache-Control", "no-store"); if (c.req.header("sec-fetch-site") !== "same-origin") return c.json({ error: "forbidden" }, 403); const user = c.get("user"); if (!user) return c.json({ error: "unauthorized" }, 401); const { AGENT_SIGNING_SECRET: secret, AGENT_SIGNING_KID: kid } = process.env; if (!secret) return notConfigured(c, "AGENT_SIGNING_SECRET"); if (!kid) return notConfigured(c, "AGENT_SIGNING_KID"); return c.json({ identityToken: signIdentityToken(String(user.id), secret, kid) }); }) .all(methodNotAllowed("GET"));
agent .post("/api/agent-session", async (c) => { c.header("Cache-Control", "no-store"); // The embed's POST carries no CSRF token; these three checks are this route's CSRF defense. const origin = c.req.header("origin"); if (!origin || !agentConfig.allowedOrigins.includes(origin) || c.req.header("x-agent-embed") !== "1" || c.req.header("sec-fetch-site") !== "same-origin") { return c.json({ error: "forbidden" }, 403); } const user = c.get("user"); if (!user) return c.json({ error: "unauthorized" }, 401); const key = process.env.AGENT_EMBED_SECRET_KEY; if (!key) return notConfigured(c, "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), }); return new Response(await upstream.arrayBuffer(), { status: upstream.status, headers: { "Content-Type": upstream.headers.get("content-type") ?? "application/json", "Cache-Control": "no-store" }, }); } catch { return c.json({ error: "session_service_unavailable" }, 502); } }) .all(methodNotAllowed("POST"));Mount it with app.route() after your session or auth middleware. c.get("user") is the seam to your authentication: the middleware sets it to the signed-in user (anything with a stable id), or null.
// Bunkhouse endpoints, after the session middleware so c.get("user") is the signed-in user.app.route("/", agent);In the page head, render the identity hook, then the auth-headers hook (see Auth headers), then the loader, before your application’s own scripts: window.AgentEmbedConfig must be defined before the loader runs. The layout is rendered on the server with hono/jsx, so the tags are in the HTML document itself rather than inserted by client code. The hooks are external same-origin scripts, so no inline script needs a CSP exception:
// Identity token mode (recommended): both page hooks first, then the loader, before the app's scripts.import { agentConfig } from "./config.ts";
export const AgentEmbedIdentity = ({ nonce }: { nonce: string }) => ( <> <script src="/agent-identity.js" nonce={nonce}></script> <script src="/agent-auth-headers.js" nonce={nonce}></script> <script src={`${agentConfig.embedOrigin}/embed.js`} nonce={nonce} data-public-key={agentConfig.publicKey} data-hints-url="/agent-hints.json"></script> </>);// 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; },};{csrfToken && <meta name="csrf-token" content={csrfToken} />}{/* Page hooks, then the loader, for the selected mode. */}<Tags nonce={nonce} />{/* The application's own JavaScript comes after the loader. */}<script src="/app.js" nonce={nonce} defer></script>The example layout renders one of the three agent-embed-* components, selected by a test-only ?mode= switch. Your application uses exactly one.
Server mint alternative
Section titled “Server mint alternative”Instead of the identity hook, add data-session-endpoint to the loader; the auth-headers hook stays before it. Do not keep agent-identity.js on the page: when getIdentityToken is defined it takes precedence.
// Server mint mode: no identity hook; the embed posts to the same-origin session endpoint. The// auth-headers hook still comes before the loader.import { agentConfig } from "./config.ts";
export const AgentEmbedMint = ({ nonce }: { nonce: string }) => ( <> <script src="/agent-auth-headers.js" nonce={nonce}></script> <script src={`${agentConfig.embedOrigin}/embed.js`} nonce={nonce} data-public-key={agentConfig.publicKey} data-hints-url="/agent-hints.json" data-session-endpoint="/api/agent-session"></script> </>);POST /api/agent-session in 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 server; the identity endpoint does not need it.
The example keeps Hono’s CSRF middleware on the whole app. It checks form-style requests by Sec-Fetch-Site or Origin, and the session endpoint’s three header checks are stricter, so it needs no exemption. If you use a token-based CSRF middleware instead, exempt only /api/agent-session: the embed’s POST carries no CSRF token.
Anonymous alternative
Section titled “Anonymous alternative”Anonymous sessions need neither the identity hook nor a session endpoint: only the auth-headers hook and 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.
// Anonymous mode (enable it for the site first): no identity hook or session endpoint; the auth-headers// hook, then the loader.import { agentConfig } from "./config.ts";
export const AgentEmbedAnonymous = ({ nonce }: { nonce: string }) => ( <> <script src="/agent-auth-headers.js" nonce={nonce}></script> <script src={`${agentConfig.embedOrigin}/embed.js`} nonce={nonce} data-public-key={agentConfig.publicKey} data-hints-url="/agent-hints.json"></script> </>);Sign-in, sign-out and navigation
Section titled “Sign-in, sign-out and navigation”The example signs in and out with ordinary form posts 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. Its demo session is a signed HttpOnly, SameSite cookie, verified with jose by middleware that sets c.var.user, and replaced with a new session and CSRF token on every login:
// Demo session: a signed, HttpOnly, SameSite cookie (a JWT verified with jose). Your app's session// middleware or auth library takes its place; it only has to set c.var.user.const SESSION_COOKIE = "session";const sessionKey = new TextEncoder().encode(SESSION_SECRET);const secure = APP_ORIGIN.startsWith("https://"); // Secure everywhere except the loopback HTTP test origin.
app.use(async (c, next) => { let user: SessionUser | null = null; const cookie = getCookie(c, SESSION_COOKIE); if (cookie) { try { const { payload } = await jwtVerify<{ name: string; csrf: string }>(cookie, sessionKey, { algorithms: ["HS256"] }); user = { id: payload.sub!, name: payload.name, csrfToken: payload.csrf }; } catch { user = null; // forged, modified or expired: not a session } } c.set("user", user); await next();});
async function startSession(c: Context, user: { id: string; name: string }) { // A new session (and CSRF token) on every login. const token = await new SignJWT({ name: user.name, csrf: randomBytes(32).toString("base64url") }) .setProtectedHeader({ alg: "HS256" }) .setSubject(user.id) .setIssuedAt() .setExpirationTime("8h") .sign(sessionKey); setCookie(c, SESSION_COOKIE, token, { path: "/", httpOnly: true, sameSite: "Lax", secure, maxAge: 8 * 3600 });}If your Hono app changes the signed-in user without a full page load (a fetch login, a client-side 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 the layout, not in per-page components or client islands.
Auth headers, user IDs and CSP
Section titled “Auth headers, user IDs and CSP”The assistant calls your API with the page’s cookies; supply any extra header your API requires with getAuthHeaders. The example’s session owns a random CSRF token, rendered into a <meta> tag for signed-in users and checked as X-CSRF-Token on every write:
// 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 and loads before the loader in every mode’s fragment (after the identity hook, where there is one). 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 uses Hono’s secureHeaders middleware, whose NONCE value gives every response a fresh nonce (c.get("secureHeadersNonce")) for the page’s script tags; no 'unsafe-inline' or 'unsafe-eval' is needed. Its other default headers stay on:
// The app's existing secure headers and Content-Security-Policy with the Bunkhouse sources added: the// embed origin to script-src and frame-src, the API to connect-src. NONCE gives every response a fresh// nonce (c.get("secureHeadersNonce")) for the page's script tags.import { NONCE, secureHeaders } from "hono/secure-headers";import { agentConfig } from "./config.ts";
export const securityHeaders = secureHeaders({ contentSecurityPolicy: { defaultSrc: ["'self'"], scriptSrc: ["'self'", NONCE, agentConfig.embedOrigin], styleSrc: ["'self'", NONCE], frameSrc: [agentConfig.embedOrigin], connectSrc: ["'self'", agentConfig.apiBase], imgSrc: ["'self'", "data:"], objectSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], frameAncestors: ["'self'"], },});Verify
Section titled “Verify”From a checkout of the Bunkhouse repository, with Docker and local Supabase running:
node tests/framework-integrations/run.ts honoThe 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 |
app.route("/", agent) is registered before your auth middleware, or the middleware does not set 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 token-based CSRF middleware must exempt only /api/agent-session. |
| 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 scriptSrc and frameSrc and the API to connectSrc, and put the nonce on every script tag. |
| Startup requests are not discovered | The page hooks and 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 components; keep ss_ and sk_ values in server environment variables. |
Related
Section titled “Related”- Installation, Security and the Page API
- Hono: Routing, Node.js, Secure Headers, CSRF