Nuxt
This guide adds Bunkhouse to a server-rendered Nuxt application: Nitro server routes for the endpoints, and the script tags rendered by the server ahead of Nuxt’s own scripts. 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 | Nuxt 4.6.3 (Vue 3.5, Nitro 2.13), server-side rendering with the Node.js server output (nuxt build, node .output/server/index.mjs), nuxt-auth-utils 0.5.34 sessions |
| Runtime | Node.js 24 (v24.22.2 in the tested image) |
| Example | apps/docs/integrations/nuxt/ 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
nuxt-auth-utilssessions 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 |
|---|---|
server/utils/agent-config.ts |
Validates the public Bunkhouse configuration |
server/utils/agent.ts |
Token signing, the signed-in-user seam, shared responses |
server/api/agent-identity.get.ts, server/api/agent-session.post.ts |
GET /api/agent-identity and POST /api/agent-session |
server/api/agent-identity.ts, server/api/agent-session.ts |
405 for every other method |
server/plugins/agent-embed.ts |
Validates the configuration at startup and passes its public part to the renderer |
server/plugins/csp.ts |
Your Content-Security-Policy with the Bunkhouse sources added |
app/agent-embed/*.ts |
The script tags for one installation mode |
app/app.vue |
Renders those tags in the head; resets the embed on sign-in and sign-out |
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.aiThese are read from process.env on the server at runtime, not through runtimeConfig, so the same build serves any site.
// Bunkhouse public configuration, validated once at startup (server/plugins/agent-embed.ts). 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"),};currentUserId is the seam to your authentication: return the signed-in user’s stable ID for the request, or null. The example reads the nuxt-auth-utils session; with another library, call its server-side session lookup here.
// Shared by the Bunkhouse endpoints in server/api/agent-*.ts.import { createHmac } from "node:crypto";import type { H3Event } from "h3";import { agentConfig } from "./agent-config";
/** Your existing authentication: the signed-in user's stable, opaque ID for this request, or null. */export async function currentUserId(event: H3Event): Promise<string | null> { const { user } = await getUserSession(event); // nuxt-auth-utils return user ? String(user.id) : 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}`;}
/** JSON error with the status; the caller has already set Cache-Control: no-store. */export function fail(event: H3Event, status: number, error: string) { setResponseStatus(event, status); return { error };}
export function notConfigured(event: H3Event, variable: string) { console.error(`Bunkhouse: ${variable} is not set`); return fail(event, 503, "not_configured");}
/** Handler for every other method on an endpoint's path (instead of Nuxt's 404 page). */export const methodNotAllowed = (allow: string) => defineEventHandler((event) => { setResponseHeaders(event, { Allow: allow, "Cache-Control": "no-store" }); return fail(event, 405, "method_not_allowed"); });The identity route signs a ten-minute HS256 token for the user your session already authenticated. It rejects 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.
// GET /api/agent-identity: a ten-minute identity token for the signed-in user (identity token mode).import { currentUserId, fail, notConfigured, signIdentityToken } from "../utils/agent";
export default defineEventHandler(async (event) => { setResponseHeader(event, "Cache-Control", "no-store"); if (getRequestHeader(event, "sec-fetch-site") !== "same-origin") return fail(event, 403, "forbidden"); const userId = await currentUserId(event); if (!userId) return fail(event, 401, "unauthorized"); const { AGENT_SIGNING_SECRET: secret, AGENT_SIGNING_KID: kid } = process.env; if (!secret) return notConfigured(event, "AGENT_SIGNING_SECRET"); if (!kid) return notConfigured(event, "AGENT_SIGNING_KID"); return { identityToken: signIdentityToken(userId, secret, kid) };});Nitro matches agent-identity.get.ts only for GET. A sibling file without a method suffix catches every other method and answers 405 with Allow: GET instead of Nuxt’s 404 page:
// Every other method on /api/agent-identity.import { methodNotAllowed } from "../utils/agent";
export default methodNotAllowed("GET");The loader must be in the document the server renders, ahead of Nuxt’s entry script, so it is installed before your app makes its first request. A Nitro plugin validates the configuration when the server starts and puts its public part on each request’s context:
// Validates the Bunkhouse configuration at startup (importing it throws if invalid) and hands its public// part to the page renderer, where app/app.vue renders the script tags.import type { AgentEmbedTags } from "#shared/types/agent-embed";import { agentConfig } from "../utils/agent-config";
declare module "h3" { interface H3EventContext { agentEmbed: AgentEmbedTags; }}
export default defineNitroPlugin((nitroApp) => { const tags: AgentEmbedTags = { publicKey: agentConfig.publicKey, embedOrigin: agentConfig.embedOrigin }; nitroApp.hooks.hook("request", (event) => { event.context.agentEmbed = tags; });});// The public Bunkhouse configuration the page renders, and the part of the page API this app calls.export interface AgentEmbedTags { publicKey: string; embedOrigin: string;}
declare global { interface Window { AgentEmbed?: { resetSession(): void }; }}The root component renders the tags with useHead on the server only, with tagPriority: "critical" so they come first in <head>. Because the client never registers them, hydration and client-side navigation leave the one loader element alone. Each mode’s fragment lists the page hooks before the loader, because the loader reads window.AgentEmbedConfig when it runs. The hooks are external same-origin scripts, so no inline script needs a CSP exception:
// The Bunkhouse tags (page hooks, then the loader), rendered once on the server at the top of <head>, ahead// of Nuxt's own scripts. The client never re-renders them, so the loader stays one element across// client-side navigation.if (import.meta.server) { const agent = useRequestEvent()!.context.agentEmbed; // Test-shell switch between the three installation modes (?mode=); a real app uses exactly one. const scripts = route.query.mode === "mint" ? mintScripts(agent) : route.query.mode === "anonymous" ? anonymousScripts(agent) : identityScripts(agent); useHead({ script: scripts }, { tagPriority: "critical" });}// Identity token mode (recommended): the page hooks (identity, then auth headers), then the loader.import type { AgentEmbedTags } from "#shared/types/agent-embed";
export const identityScripts = ({ publicKey, embedOrigin }: AgentEmbedTags) => [ { src: "/agent-identity.js" }, { src: "/agent-auth-headers.js" }, { src: `${embedOrigin}/embed.js`, "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; },};In the rendered head, after the charset, viewport and title, come Nuxt’s import map (which makes no requests), then the identity hook, the auth-header hook (see Auth headers) and the loader, then Nuxt’s type="module" entry. The example picks one of the three app/agent-embed/*.ts fragments with 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. Do not keep agent-identity.js on the page: when getIdentityToken is defined it takes precedence. The auth-header hook stays before the loader.
// Server mint mode: no identity hook; the auth-header hook, then the loader, which posts to the same-origin// session endpoint.import type { AgentEmbedTags } from "#shared/types/agent-embed";
export const mintScripts = ({ publicKey, embedOrigin }: AgentEmbedTags) => [ { src: "/agent-auth-headers.js" }, { src: `${embedOrigin}/embed.js`, "data-public-key": publicKey, "data-hints-url": "/agent-hints.json", "data-session-endpoint": "/api/agent-session", },];// POST /api/agent-session: creates a signed session for the signed-in user with the mint key (server mint mode).import { agentConfig } from "../utils/agent-config";import { currentUserId, fail, notConfigured } from "../utils/agent";
export default defineEventHandler(async (event) => { setResponseHeader(event, "Cache-Control", "no-store"); // The embed's POST carries no CSRF token; these three checks are this route's CSRF defense. const origin = getRequestHeader(event, "origin"); if (!origin || !agentConfig.allowedOrigins.includes(origin) || getRequestHeader(event, "x-agent-embed") !== "1" || getRequestHeader(event, "sec-fetch-site") !== "same-origin") { return fail(event, 403, "forbidden"); } const userId = await currentUserId(event); if (!userId) return fail(event, 401, "unauthorized"); const key = process.env.AGENT_EMBED_SECRET_KEY; if (!key) return notConfigured(event, "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, origin }), signal: AbortSignal.timeout(15_000), }); const body = Buffer.from(await upstream.arrayBuffer()); setResponseStatus(event, upstream.status); setResponseHeader(event, "Content-Type", upstream.headers.get("content-type") ?? "application/json"); return body; } catch { return fail(event, 502, "session_service_unavailable"); }});// Every other method on /api/agent-session.import { methodNotAllowed } from "../utils/agent";
export default methodNotAllowed("POST");The route 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 route does not need it.
Nuxt has no built-in CSRF protection. If you add one (such as a CSRF module), exempt only /api/agent-session: the embed’s POST carries no CSRF token, and the three header checks are that route’s protection.
Anonymous alternative
Section titled “Anonymous alternative”Anonymous sessions need only the loader; the example keeps its auth-header hook before it for calls to its own API. 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 auth-header hook, then the loader.import type { AgentEmbedTags } from "#shared/types/agent-embed";
export const anonymousScripts = ({ publicKey, embedOrigin }: AgentEmbedTags) => [ { src: "/agent-auth-headers.js" }, { src: `${embedOrigin}/embed.js`, "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 example signs in and out with $fetch and refreshes the nuxt-auth-utils session, so the document, and the embed in it, survives the account change. Call window.AgentEmbed?.resetSession() right after the sign-in, sign-out or account switch succeeds and before the new user’s activity; this drops the previous user’s assistant session. The same <script setup> keeps the session’s CSRF token in a <meta> tag for the auth-header hook below:
const { loggedIn, user, session, fetch: refreshSession } = useUserSession();// The session's CSRF token for agent-auth-headers.js, kept current across sign-in and sign-out.useHead({ meta: computed(() => (loggedIn.value && session.value?.csrfToken ? [{ name: "csrf-token", content: session.value.csrfToken }] : [])) });
const loginError = ref("");async function signIn(event: SubmitEvent) { const form = event.target as HTMLFormElement; const data = new FormData(form); try { await $fetch("/login", { method: "POST", body: { username: data.get("username"), password: data.get("password") } }); } catch { loginError.value = "Invalid username or password"; return; } // Another user (or the same one again) from here on: drop the previous assistant session first. window.AgentEmbed?.resetSession(); loginError.value = ""; form.reset(); await refreshSession();}
async function signOut() { await $fetch("/logout", { method: "POST", body: {} }); window.AgentEmbed?.resetSession(); await refreshSession();}Before hydration, the same forms fall back to ordinary form posts that redirect back to the page; a new document starts a new embed with no session. <NuxtLink> navigation keeps the single loader and its session: reset for identity changes, not route changes. Render the loader only from app.vue, never from a page, layout or component that mounts later.
nuxt-auth-utils keeps the session in a sealed, HttpOnly, SameSite=Lax cookie, sealed with NUXT_SESSION_PASSWORD (32+ characters, server only). The demo login replaces the session on every sign-in. The cookie is Secure by default; the example turns that off only for its loopback HTTP test origin:
export default defineNuxtConfig({ compatibilityDate: "2026-10-01", modules: ["nuxt-auth-utils"], devtools: { enabled: false }, telemetry: false, app: { head: { htmlAttrs: { lang: "en" } } }, runtimeConfig: { // nuxt-auth-utils' sealed session cookie (HttpOnly, SameSite=Lax). Both are set at runtime from the // environment: NUXT_SESSION_PASSWORD, and NUXT_SESSION_COOKIE_SECURE (the example turns Secure off only // for its loopback HTTP origin). session: { password: "", cookie: { secure: true } }, },});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 the <meta> tag above for signed-in users and checked on every write:
// The app's own CSRF token (the signed-in session's <meta name="csrf-token">, kept current by app/app.vue)// 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 } : {}; },};Like the identity hook, it merges into window.AgentEmbedConfig and loads before the loader. 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. Nuxt renders inline scripts of its own (the runtime config and an import map), so the example adds a fresh nonce to every script and style tag of each rendered document in Nitro’s render:html hook and sends the policy with it; 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. Nuxt renders inline scripts (its runtime config and// import map), so every rendered document gets a fresh nonce on all of its script and style tags.import { randomBytes } from "node:crypto";import { agentConfig } from "../utils/agent-config";
export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook("render:html", (html, { event }) => { const nonce = randomBytes(16).toString("base64"); for (const part of ["head", "bodyPrepend", "body", "bodyAppend"] as const) { html[part] = html[part].map((chunk) => chunk.replace(/<(script|style)\b/g, `<$1 nonce="${nonce}"`)); } setResponseHeader( event, "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("; "), ); });});If you use a security module that already manages your CSP nonce (such as nuxt-security), add the same three sources to its policy instead.
Verify
Section titled “Verify”From a checkout of the Bunkhouse repository, with Docker and local Supabase running:
node tests/framework-integrations/run.ts nuxtThe 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 without a page load, <NuxtLink> navigation with one loader, 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 |
currentUserId does not find the user: check your session lookup and that the session cookie reaches the route. |
| 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 CSRF module must exempt only /api/agent-session. |
| 404 instead of 405 | The method-less server/api/agent-*.ts sibling files are missing. |
| 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; Nuxt’s inline scripts need the per-response nonce. |
| Startup requests are not discovered | The loader must come before Nuxt’s entry script: render it from app.vue with tagPriority: "critical", not from a page or a client-only component. |
| The previous user’s session continues | Call window.AgentEmbed?.resetSession() after a client-side sign-in, sign-out or account switch. |
| Two launchers | Render the loader once, server-only in app.vue. |
| A secret in page source | Only AGENT_PUBLIC_KEY and origins belong in the head and runtimeConfig.public; keep ss_ and sk_ values and NUXT_SESSION_PASSWORD in server environment variables. |
Related
Section titled “Related”- Installation, Security and the Page API
- Nuxt: Server directory, Sessions and authentication,
useHead