Vue
This guide adds Bunkhouse to a Vue single-page app. Vue 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.
Tested setup
Section titled “Tested setup”| Framework | Vue 3.5.44 with Vue Router 5.4.0, 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/vue/ 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-sessionwith two demo users and the Vue app signs in withfetch; 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 |
Identity token (recommended)
Section titled “Identity token (recommended)”Backend
Section titled “Backend”Set the backend’s environment. Only AGENT_PUBLIC_KEY and the origins ever reach the browser:
AGENT_PUBLIC_KEY=pk_...AGENT_SIGNING_SECRET=ss_... # server only, never in the Vue buildAGENT_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.aiNever put the signing secret or mint key in a VITE_ variable or anywhere else in the Vue build: anything bundled is public.
// 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.
// 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.
/** 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));Page shell
Section titled “Page shell”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 Vue 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:
<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Vue 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.ts"></script> </head> <body> <div id="app"></div> </body></html>// 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));});<%# 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:
// 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 Vue 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.
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. %><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 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. %><script src="<%= agent.embedOrigin %>/embed.js" nonce="<%= nonce %>" data-public-key="<%= agent.publicKey %>" data-hints-url="/agent-hints.json"></script>Sign-in, sign-out and navigation
Section titled “Sign-in, sign-out and navigation”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 a watcher or a lifecycle hook that reacts to the new state later: a direct switch between accounts is one auth action, and the reset must precede the new user’s requests.
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:
// 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;}Vue Router navigation keeps the same document, so the loader, launcher and pane persist across routes; do not reset on route changes. Never install the loader from onMounted or a component: it would run after the app’s first requests and again whenever the component mounts.
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 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:
// 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 and compiles templates ahead of time (the runtime-only Vue build needs no 'unsafe-eval'), so the backend’s policy with a fresh nonce per response needs no 'unsafe-inline' or 'unsafe-eval':
// 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();}Verify
Section titled “Verify”From a checkout of the Bunkhouse repository, with Docker and local Supabase running:
node tests/framework-integrations/run.ts vueThe run builds the Vue 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. |
Related
Section titled “Related”- Installation, Security and the Page API
- Express, the backend used here
- Vue: Lifecycle hooks, Vue Router