Astro
This guide adds Bunkhouse to an Astro application rendered on demand (output: "server") on the Node adapter, the hosting model the example is tested with. 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 | Astro 7.4.4, server output, @astrojs/node 11.1.9 in standalone mode |
| Runtime | Node.js 24 (v24.22.2 in the tested image) |
| Example | apps/docs/integrations/astro/ 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, in
Astro.locals.
| File | Purpose |
|---|---|
src/config.ts |
Validates the public Bunkhouse configuration |
src/middleware.ts |
Puts the signed-in user in context.locals.user |
src/pages/api/agent-identity.ts |
GET /api/agent-identity |
src/pages/api/agent-session.ts |
POST /api/agent-session (server mint alternative) |
src/components/AgentEmbed*.astro |
The script tags for one installation mode |
src/layouts/Layout.astro |
Renders the tags in the head and adds the Bunkhouse CSP sources |
public/agent-identity.js, public/agent-auth-headers.js |
Page hooks, loaded before the loader |
astro.config.mjs |
Server output, origin check and the app’s Content-Security-Policy |
Identity token (recommended)
Section titled “Identity token (recommended)”Set the server environment. Only AGENT_PUBLIC_KEY and the origins ever reach the browser. The example reads them from process.env at runtime on the server, so they are not baked into the build:
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 when the server loads it (src/middleware.ts imports it).// 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"),};Astro’s native place for the signed-in user is locals. Middleware resolves your session once per request; this is the seam to your authentication: set context.locals.user to the signed-in user, or null.
// Your authentication in Astro's native seam: middleware resolves the signed-in user once per request// into context.locals.user, which pages and the Bunkhouse endpoints read.import { defineMiddleware } from "astro:middleware";// Astro loads server modules on first use; importing the configuration here validates it before any// route answers, so a misconfigured server fails its first request (and health check).import "./config.ts";import { readSession } from "./session.ts";
export const onRequest = defineMiddleware(async (context, next) => { context.locals.user = await readSession(context.cookies); return next();});import type { SessionUser } from "./session.ts";
declare global { namespace App { interface Locals { /** The signed-in user (set by src/middleware.ts), or null. */ user: SessionUser | null; } }}The endpoint signs a ten-minute HS256 token for that user. It rejects other methods (405, via the ALL export), 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 locals, never from the query, body or headers.
// GET /api/agent-identity (identity token mode): a ten-minute HS256 token for the user your existing// session already authenticated (context.locals.user, set by src/middleware.ts).import { createHmac } from "node:crypto";import type { APIRoute } from "astro";import { agentConfig } from "../../config.ts";
const json = (status: number, body: object, 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}`;}
export const GET: APIRoute = ({ request, locals }) => { if (request.headers.get("sec-fetch-site") !== "same-origin") return json(403, { error: "forbidden" }); const user = locals.user; if (!user) return json(401, { error: "unauthorized" }); const { AGENT_SIGNING_SECRET: secret, AGENT_SIGNING_KID: kid } = process.env; if (!secret || !kid) { console.error(`Bunkhouse: ${secret ? "AGENT_SIGNING_KID" : "AGENT_SIGNING_SECRET"} is not set`); return json(503, { error: "not_configured" }); } return json(200, { identityToken: signIdentityToken(String(user.id), secret, kid) });};
export const ALL: APIRoute = () => json(405, { error: "method_not_allowed" }, { Allow: "GET" });In the layout’s head, render both page hooks and then the loader, before your application’s own scripts: the identity hook, the getAuthHeaders hook (see Auth headers), then embed.js, so window.AgentEmbedConfig is complete when the loader runs. is:inline tells Astro to leave all three as classic external scripts in place instead of bundling them, so the loader runs before any module script. The hooks are external same-origin scripts, so no inline script needs a CSP exception:
---// Identity token mode (recommended): the page hooks first (identity, then the app's auth headers),// then the loader, before the app's scripts. is:inline keeps all three as classic external scripts,// exactly as written.import { agentConfig } from "../config.ts";---
<script is:inline src="/agent-identity.js"></script><script is:inline src="/agent-auth-headers.js"></script><script is:inline src={`${agentConfig.embedOrigin}/embed.js`} 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; },};{user && <meta name="csrf-token" content={user.csrfToken} />}{/* The page hooks and the loader for one installation mode. */}<AgentEmbed />{/* The application's own JavaScript (bundled by Astro as a module script) comes after the loader. */}<script> const out = document.getElementById("startup-items")!; const { items } = (await (await fetch("/api/startup-items", { credentials: "same-origin" })).json()) as { items: Array<{ name: string }> }; out.textContent = `Startup items: ${items.map((item) => item.name).join(", ")}`;</script>The example layout picks one of the three AgentEmbed* components with a test-only ?mode= switch. Your application renders exactly one.
Server mint alternative
Section titled “Server mint alternative”Instead of the identity hook, add data-session-endpoint to the loader; the getAuthHeaders hook still comes first. Do not keep agent-identity.js on the page: when getIdentityToken is defined it takes precedence.
---// Server mint mode: no identity hook; the auth-headers hook, then the loader, which posts to the// same-origin session endpoint.import { agentConfig } from "../config.ts";---
<script is:inline src="/agent-auth-headers.js"></script><script is:inline src={`${agentConfig.embedOrigin}/embed.js`} data-public-key={agentConfig.publicKey} 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 your existing// session already authenticated (context.locals.user, set by src/middleware.ts).import type { APIRoute } from "astro";import { agentConfig } from "../../config.ts";
const json = (status: number, body: object, headers: Record<string, string> = {}) => Response.json(body, { status, headers: { "Cache-Control": "no-store", ...headers } });
export const POST: APIRoute = 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 || !agentConfig.allowedOrigins.includes(origin) || request.headers.get("x-agent-embed") !== "1" || request.headers.get("sec-fetch-site") !== "same-origin") { return json(403, { error: "forbidden" }); } const user = locals.user; if (!user) return json(401, { error: "unauthorized" }); const key = process.env.AGENT_EMBED_SECRET_KEY; if (!key) { console.error("Bunkhouse: AGENT_EMBED_SECRET_KEY is not set"); return json(503, { error: "not_configured" }); } 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 json(502, { error: "session_service_unavailable" }); }};
export const ALL: APIRoute = () => json(405, { error: "method_not_allowed" }, { Allow: "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 through locals (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.
Astro’s built-in origin check (security.checkOrigin, on by default for on-demand routes) rejects cross-site form posts. The embed sends its mint request as JSON, which the check does not cover, so no exemption is needed; the three header checks are that route’s CSRF protection. If you add a CSRF middleware of your own, exempt only /api/agent-session.
Anonymous alternative
Section titled “Anonymous alternative”Anonymous sessions need only the loader (after the getAuthHeaders hook, if your API needs extra headers). 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-headers hook, then the loader.import { agentConfig } from "../config.ts";---
<script is:inline src="/agent-auth-headers.js"></script><script is:inline src={`${agentConfig.embedOrigin}/embed.js`} 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 uses Astro’s default multi-page navigation (plain links) and 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. The demo session cookie is HttpOnly and SameSite, and each sign-in issues a new session with a new CSRF token:
/** Replaces any previous session (and its CSRF token) with one for `user`. */export async function startSession(cookies: AstroCookies, 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); // Secure everywhere except the loopback HTTP test origin. cookies.set(SESSION_COOKIE, token, { path: "/", httpOnly: true, sameSite: "lax", secure: !process.env.APP_ORIGIN?.startsWith("http://") });}If your Astro app changes the signed-in user without a full page load (a fetch login, or <ClientRouter /> view transitions that keep the document), 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’s head, not in per-page components.
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 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 } : {}; },};Each AgentEmbed* component loads this hook before the loader. It merges into window.AgentEmbedConfig, so it keeps the identity hook’s getIdentityToken. 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.
The example uses Astro’s built-in Content-Security-Policy support. security.csp holds the app’s existing policy; Astro hashes its own bundled scripts and sends the policy as a response header on every on-demand page, so no 'unsafe-inline' or 'unsafe-eval' is needed:
security: { // Astro's built-in origin check (on by default) rejects cross-site form posts, such as the demo // login and logout. The JSON mint POST is not a form post; its own guards protect it. checkOrigin: true, // The app's existing policy. Astro adds hashes for its own scripts and sends the header on every // on-demand page; src/layouts/Layout.astro adds the Bunkhouse sources at render time. csp: { directives: ["default-src 'self'", "img-src 'self' data:", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'self'"], scriptDirective: { resources: ["'self'"] }, styleDirective: { resources: ["'self'"] }, },},The layout adds the Bunkhouse sources at render time with the Astro.csp API, because the embed and API origins come from the server environment rather than the build:
// Add the Bunkhouse sources to the app's policy (astro.config.mjs) for this response.Astro.csp.insertScriptResource(agentConfig.embedOrigin);Astro.csp.insertDirective(`frame-src ${agentConfig.embedOrigin}`);Astro.csp.insertDirective(`connect-src 'self' ${agentConfig.apiBase}`);Verify
Section titled “Verify”From a checkout of the Bunkhouse repository, with Docker and local Supabase running:
node tests/framework-integrations/run.ts astroThe 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 |
Your middleware does not set context.locals.user for this request, or does not find the session cookie. |
| 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 middleware of your own 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 | The layout must add the embed origin to script-src and frame-src and the API to connect-src; insertScriptResource replaces Astro’s default 'self', so keep 'self' in scriptDirective.resources. |
| Startup requests are not discovered | The loader must be an is:inline script in the head, ahead of your bundled scripts. |
| Two launchers | Render the loader once, in the layout. |
| A secret in page source | Only AGENT_PUBLIC_KEY and origins belong in components; read ss_ and sk_ values from process.env in server endpoints only, never through import.meta.env with a PUBLIC_ prefix. |
Related
Section titled “Related”- Installation, Security and the Page API
- Astro: Endpoints, Middleware, Content Security Policy, Node adapter