Next.js
This guide adds Bunkhouse to a Next.js application that uses the App Router. 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 | Next.js 16.4.3 App Router with React 19.3.1, next build and next start (server-rendered on demand) |
| Runtime | Node.js 24 (v24.22.2 in the tested image); route handlers use the Node.js runtime, not Edge |
| Example | apps/docs/integrations/nextjs/ in the Bunkhouse repository |
Static export (output: "export") and the Pages Router are not covered: the endpoints below need a server, and a per-request CSP nonce needs dynamic rendering.
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 and 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 |
|---|---|
lib/agent-config.ts, instrumentation.ts |
Validates the public Bunkhouse configuration when the server starts |
app/api/agent-identity/route.ts |
GET /api/agent-identity |
app/api/agent-session/route.ts |
POST /api/agent-session (server mint alternative) |
components/agent-embed-*.tsx |
The next/script tags for one installation mode |
app/layout.tsx |
Root layout: renders the tags once, before the app hydrates |
public/agent-identity.js, public/agent-auth-headers.js |
Page hooks, loaded before the loader |
components/auth-forms.tsx |
Resets the assistant session after sign-in and sign-out |
proxy.ts |
Your Content-Security-Policy with the Bunkhouse sources and a per-request nonce |
Identity token (recommended)
Section titled “Identity token (recommended)”Set the server environment. Only AGENT_PUBLIC_KEY and the origins ever reach the browser. Do not prefix these variables with NEXT_PUBLIC_, which would inline them into client bundles:
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.aiThe configuration is read when first used, because next build loads route modules without the runtime environment, and checked once at server start by the instrumentation hook:
// Bunkhouse public configuration, validated at server start (instrumentation.ts). Signing and mint// credentials are read only by the route handlers that use them, so each mode needs only its own secret.export interface AgentConfig { publicKey: string; allowedOrigins: string[]; apiBase: string; embedOrigin: string;}
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;}
function load(): AgentConfig { 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"]'); return { 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"), };}
// Read lazily: `next build` imports route modules without the runtime environment.let config: AgentConfig | undefined;export const agentConfig = (): AgentConfig => (config ??= load());import { agentConfig } from "./lib/agent-config";
// Runs once when the server starts. Next.js only logs errors thrown here, so exit explicitly:// invalid Bunkhouse configuration stops the server instead of failing later requests.export function register() { try { agentConfig(); } catch (error) { console.error(error instanceof Error ? error.message : error); process.exit(1); }}The route handler signs a ten-minute HS256 token for the user your session already authenticated. It rejects other methods (405 with Allow: GET, including OPTIONS, which Next.js would otherwise answer itself), 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. It uses node:crypto, so it runs on the Node.js runtime:
// GET /api/agent-identity: a ten-minute identity token for the user your session already authenticated.import { createHmac } from "node:crypto";import { agentConfig } from "@/lib/agent-config";import { getCurrentUser } from "@/lib/session";
export const runtime = "nodejs";
const json = (body: object, status = 200, headers: Record<string, string> = {}) => Response.json(body, { status, headers: { "Cache-Control": "no-store", ...headers } });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. */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}`;}
function notConfigured(variable: string) { console.error(`Bunkhouse: ${variable} is not set`); return json({ error: "not_configured" }, 503);}
export async function GET(request: Request) { if (request.headers.get("sec-fetch-site") !== "same-origin") return json({ error: "forbidden" }, 403); // Your existing authentication: the signed-in user of this request, or null. const user = await getCurrentUser(); if (!user) return json({ error: "unauthorized" }, 401); const { AGENT_SIGNING_SECRET: secret, AGENT_SIGNING_KID: kid } = process.env; if (!secret) return notConfigured("AGENT_SIGNING_SECRET"); if (!kid) return notConfigured("AGENT_SIGNING_KID"); return json({ identityToken: signIdentityToken(String(user.id), secret, kid) });}
const methodNotAllowed = () => json({ error: "method_not_allowed" }, 405, { Allow: "GET" });export { methodNotAllowed as POST, methodNotAllowed as PUT, methodNotAllowed as PATCH, methodNotAllowed as DELETE, methodNotAllowed as OPTIONS };getCurrentUser() is the seam to your authentication: return the signed-in user, or null. Replace it with the session lookup your app already uses in route handlers, as in the Next.js authentication guide. The example’s demo version verifies a signed cookie with jose:
// Demo authentication (a stand-in for yours): a signed, HttpOnly session cookie verified with jose.// getCurrentUser() is the only thing the Bunkhouse route handlers use from it.import { randomBytes } from "node:crypto";import { jwtVerify, SignJWT } from "jose";import { cookies } from "next/headers";
export interface SessionUser { /** Stable opaque ID: what Bunkhouse receives, never a name or email. */ id: string; name: string; csrfToken: string;}
const COOKIE = "example_session";
function key() { const secret = process.env.DEMO_SESSION_SECRET; if (!secret || secret.length < 32) throw new Error("DEMO_SESSION_SECRET (32+ characters) is required"); return new TextEncoder().encode(secret);}
/** The signed-in user of this request, or null. */export async function getCurrentUser(): Promise<SessionUser | null> { const token = (await cookies()).get(COOKIE)?.value; if (!token) return null; try { const { payload } = await jwtVerify<{ name: string; csrf: string }>(token, key(), { algorithms: ["HS256"] }); return payload.sub ? { id: payload.sub, name: payload.name, csrfToken: payload.csrf } : null; } catch { return null; }}
/** Starts a new session (with a new CSRF token) for the user. */export async function startSession(user: { id: string; name: string }): Promise<void> { const token = await new SignJWT({ name: user.name, csrf: randomBytes(32).toString("base64url") }) .setProtectedHeader({ alg: "HS256" }) .setSubject(user.id) .setIssuedAt() .setExpirationTime("8h") .sign(key()); (await cookies()).set(COOKIE, token, { httpOnly: true, sameSite: "lax", // Secure everywhere except the loopback HTTP test origin. secure: !process.env.APP_ORIGIN?.startsWith("http://"), path: "/", maxAge: 8 * 60 * 60, });}
export async function endSession(): Promise<void> { (await cookies()).delete(COOKIE);}Render the hook and then the loader with strategy="beforeInteractive" from the root layout. Next.js loads beforeInteractive scripts in order before any Next.js code and before hydration, so the loader observes your app’s first requests. The hook is an external same-origin script, so no inline script needs a CSP exception:
// Identity token mode (recommended): the hook first, then the loader, both before the app's own code.import Script from "next/script";import { agentConfig } from "@/lib/agent-config";
export function AgentEmbedIdentity({ nonce }: { nonce?: string }) { const { embedOrigin, publicKey } = agentConfig(); return ( <> <Script src="/agent-identity.js" strategy="beforeInteractive" nonce={nonce} /> <Script src={`${embedOrigin}/embed.js`} strategy="beforeInteractive" nonce={nonce} data-public-key={publicKey} data-hints-url="/agent-hints.json" /> </> );}// 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; },};{user && <meta name="csrf-token" content={user.csrfToken} />}{/* beforeInteractive scripts load in order, before Next.js hydrates the app: page hooks, then the loader. */}<Script src="/agent-auth-headers.js" strategy="beforeInteractive" nonce={nonce} /><AgentEmbed nonce={nonce} />The example layout renders one of the three agent-embed-* components, selected by a test-only ?mode= switch. Your application uses exactly one. Keep it in the root layout, never in a page or a client component’s effect.
Server mint alternative
Section titled “Server mint alternative”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.
// Server mint mode: no identity hook; the embed posts to the same-origin session endpoint.import Script from "next/script";import { agentConfig } from "@/lib/agent-config";
export function AgentEmbedMint({ nonce }: { nonce?: string }) { const { embedOrigin, publicKey } = agentConfig(); return ( <Script src={`${embedOrigin}/embed.js`} strategy="beforeInteractive" nonce={nonce} data-public-key={publicKey} data-hints-url="/agent-hints.json" data-session-endpoint="/api/agent-session" /> );}// POST /api/agent-session (server mint mode): creates a Bunkhouse session for the signed-in user with// the server-only mint key.import { agentConfig } from "@/lib/agent-config";import { getCurrentUser } from "@/lib/session";
export const runtime = "nodejs";
const json = (body: object, status: number, headers: Record<string, string> = {}) => Response.json(body, { status, headers: { "Cache-Control": "no-store", ...headers } });
export async function POST(request: Request) { const { allowedOrigins, apiBase, publicKey } = agentConfig(); // The embed's POST carries no CSRF token; these three checks are this route's CSRF defense. const origin = request.headers.get("origin"); if (!origin || !allowedOrigins.includes(origin) || request.headers.get("x-agent-embed") !== "1" || request.headers.get("sec-fetch-site") !== "same-origin") { return json({ error: "forbidden" }, 403); } // Your existing authentication: the signed-in user of this request, or null. const user = await getCurrentUser(); if (!user) return json({ error: "unauthorized" }, 401); const key = process.env.AGENT_EMBED_SECRET_KEY; if (!key) { console.error("Bunkhouse: AGENT_EMBED_SECRET_KEY is not set"); return json({ error: "not_configured" }, 503); } try { const upstream = await fetch(`${apiBase}/v1/embed/sessions`, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, body: JSON.stringify({ publicKey, mode: "signed", userId: String(user.id), origin }), signal: AbortSignal.timeout(15_000), cache: "no-store", }); 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 json({ error: "session_service_unavailable" }, 502); }}
const methodNotAllowed = () => json({ error: "method_not_allowed" }, 405, { Allow: "POST" });export { methodNotAllowed as GET, methodNotAllowed as PUT, methodNotAllowed as PATCH, methodNotAllowed as DELETE, methodNotAllowed as OPTIONS };The handler 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. Other methods return 405 with Allow: POST. Set AGENT_EMBED_SECRET_KEY=sk_... on the server; the identity endpoint does not need it.
Route handlers have no automatic CSRF protection (Server Actions compare Origin with the host themselves), so these three header checks are this route’s protection. If you add CSRF middleware in proxy.ts, exempt only /api/agent-session: the embed’s POST carries no CSRF token.
Anonymous alternative
Section titled “Anonymous alternative”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.
// Anonymous mode (enable it for the site first): the loader alone.import Script from "next/script";import { agentConfig } from "@/lib/agent-config";
export function AgentEmbedAnonymous({ nonce }: { nonce?: string }) { const { embedOrigin, publicKey } = agentConfig(); return ( <Script src={`${embedOrigin}/embed.js`} strategy="beforeInteractive" nonce={nonce} data-public-key={publicKey} data-hints-url="/agent-hints.json" /> );}Sign-in, sign-out and navigation
Section titled “Sign-in, sign-out and navigation”The root layout stays mounted across next/link navigation, so the loader and its launcher are installed once per document. Sign-in and sign-out in the example are Server Actions: they set or delete the session cookie and Next.js re-renders the page without loading a new document. The embed therefore survives the account change, so the form calls window.AgentEmbed?.resetSession() as soon as the action succeeds, before the new user does anything:
"use client";// Sign-in, account switch and sign-out through Server Actions. The page is not reloaded, so the embed// survives: drop the previous user's assistant session as soon as the change succeeds.import { useActionState } from "react";import { signIn, signOut } from "@/app/actions";
declare global { interface Window { AgentEmbed?: { resetSession(): void }; }}
export function AuthForms({ signedIn }: { signedIn: boolean }) { const [error, login, pending] = useActionState(async (_previous: string | null, form: FormData) => { const { error } = await signIn(form); if (!error) window.AgentEmbed?.resetSession(); return error; }, null);
return ( <> <form id="login-form" action={login}> <label> Username <input name="username" autoComplete="username" /> </label> <label> Password <input name="password" type="password" autoComplete="current-password" /> </label> <button type="submit" disabled={pending}> Sign in </button> {error && <p role="alert">{error}</p>} </form> {signedIn && ( <form action={async () => { await signOut(); window.AgentEmbed?.resetSession(); }} > <button type="submit">Sign out</button> </form> )} </> );}Do the same wherever your app changes the signed-in user without a full page load, including account switches. A full page load (for example a sign-out route that redirects with a document request) starts a new embed with no session, so no reset is needed there. The demo session cookie is HttpOnly, SameSite=Lax and Secure outside the loopback test origin, and sign-in issues a new session.
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 carries a random CSRF token that the layout renders into a <meta> tag for signed-in users and app/api/notes/route.ts checks 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 layout loads this hook before the mode component, so both page hooks exist before the loader runs. It merges into window.AgentEmbedConfig, so its order relative to the identity hook does not matter. Its values never reach the model or the sandbox. Because the layout re-renders after a Server Action sign-in, the <meta> tag always holds the current user’s token.
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 follows the Next.js nonce pattern: proxy.ts (called middleware.ts before Next.js 16) sets a fresh nonce per request on the request and the response, Next.js applies it to its own scripts, and the layout passes x-nonce to next/script. The embed reads the nonce from its loader tag for the styles and scripts it adds. No 'unsafe-inline' or 'unsafe-eval' is needed:
// 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 request: Next.js reads it from// the request's policy for its own scripts, and the root layout passes x-nonce to next/script.import { NextResponse, type NextRequest } from "next/server";import { agentConfig } from "@/lib/agent-config";
export function proxy(request: NextRequest) { const { embedOrigin, apiBase } = agentConfig(); const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64"); const policy = [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}' ${embedOrigin}`, `style-src 'self' 'nonce-${nonce}'`, `frame-src ${embedOrigin}`, `connect-src 'self' ${apiBase}`, "img-src 'self' data:", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'self'", ].join("; ");
const requestHeaders = new Headers(request.headers); requestHeaders.set("x-nonce", nonce); requestHeaders.set("content-security-policy", policy); // Test-shell switch between the three installation modes (?mode=); a real app uses exactly one. const mode = request.nextUrl.searchParams.get("mode"); requestHeaders.set("x-agent-mode", mode === "mint" || mode === "anonymous" ? mode : "identity");
const response = NextResponse.next({ request: { headers: requestHeaders } }); response.headers.set("content-security-policy", policy); return response;}
export const config = { // Documents and Server Actions; not API routes or static files. matcher: ["/((?!api/|_next/static|_next/image|favicon.ico|healthz|agent-hints.json).*)"],};Verify
Section titled “Verify”From a checkout of the Bunkhouse repository, with Docker and local Supabase running:
node tests/framework-integrations/run.ts nextjsThe 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, next/link navigation, 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 |
getCurrentUser() does not find the user: it must read the same session your pages use. |
| 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. Custom 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 script-src and frame-src and the API to connect-src, and pass the nonce to every next/script tag. A nonce only works on dynamically rendered pages. |
| Startup requests are not discovered | Use strategy="beforeInteractive" in the root layout; afterInteractive or a useEffect loads the loader after your app’s first requests. |
| Two launchers | Render the loader once, in the root layout, not in pages or nested layouts. |
| The previous user’s assistant session continues | Call window.AgentEmbed?.resetSession() after every sign-in, sign-out and account switch that does not load a new document. |
| A secret in page source | Only AGENT_PUBLIC_KEY and origins belong in components; keep ss_ and sk_ values in server environment variables without the NEXT_PUBLIC_ prefix. |
Related
Section titled “Related”- Installation, Security and the Page API
- Next.js: Route handlers, Script, Authentication, Content Security Policy