Skip to content

Angular

This guide adds Bunkhouse to an Angular single-page app. Everything in an Angular build, including environment files, ships to the browser, so Angular code cannot hold a signing secret or mint key. 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.

Framework Angular 22.2.3 (standalone components, zoneless, Router and HttpClient), production build with the Angular CLI application builder 22.2.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/angular/ 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-session with two demo users and the Angular app signs in with HttpClient; your own authentication takes its place. Bunkhouse only needs the signed-in user’s stable ID on the server.
File Runs in Purpose
src/index.html Browser The page shell: the CSRF meta tag, the page hooks and the Bunkhouse script tags in the head, before the app’s entry script
src/app/auth.service.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

Set the backend’s environment. Only AGENT_PUBLIC_KEY and the origins ever reach the browser:

Terminal window
AGENT_PUBLIC_KEY=pk_...
AGENT_SIGNING_SECRET=ss_... # server only, never in the Angular build
AGENT_SIGNING_KID=... # key ID shown with the signing secret
AGENT_ALLOWED_ORIGINS='["https://app.example.com"]'
# AGENT_API_BASE and AGENT_EMBED_ORIGIN default to https://api.bunkhouse.ai and https://embed.bunkhouse.ai

Never put the signing secret or mint key in an Angular environment file or anywhere else in the Angular build: anything bundled is public.

src/config.ts (Express backend)
// 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.

src/agent.ts (Express backend)
// 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.

src/server.ts (Express backend, excerpt)
/** 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));

The loader is a classic script in the document head. The Angular CLI adds the app’s entry module at the end of the body, so the loader runs first. Install it in src/index.html, not from a component, afterNextRender or an app initializer: it must run before Angular starts making requests, exactly once per page. In the example, src/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:

src/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Angular example</title>
<base href="/" />
<!-- The app keeps the signed-in session's CSRF token here (src/app/auth.service.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 -->
</head>
<body>
<!-- ngCspNonce: the server's per-response nonce, which Angular puts on the styles it adds. -->
<app-root ngCspNonce="__CSP_NONCE__"></app-root>
</body>
</html>
src/server.ts (Express backend, excerpt)
// 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));
});
views/agent-embed-identity.ejs (Express backend)
<%# 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:

public/agent-identity.js (Express backend)
// 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 Angular build from a static host instead, put the same script tags with literal values in the head of src/index.html, after the auth-headers hook, 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.

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.

views/agent-embed-mint.ejs (Express backend)
<%# 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 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.

views/agent-embed-anonymous.ejs (Express backend)
<%# 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>

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 service method that completes the auth action, after the server confirms it, not in an effect() or a component hook that reacts to the new state later.

The example’s login form posts JSON with HttpClient; the backend requires the app’s exact Origin on sign-in and sign-out and regenerates the session on login. login and logout update the session signal and reset the embed as soon as the request succeeds:

src/app/auth.service.ts
// 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. The browser holds no Bunkhouse secret; the backend signs and mints.
import { HttpClient } from "@angular/common/http";
import { inject, Injectable, signal } from "@angular/core";
import { Meta } from "@angular/platform-browser";
import { firstValueFrom } from "rxjs";
declare global {
interface Window {
AgentEmbed?: { resetSession(): void };
}
}
export interface Session {
user: string | null;
csrfToken: string | null;
}
@Injectable({ providedIn: "root" })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly meta = inject(Meta);
readonly user = signal<string | null>(null);
private set(session: Session) {
this.user.set(session.user);
// The CSRF token the backend checks on every write; agent-auth-headers.js reads it for the assistant.
this.meta.updateTag({ name: "csrf-token", content: session.csrfToken ?? "" });
}
async load(): Promise<void> {
this.set(await firstValueFrom(this.http.get<Session>("/api/session")));
}
async login(username: string, password: string): Promise<void> {
this.set(await firstValueFrom(this.http.post<Session>("/login", { username, password })));
window.AgentEmbed?.resetSession(); // new user (or account switch): drop the previous assistant session
}
async logout(): Promise<void> {
this.set(await firstValueFrom(this.http.post<Session>("/logout", {})));
window.AgentEmbed?.resetSession();
}
}

Angular Router navigation keeps the same document, so the loader, launcher and pane persist across routes; do not reset on route changes.

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 src/index.html (see src/app/auth.service.ts above, which uses Angular’s Meta service). The backend’s hook script, loaded in src/index.html before the loader, merges getAuthHeaders into window.AgentEmbedConfig and reads that tag on every call:

public/agent-auth-headers.js (Express backend)
// 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. If your app already adds its CSRF header to its own requests with an HttpClient interceptor, take the token from the same source; interceptors do not apply to the assistant’s calls.

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 production build is ahead-of-time compiled, so it needs no 'unsafe-eval'. ngCspNonce on the root element (filled with the per-response nonce by the backend, see src/index.html above) gives the entry script and the styles Angular adds the nonce, which keeps the build free of 'unsafe-inline'. The backend sets the policy with a fresh nonce per response:

src/csp.ts (Express backend)
// 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();
}

From a checkout of the Bunkhouse repository, with Docker and local Supabase running:

Terminal window
node tests/framework-integrations/run.ts angular

The run builds the Angular 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. Blocked Angular scripts or styles mean ngCspNonce is missing from the root element.
Startup requests are not discovered The loader must be in the head of src/index.html, not added by a component or initializer.
Two launchers Render the loader once, in src/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.