SvelteKit
This guide adds Bunkhouse to a SvelteKit application running as a Node server. 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 | SvelteKit 3.1.0 with Svelte 5.57, Vite 8.3 and @sveltejs/adapter-node 6.0.0; server-rendered pages with client-side navigation |
| Runtime | Node.js 24 (v24.22.2 in the tested image) |
| Example | apps/docs/integrations/sveltekit/ 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 that puts the user on
event.localsinsrc/hooks.server.ts. The example verifies a demo session cookie there; your own authentication takes its place. Bunkhouse only needs one thing from it: the signed-in user’s stable ID on the server.
Only the tested model is covered here: SvelteKit server rendering on Node (adapter-node). The endpoints need a server, so a fully prerendered or adapter-static site cannot sign identity tokens.
| File | Purpose |
|---|---|
src/env.ts |
Declares and validates the Bunkhouse environment variables at startup |
src/routes/api/agent-identity/+server.ts |
GET /api/agent-identity |
src/routes/api/agent-session/+server.ts |
POST /api/agent-session |
src/lib/server/agent.ts |
no-store, 503 and 405 responses shared by both endpoints |
src/hooks.server.ts |
Authentication on event.locals and the Bunkhouse sources in the CSP |
src/app.html |
The script tags for one installation mode |
static/agent-identity.js, static/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.aiSvelteKit 3 declares environment variables in src/env.ts. The public key and embed origin are public: true, so src/app.html can use them as %sveltekit.env.AGENT_PUBLIC_KEY%; everything else is private and can only be imported from $app/env/private in server code. Invalid values stop the app at startup; the building check lets vite build run without the runtime environment:
// Environment variables, validated when the app starts. AGENT_PUBLIC_KEY and AGENT_EMBED_ORIGIN are// public (src/app.html uses them); the signing secret and mint key are optional here and checked by the// endpoint that uses them, so each mode needs only its own credential.import { building } from "$app/env";import { defineEnvVars } from "@sveltejs/kit/env";
/** Required when the app starts. The build runs the app without its runtime environment. */function required<T>(parse: (value: string) => T) { return (value: string | undefined): T => { if (value !== undefined) return parse(value); if (building) return undefined as T; throw new Error("is required"); };}
function exactOrigin(value: string): string { if (new URL(value).origin !== value) throw new Error("must be an exact origin like https://app.example.com"); return value;}
const optional = (value: string | undefined) => value || undefined;
export const variables = defineEnvVars({ AGENT_PUBLIC_KEY: { public: true, schema: required((value) => { if (!/^pk_[\w-]+$/.test(value)) throw new Error("must be the site's public key (pk_...)"); return value; }), }, AGENT_EMBED_ORIGIN: { public: true, schema: (value = "https://embed.bunkhouse.ai") => exactOrigin(value) }, AGENT_API_BASE: { schema: (value = "https://api.bunkhouse.ai") => exactOrigin(value) }, AGENT_ALLOWED_ORIGINS: { description: 'Exact origins the mint endpoint accepts, as a JSON array: ["https://app.example.com"]', schema: required((value) => { const origins: unknown = JSON.parse(value); if (!Array.isArray(origins) || origins.length === 0) throw new Error('must be a JSON array such as ["https://app.example.com"]'); return origins.map((origin) => exactOrigin(String(origin))); }), }, AGENT_SIGNING_SECRET: { description: "Identity token mode: signing secret (ss_...)", schema: optional }, AGENT_SIGNING_KID: { description: "Identity token mode: the signing secret's key ID", schema: optional }, AGENT_EMBED_SECRET_KEY: { description: "Server mint mode: mint key (sk_...)", schema: optional },
// Example app only: its demo session cookie and origin. DEMO_SESSION_SECRET: { schema: required((value) => { if (value.length < 32) throw new Error("must be 32+ characters"); return value; }), }, APP_ORIGIN: { schema: required(exactOrigin) },});The identity endpoint signs a ten-minute HS256 token for the user on event.locals. It rejects requests whose Sec-Fetch-Site is not same-origin (403) and signed-out users (401, never a login redirect); its fallback handler answers every other method with 405 and Allow: GET. Every response is marked Cache-Control: no-store. The subject comes only from the server session, never from the query, body or headers.
// GET /api/agent-identity (identity token mode): a ten-minute token for the user your authentication// put on event.locals in src/hooks.server.ts.import { createHmac } from "node:crypto";import { AGENT_SIGNING_KID, AGENT_SIGNING_SECRET } from "$app/env/private";import { AGENT_PUBLIC_KEY } from "$app/env/public";import { methodNotAllowed, noStore, notConfigured } from "#lib/server/agent.ts";import type { RequestHandler } from "./$types";
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: AGENT_PUBLIC_KEY, iat, exp: iat + 600 }); const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url"); return `${header}.${payload}.${signature}`;}
export const GET: RequestHandler = ({ request, locals }) => { if (request.headers.get("sec-fetch-site") !== "same-origin") return Response.json({ error: "forbidden" }, { status: 403, headers: noStore }); if (!locals.user) return Response.json({ error: "unauthorized" }, { status: 401, headers: noStore }); if (!AGENT_SIGNING_SECRET) return notConfigured("AGENT_SIGNING_SECRET"); if (!AGENT_SIGNING_KID) return notConfigured("AGENT_SIGNING_KID"); return Response.json({ identityToken: signIdentityToken(String(locals.user.id), AGENT_SIGNING_SECRET, AGENT_SIGNING_KID) }, { headers: noStore });};
export const fallback: RequestHandler = methodNotAllowed("GET");// Responses shared by the two Bunkhouse endpoints: never cached, errors without details.export const noStore = { "cache-control": "no-store" };
export function notConfigured(variable: string): Response { console.error(`Bunkhouse: ${variable} is not set`); return Response.json({ error: "not_configured" }, { status: 503, headers: noStore });}
/** The route's `fallback` handler for every other method. */export const methodNotAllowed = (allow: string) => () => Response.json({ error: "method_not_allowed" }, { status: 405, headers: { ...noStore, allow } });event.locals.user is the seam to your authentication. Set it in the handle hook from your existing session, and type it in src/app.d.ts:
/** Your existing authentication: the signed-in user of this request on event.locals, or null. */const authentication: Handle = async ({ event, resolve }) => { event.locals.user = await readSession(event.cookies.get(SESSION_COOKIE)); return resolve(event);};declare global { namespace App { interface Locals { /** The signed-in user, set by your authentication in src/hooks.server.ts. */ user: { id: string; name: string; csrfToken: string } | null; } }
interface Window { AgentEmbed?: { resetSession(): void }; }}
export {};In src/app.html, before SvelteKit’s head content, load the identity hook, then the auth-headers hook (see Auth headers), then the loader: window.AgentEmbedConfig must be defined before the loader runs. SvelteKit’s own scripts come later in the document, so the loader is installed before the app starts and makes its first requests, and client-side navigation never re-runs it. Both hooks are external same-origin scripts:
<!-- Identity token mode (recommended): the page hooks first, then the loader. --><script src="/agent-identity.js" nonce="%sveltekit.nonce%"></script><script src="/agent-auth-headers.js" nonce="%sveltekit.nonce%"></script><script src="%sveltekit.env.AGENT_EMBED_ORIGIN%/embed.js" nonce="%sveltekit.nonce%" data-public-key="%sveltekit.env.AGENT_PUBLIC_KEY%" 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; },};The example’s app.html contains all three installation modes; a test-only ?mode= switch in src/hooks.server.ts removes the two that are not selected. Your app.html contains exactly one:
<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <!-- Bunkhouse, before SvelteKit's head content and the app's scripts. This example carries all three installation modes and src/hooks.server.ts keeps the one ?mode= selects; yours has one. --> <!-- #region identity --> <!-- Identity token mode (recommended): the page hooks first, then the loader. --> <script src="/agent-identity.js" nonce="%sveltekit.nonce%"></script> <script src="/agent-auth-headers.js" nonce="%sveltekit.nonce%"></script> <script src="%sveltekit.env.AGENT_EMBED_ORIGIN%/embed.js" nonce="%sveltekit.nonce%" data-public-key="%sveltekit.env.AGENT_PUBLIC_KEY%" data-hints-url="/agent-hints.json"></script> <!-- #endregion --> <!-- #region mint --> <!-- Server mint mode: no identity hook; the embed posts to the same-origin session endpoint. --> <script src="/agent-auth-headers.js" nonce="%sveltekit.nonce%"></script> <script src="%sveltekit.env.AGENT_EMBED_ORIGIN%/embed.js" nonce="%sveltekit.nonce%" data-public-key="%sveltekit.env.AGENT_PUBLIC_KEY%" data-hints-url="/agent-hints.json" data-session-endpoint="/api/agent-session"></script> <!-- #endregion --> <!-- #region anonymous --> <!-- Anonymous mode (enable it for the site first): no identity hook. --> <script src="/agent-auth-headers.js" nonce="%sveltekit.nonce%"></script> <script src="%sveltekit.env.AGENT_EMBED_ORIGIN%/embed.js" nonce="%sveltekit.nonce%" data-public-key="%sveltekit.env.AGENT_PUBLIC_KEY%" data-hints-url="/agent-hints.json"></script> <!-- #endregion --> %sveltekit.head% </head> <body> <div>%sveltekit.body%</div> </body></html>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-auth-headers.js" nonce="%sveltekit.nonce%"></script><script src="%sveltekit.env.AGENT_EMBED_ORIGIN%/embed.js" nonce="%sveltekit.nonce%" data-public-key="%sveltekit.env.AGENT_PUBLIC_KEY%" data-hints-url="/agent-hints.json" data-session-endpoint="/api/agent-session"></script>// POST /api/agent-session (server mint mode): creates a signed session for the user on event.locals// with the server-only mint key and returns the API's response unchanged.import { AGENT_ALLOWED_ORIGINS, AGENT_API_BASE, AGENT_EMBED_SECRET_KEY } from "$app/env/private";import { AGENT_PUBLIC_KEY } from "$app/env/public";import { methodNotAllowed, noStore, notConfigured } from "#lib/server/agent.ts";import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, locals }) => { // 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 || !AGENT_ALLOWED_ORIGINS.includes(origin) || request.headers.get("x-agent-embed") !== "1" || request.headers.get("sec-fetch-site") !== "same-origin") { return Response.json({ error: "forbidden" }, { status: 403, headers: noStore }); } if (!locals.user) return Response.json({ error: "unauthorized" }, { status: 401, headers: noStore }); if (!AGENT_EMBED_SECRET_KEY) return notConfigured("AGENT_EMBED_SECRET_KEY"); try { const upstream = await fetch(`${AGENT_API_BASE}/v1/embed/sessions`, { method: "POST", headers: { authorization: `Bearer ${AGENT_EMBED_SECRET_KEY}`, "content-type": "application/json" }, body: JSON.stringify({ publicKey: AGENT_PUBLIC_KEY, mode: "signed", userId: String(locals.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", ...noStore }, }); } catch { return Response.json({ error: "session_service_unavailable" }, { status: 502, headers: noStore }); }};
export const fallback: RequestHandler = methodNotAllowed("POST");The endpoint 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.
SvelteKit’s built-in CSRF protection rejects cross-origin form submissions (application/x-www-form-urlencoded, multipart/form-data, text/plain or no content type). The embed posts application/json, so the mint route needs no exemption and csrf.trustedOrigins stays empty; the three header checks are that route’s protection. That built-in check still guards your form actions, such as sign-in and sign-out. adapter-node assumes https and the request’s Host unless paths.origin is set at build time; the example’s Dockerfile builds with ORIGIN set to its own origin:
// The app's public origin, for SvelteKit's form CSRF check: adapter-node otherwise assumes https// and the request's Host header. Set ORIGIN when building, e.g. https://app.example.com.paths: { origin: process.env.ORIGIN },Anonymous alternative
Section titled “Anonymous alternative”Anonymous sessions need no identity hook; keep 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. --><script src="/agent-auth-headers.js" nonce="%sveltekit.nonce%"></script><script src="%sveltekit.env.AGENT_EMBED_ORIGIN%/embed.js" nonce="%sveltekit.nonce%" data-public-key="%sveltekit.env.AGENT_PUBLIC_KEY%" 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 form actions enhanced by use:enhance, so the document survives an account change. Call window.AgentEmbed?.resetSession() as soon as the action succeeds, before update() reloads the new user’s data; the next assistant run then starts a session for whoever is signed in now:
// Sign-in and sign-out keep this document, so the previous user's assistant session is dropped as// soon as the account changes, before the new user's data loads.const resetAgentSession: SubmitFunction = () => async ({ result, update }) => { if (result.type === "redirect") window.AgentEmbed?.resetSession(); await update({ navigate: false });};Use the same callback on every form or fetch call that signs in, signs out or switches accounts. Without JavaScript the forms post normally and the redirect loads a new document, which starts a new embed with no session. Links between pages use SvelteKit’s client-side router; the loader lives in app.html, outside the routed components, so navigation keeps one loader and one launcher. Do not add it with <svelte:head> or onMount.
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 by the root layout into a <meta> tag for signed-in users (<svelte:head> updates it after an enhanced sign-in) and checked 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, so it works with the identity hook in either order; both load before the loader in every mode. 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.
Configure SvelteKit’s CSP in nonce mode. SvelteKit adds a fresh nonce per response to script-src for its own inline startup script and to every %sveltekit.nonce% in app.html; no 'unsafe-inline' or 'unsafe-eval' is needed. SvelteKit’s route announcer, a visually hidden live region it adds to every page, carries an inline style attribute; style-src-attr allows exactly that attribute by its hash (computed for SvelteKit 3.1.0) instead of 'unsafe-inline':
// The app's existing Content-Security-Policy. SvelteKit adds a fresh nonce per response to// script-src and to %sveltekit.nonce% in src/app.html; src/hooks.server.ts adds the Bunkhouse// origins, which are runtime configuration.csp: { mode: "nonce", directives: { "default-src": ["self"], "script-src": ["self"], "style-src": ["self"], // SvelteKit's route announcer (a visually hidden live region) has one inline style // attribute; allow exactly that one by its hash. "style-src-attr": ["unsafe-hashes", "sha256-S8qMpvofolR8Mpjy4kQvEm7m1q8clzU4dfDH0AmvZjo="], "connect-src": ["self"], "img-src": ["self", "data:"], "object-src": ["none"], "base-uri": ["self"], "form-action": ["self"], "frame-ancestors": ["self"], },},The csp option is fixed at build time, while the Bunkhouse origins are runtime configuration. The handle hook adds them to SvelteKit’s policy for each page response: the embed origin to script-src and frame-src, the API to connect-src:
import { sequence, type Handle } from "@sveltejs/kit/hooks";import { AGENT_API_BASE } from "$app/env/private";import { AGENT_EMBED_ORIGIN } from "$app/env/public";import { readSession, SESSION_COOKIE } from "#lib/server/demo.ts";
// #region auth/** Your existing authentication: the signed-in user of this request on event.locals, or null. */const authentication: Handle = async ({ event, resolve }) => { event.locals.user = await readSession(event.cookies.get(SESSION_COOKIE)); return resolve(event);};// #endregion
// #region csp/** SvelteKit's policy for this response (nonce included) plus the Bunkhouse origins. */function withAgentSources(policy: string): string { const additions = { "script-src": AGENT_EMBED_ORIGIN, "frame-src": AGENT_EMBED_ORIGIN, "connect-src": AGENT_API_BASE }; const directives = policy.split(";").map((d) => d.trim()).filter(Boolean); for (const [name, source] of Object.entries(additions)) { const i = directives.findIndex((d) => d.split(" ")[0] === name); if (i === -1) directives.push(`${name} ${source}`); else directives[i] += ` ${source}`; } return directives.join("; ");}// #endregion
const agentEmbed: Handle = async ({ event, resolve }) => { // Test-shell switch between the three installation modes (?mode=): keeps that src/app.html region. const requested = event.url.searchParams.get("mode"); const mode = requested === "mint" || requested === "anonymous" ? requested : "identity"; const response = await resolve(event, { transformPageChunk: ({ html }) => html.replace(/[ \t]*<!-- #region (identity|mint|anonymous) -->[\s\S]*?<!-- #endregion -->\n/g, (block, name: string) => (name === mode ? block : "")), }); const policy = response.headers.get("content-security-policy"); if (policy) response.headers.set("content-security-policy", withAgentSources(policy)); return response;};
export const handle = sequence(authentication, agentEmbed);If your origins are fixed, you can list them in csp.directives instead.
Verify
Section titled “Verify”From a checkout of the Bunkhouse repository, with Docker and local Supabase running:
node tests/framework-integrations/run.ts sveltekitThe 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, client-side 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 |
|---|---|
The app does not start: Invalid environment variables |
Set the variables src/env.ts names as required, with exact origins. |
/api/agent-identity returns 401 |
event.locals.user is not set for the request: your handle hook must read the session for API routes too. |
| 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. |
Cross-site POST form submissions are forbidden on sign-in |
SvelteKit derived the wrong origin for its form CSRF check; set paths.origin to your public origin. |
| 503 | The signing secret, key ID or mint key for the mode you use is not set. |
| The pane reports a CSP block | The handle hook must add the embed origin to script-src and frame-src and the API to connect-src. A blocked inline style attribute after a SvelteKit upgrade means the announcer’s style-src-attr hash changed. |
| Startup requests are not discovered | The loader must be in app.html before SvelteKit’s head content, not in a component. |
| The previous user’s session continues after sign-in | Call resetSession() in the use:enhance callback of every account change. |
| Two launchers | Render the loader once, in app.html. |
| A secret in page source | Only AGENT_PUBLIC_KEY and AGENT_EMBED_ORIGIN are public: true; import ss_ and sk_ values only from $app/env/private. |
Related
Section titled “Related”- Installation, Security and the Page API
- SvelteKit: Routing, Hooks, Auth, Form actions, Environment variables, CSP configuration, adapter-node