Skip to content

Actix Web

This guide adds Bunkhouse to a server-rendered Actix Web application. Every file shown below is part of a runnable example app that is tested end to end against the real Bunkhouse API and embed.

Framework Actix Web 4.18.1, actix-session 0.11 (cookie store), awc 3.11 (rustls), jsonwebtoken 11.1
Runtime Stable Rust (rustc 1.99.1 in the tested image), release build on Debian bookworm-slim
Example apps/docs/integrations/actix-web/ 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 actix-session 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
src/config.rs Validates the public Bunkhouse configuration at startup
src/agent.rs GET /api/agent-identity and POST /api/agent-session
src/csp.rs Your Content-Security-Policy with the Bunkhouse sources added
templates/agent-embed-*.html The script tags for one installation mode
public/agent-identity.js, public/agent-auth-headers.js Page hooks, loaded before the loader

The Bunkhouse code uses these crates. awc makes the server mint request; it needs one rustls crypto provider:

Cargo.toml
[package]
name = "bunkhouse-actix-web-example"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
actix-web = { version = "=4.18.1", default-features = false, features = ["macros", "cookies", "secure-cookies"] }
actix-session = { version = "=0.11.0", features = ["cookie-session"] }
awc = { version = "=3.11.0", default-features = false, features = ["rustls-0_23-webpki-roots"] }
jsonwebtoken = { version = "=11.1.0", default-features = false, features = ["rust_crypto"] }
rand = "=0.9.5"
# awc's TLS (rustls) needs exactly one crypto provider; ring is pure cc, no cmake.
rustls = { version = "=0.23.46", default-features = false, features = ["ring"] }
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.154"
url = "=2.5.8"

Set the server environment. Only AGENT_PUBLIC_KEY and the origins ever reach the browser:

Terminal window
AGENT_PUBLIC_KEY=pk_...
AGENT_SIGNING_SECRET=ss_... # server only
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
src/config.rs
// 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.
use std::env;
use url::Url;
pub struct AgentConfig {
pub public_key: String,
pub allowed_origins: Vec<String>,
pub api_base: String,
pub embed_origin: String,
}
fn exact_origin(name: &str, value: &str) -> Result<String, String> {
match Url::parse(value) {
Ok(url) if url.origin().ascii_serialization() == value => Ok(value.to_owned()),
_ => Err(format!("{name} must be an exact origin like https://app.example.com")),
}
}
impl AgentConfig {
pub fn from_env() -> Result<Self, String> {
let public_key = env::var("AGENT_PUBLIC_KEY").unwrap_or_default();
if !public_key.starts_with("pk_") {
return Err("AGENT_PUBLIC_KEY must be the site's public key (pk_...)".into());
}
let origins: Vec<String> = serde_json::from_str(&env::var("AGENT_ALLOWED_ORIGINS").unwrap_or_else(|_| "[]".into()))
.map_err(|_| r#"AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]"#)?;
if origins.is_empty() {
return Err(r#"AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]"#.into());
}
Ok(Self {
public_key,
allowed_origins: origins.iter().map(|o| exact_origin("AGENT_ALLOWED_ORIGINS", o)).collect::<Result<_, _>>()?,
api_base: exact_origin("AGENT_API_BASE", &env::var("AGENT_API_BASE").unwrap_or_else(|_| "https://api.bunkhouse.ai".into()))?,
embed_origin: exact_origin("AGENT_EMBED_ORIGIN", &env::var("AGENT_EMBED_ORIGIN").unwrap_or_else(|_| "https://embed.bunkhouse.ai".into()))?,
})
}
}

The handlers sign a ten-minute HS256 token with jsonwebtoken for the user your session already authenticated. They reject other methods (405 with Allow), requests whose Sec-Fetch-Site is not same-origin (403) and signed-out users (401, never a login redirect), and mark every response Cache-Control: no-store. The subject comes only from the server session, never from the query, body or headers. The same file contains the server mint endpoint used by the alternative below.

src/agent.rs
// 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.
use std::env;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use actix_web::http::{StatusCode, header};
use actix_web::{HttpRequest, HttpResponse, HttpResponseBuilder, web};
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
use serde::Serialize;
use serde_json::json;
use crate::config::AgentConfig;
/// Your existing authentication: the signed-in user's stable, opaque ID. Its `FromRequest`
/// implementation is the seam to your sessions; the endpoints receive `None` when signed out.
pub struct CurrentUser {
pub id: String,
}
#[derive(Serialize)]
struct Claims<'a> {
sub: &'a str,
aud: &'a str,
iat: u64,
exp: u64,
}
/// HS256 identity token: kid header, sub = stable opaque user ID, aud = public key, 10 minutes.
pub fn sign_identity_token(user_id: &str, public_key: &str, secret: &str, kid: &str) -> jsonwebtoken::errors::Result<String> {
let iat = SystemTime::now().duration_since(UNIX_EPOCH).expect("system clock before 1970").as_secs();
let mut header = Header::new(Algorithm::HS256);
header.kid = Some(kid.to_owned());
let claims = Claims { sub: user_id, aud: public_key, iat, exp: iat + 600 };
encode(&header, &claims, &EncodingKey::from_secret(secret.as_bytes()))
}
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::resource("/api/agent-identity")
.route(web::get().to(identity))
.default_service(web::to(|| method_not_allowed("GET"))),
)
.service(
web::resource("/api/agent-session")
.route(web::post().to(session))
.default_service(web::to(|| method_not_allowed("POST"))),
);
}
fn no_store(status: StatusCode) -> HttpResponseBuilder {
let mut res = HttpResponse::build(status);
res.insert_header((header::CACHE_CONTROL, "no-store"));
res
}
fn error(status: StatusCode, code: &str) -> HttpResponse {
no_store(status).json(json!({ "error": code }))
}
async fn method_not_allowed(allow: &'static str) -> HttpResponse {
no_store(StatusCode::METHOD_NOT_ALLOWED)
.insert_header((header::ALLOW, allow))
.json(json!({ "error": "method_not_allowed" }))
}
/// A server-only credential, read where it is used. Missing: 503, logging only the variable name.
fn credential(name: &str) -> Result<String, HttpResponse> {
env::var(name).ok().filter(|v| !v.is_empty()).ok_or_else(|| {
eprintln!("Bunkhouse: {name} is not set");
error(StatusCode::SERVICE_UNAVAILABLE, "not_configured")
})
}
fn header_value<'a>(req: &'a HttpRequest, name: &str) -> Option<&'a str> {
req.headers().get(name)?.to_str().ok()
}
async fn identity(req: HttpRequest, user: Option<CurrentUser>, agent: web::Data<AgentConfig>) -> HttpResponse {
if header_value(&req, "sec-fetch-site") != Some("same-origin") {
return error(StatusCode::FORBIDDEN, "forbidden");
}
let Some(user) = user else { return error(StatusCode::UNAUTHORIZED, "unauthorized") };
let (secret, kid) = match (credential("AGENT_SIGNING_SECRET"), credential("AGENT_SIGNING_KID")) {
(Ok(secret), Ok(kid)) => (secret, kid),
(Err(res), _) | (_, Err(res)) => return res,
};
match sign_identity_token(&user.id, &agent.public_key, &secret, &kid) {
Ok(token) => no_store(StatusCode::OK).json(json!({ "identityToken": token })),
Err(_) => error(StatusCode::INTERNAL_SERVER_ERROR, "signing_failed"),
}
}
async fn session(req: HttpRequest, user: Option<CurrentUser>, agent: web::Data<AgentConfig>, client: web::Data<awc::Client>) -> HttpResponse {
// The embed's POST carries no CSRF token; these three checks are this route's CSRF defense.
let origin = header_value(&req, "origin").filter(|o| agent.allowed_origins.iter().any(|a| a == o));
let (Some(origin), Some("1"), Some("same-origin")) = (origin, header_value(&req, "x-agent-embed"), header_value(&req, "sec-fetch-site")) else {
return error(StatusCode::FORBIDDEN, "forbidden");
};
let Some(user) = user else { return error(StatusCode::UNAUTHORIZED, "unauthorized") };
let key = match credential("AGENT_EMBED_SECRET_KEY") {
Ok(key) => key,
Err(res) => return res,
};
let upstream = client
.post(format!("{}/v1/embed/sessions", agent.api_base))
.insert_header((header::AUTHORIZATION, format!("Bearer {key}")))
.timeout(Duration::from_secs(15))
.send_json(&json!({ "publicKey": agent.public_key, "mode": "signed", "userId": user.id, "origin": origin }))
.await;
let Ok(mut upstream) = upstream else { return error(StatusCode::BAD_GATEWAY, "session_service_unavailable") };
let Ok(body) = upstream.body().await else { return error(StatusCode::BAD_GATEWAY, "session_service_unavailable") };
let content_type = upstream.headers().get(header::CONTENT_TYPE).cloned().unwrap_or(header::HeaderValue::from_static("application/json"));
no_store(upstream.status()).insert_header((header::CONTENT_TYPE, content_type)).body(body)
}

The endpoints take Option<CurrentUser>. Its FromRequest implementation is the seam to your authentication: return the signed-in user’s stable ID, or fail extraction when signed out. Register the routes inside your session middleware with configure:

src/main.rs (excerpt)
/// The signed-in user of this request (your existing authentication). The Bunkhouse endpoints take
/// `Option<CurrentUser>`, so a failed extraction means "signed out".
impl FromRequest for CurrentUser {
type Error = actix_web::Error;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
let user = req.get_session().get::<DemoUser>("user").ok().flatten();
ready(user.map(|u| CurrentUser { id: u.id }).ok_or_else(|| actix_web::error::ErrorUnauthorized("signed out")))
}
}
src/main.rs (excerpt)
// Bunkhouse endpoints, inside the session middleware so they see the signed-in user.
.configure(agent::configure)

In the page head, window.AgentEmbedConfig must be defined before the loader: load the identity hook, then the auth-headers hook (see Auth headers), then the loader, all before your application’s own scripts. The hooks are external same-origin scripts, so no inline script needs a CSP exception. The example compiles its templates in with include_str! and fills only HTML-escaped slots ({{nonce}}, {{embed_origin}}, {{public_key}}); a template engine renders the same tags:

templates/agent-embed-identity.html
<!-- Identity token mode (recommended): the page hooks first, then the loader, before the app's scripts. -->
<script src="/agent-identity.js" nonce="{{nonce}}"></script>
<script src="/agent-auth-headers.js" nonce="{{nonce}}"></script>
<script src="{{embed_origin}}/embed.js" nonce="{{nonce}}"
data-public-key="{{public_key}}" data-hints-url="/agent-hints.json"></script>
public/agent-identity.js
// 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;
},
};
templates/index.html (excerpt)
{{csrf_meta}}
<!-- One agent-embed-*.html fragment: the page hooks, then the loader. -->
{{agent_embed}}
<!-- The application's own JavaScript comes after the loader. -->
<script src="/app.js" nonce="{{nonce}}" defer></script>

The example page inserts one of the three agent-embed-* fragments at {{agent_embed}}, selected by a test-only ?mode= switch; each fragment holds that mode’s page hooks followed by the loader. Your application uses exactly one.

Instead of the identity hook, add data-session-endpoint to the loader; the auth-headers hook still loads first. Do not keep agent-identity.js on the page: when getIdentityToken is defined it takes precedence.

templates/agent-embed-mint.html
<!-- Server mint mode: no identity hook; the embed posts to the same-origin session endpoint. -->
<script src="/agent-auth-headers.js" nonce="{{nonce}}"></script>
<script src="{{embed_origin}}/embed.js" nonce="{{nonce}}"
data-public-key="{{public_key}}" data-hints-url="/agent-hints.json"
data-session-endpoint="/api/agent-session"></script>

POST /api/agent-session in src/agent.rs 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 through awc 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.

Actix Web has no built-in CSRF middleware. If you use one, 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; the example keeps its auth-headers hook in front of it. 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.

templates/agent-embed-anonymous.html
<!-- Anonymous mode (enable it for the site first): the auth-headers hook, then the loader. -->
<script src="/agent-auth-headers.js" nonce="{{nonce}}"></script>
<script src="{{embed_origin}}/embed.js" nonce="{{nonce}}"
data-public-key="{{public_key}}" data-hints-url="/agent-hints.json"></script>

The example 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 session cookie is private (encrypted and authenticated), HttpOnly and SameSite, and every sign-in renews the session:

src/main.rs (excerpt)
.wrap(
SessionMiddleware::builder(CookieSessionStore::default(), key.clone())
.cookie_name("example.sid".into())
.cookie_content_security(CookieContentSecurity::Private)
.cookie_http_only(true)
.cookie_same_site(SameSite::Lax)
.cookie_secure(secure)
.build(),
)

If your app changes the signed-in user without a full page load (a fetch login, a single-page frontend), 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, not in per-page templates.

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:

public/agent-auth-headers.js
// 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 alongside the identity hook, and every mode’s fragment loads it 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. The example’s from_fn middleware sets a fresh nonce per response, stores it as a request extension for the page handler and puts it on the script tags; no 'unsafe-inline' or 'unsafe-eval' is needed:

src/csp.rs
// 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 (the `CspNonce` request
// extension) goes on the page's script tags.
use actix_web::body::MessageBody;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::http::header::{CONTENT_SECURITY_POLICY, HeaderValue};
use actix_web::middleware::Next;
use actix_web::{Error, HttpMessage, web};
use crate::config::AgentConfig;
#[derive(Clone)]
pub struct CspNonce(pub String);
pub async fn content_security_policy(req: ServiceRequest, next: Next<impl MessageBody>) -> Result<ServiceResponse<impl MessageBody>, Error> {
let nonce: String = rand::random::<[u8; 16]>().iter().map(|b| format!("{b:02x}")).collect();
let agent = req.app_data::<web::Data<AgentConfig>>().expect("AgentConfig app data").clone();
req.extensions_mut().insert(CspNonce(nonce.clone()));
let mut res = next.call(req).await?;
let policy = [
"default-src 'self'".to_owned(),
format!("script-src 'self' 'nonce-{nonce}' {}", agent.embed_origin),
format!("style-src 'self' 'nonce-{nonce}'"),
format!("frame-src {}", agent.embed_origin),
format!("connect-src 'self' {}", agent.api_base),
"img-src 'self' data:".to_owned(),
"object-src 'none'".to_owned(),
"base-uri 'self'".to_owned(),
"form-action 'self'".to_owned(),
"frame-ancestors 'self'".to_owned(),
]
.join("; ");
res.headers_mut().insert(CONTENT_SECURITY_POLICY, HeaderValue::from_str(&policy)?);
Ok(res)
}

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

Terminal window
node tests/framework-integrations/run.ts actix-web

The 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 The routes are registered outside your session middleware, or the CurrentUser extractor does not find the user.
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 must exempt only /api/agent-session.
503 The signing secret, key ID or mint key for the mode you use is not set.
Mint returns 502 The server cannot reach AGENT_API_BASE; awc needs a rustls crypto provider and root certificates (the example uses webpki roots).
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 come before your application’s scripts in the head.
Two launchers Render the loader once, in the layout.
A secret in page source Only AGENT_PUBLIC_KEY and origins belong in templates; keep ss_ and sk_ values in server environment variables.