Skip to content

Django

This guide adds Bunkhouse to a server-rendered Django 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 Django 6.1.2, Django templates, django.contrib.auth with database sessions, Django’s built-in CSP
Runtime Python 3.14 (3.14.10 in the tested image), Gunicorn 26.2 with DEBUG = False, WhiteNoise 6.12 for static files
Example apps/docs/integrations/django/ 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 Django’s own authentication with two demo users; your login takes its place. Bunkhouse only needs one thing from it: request.user, set by AuthenticationMiddleware, and a stable ID for that user.
File Purpose
agent/config.py Validates the public Bunkhouse configuration at startup
agent/views.py, agent/urls.py GET /api/agent-identity and POST /api/agent-session
agent/context_processors.py Public configuration for the loader tags
templates/agent/embed_*.html The page hooks and loader tag for one installation mode
static/agent-identity.js, static/agent-auth-headers.js Page hooks, loaded before the loader
example/settings.py Context processors and your SECURE_CSP with the Bunkhouse sources added

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

settings.py imports this module, so a misconfigured public key or origin stops the project at startup:

agent/config.py
# Bunkhouse public configuration, validated once at startup (imported by settings). Signing and mint
# credentials are read only by the views that use them, so each mode needs only its own secret.
import json
import os
from dataclasses import dataclass
from urllib.parse import urlsplit
def _canonical_origin(value: str) -> str | None:
try:
url = urlsplit(value)
port = url.port
except ValueError: # invalid or out-of-range port
return None
host = url.hostname # lowercased; userinfo, path and query are dropped
if url.scheme not in ("http", "https") or not host or not host.isascii():
return None
host = f"[{host}]" if ":" in host else host
default = {"http": 80, "https": 443}[url.scheme]
return f"{url.scheme}://{host}" + ("" if port in (None, default) else f":{port}")
def _exact_origin(name: str, value: str) -> str:
# Like `new URL(value).origin === value`: anything but the canonical origin is rejected.
if _canonical_origin(value) != value:
raise RuntimeError(f"{name} must be an exact origin like https://app.example.com")
return value
@dataclass(frozen=True)
class AgentConfig:
public_key: str
allowed_origins: tuple[str, ...]
api_base: str
embed_origin: str
def _load() -> AgentConfig:
public_key = os.environ.get("AGENT_PUBLIC_KEY", "")
if not public_key.startswith("pk_"):
raise RuntimeError("AGENT_PUBLIC_KEY must be the site's public key (pk_...)")
origins = json.loads(os.environ.get("AGENT_ALLOWED_ORIGINS", "[]"))
if not isinstance(origins, list) or not origins:
raise RuntimeError('AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]')
return AgentConfig(
public_key=public_key,
allowed_origins=tuple(_exact_origin("AGENT_ALLOWED_ORIGINS", str(o)) for o in origins),
api_base=_exact_origin("AGENT_API_BASE", os.environ.get("AGENT_API_BASE", "https://api.bunkhouse.ai")),
embed_origin=_exact_origin("AGENT_EMBED_ORIGIN", os.environ.get("AGENT_EMBED_ORIGIN", "https://embed.bunkhouse.ai")),
)
agent_config = _load()

The identity view signs a ten-minute HS256 token with the standard library’s hmac for the user Django already authenticated. It rejects other methods (405 with Allow: GET), requests whose Sec-Fetch-Site is not same-origin (403) and signed-out users (401 JSON, never a redirect to LOGIN_URL), and @never_cache marks every response no-store. The subject is str(request.user.pk) from the session, never the query, body or headers. The view keeps CsrfViewMiddleware, so a wrong-method POST may get Django’s CSRF 403 instead of the 405, which is fine. The same file contains the server mint view used by the alternative below.

agent/views.py
# 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 Django authentication already signed in.
import base64
import hashlib
import hmac
import http.client
import json
import logging
import os
import time
import urllib.error
import urllib.request
from django.http import HttpResponse, JsonResponse
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_POST
from .config import agent_config
log = logging.getLogger(__name__)
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def _b64json(value: dict) -> str:
return _b64url(json.dumps(value, separators=(",", ":")).encode())
def sign_identity_token(user_id: str, secret: str, kid: str) -> str:
"""HS256 identity token: kid header, sub = stable opaque user ID, aud = public key, 10 minutes."""
iat = int(time.time())
header = _b64json({"alg": "HS256", "typ": "JWT", "kid": kid})
payload = _b64json({"sub": user_id, "aud": agent_config.public_key, "iat": iat, "exp": iat + 600})
signature = hmac.new(secret.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest()
return f"{header}.{payload}.{_b64url(signature)}"
def _forbidden():
return JsonResponse({"error": "forbidden"}, status=403)
def _unauthorized():
return JsonResponse({"error": "unauthorized"}, status=401)
def _not_configured(variable: str):
log.error("Bunkhouse: %s is not set", variable)
return JsonResponse({"error": "not_configured"}, status=503)
@never_cache
@require_GET
def agent_identity(request):
if request.headers.get("Sec-Fetch-Site") != "same-origin":
return _forbidden()
if not request.user.is_authenticated:
return _unauthorized()
secret, kid = os.environ.get("AGENT_SIGNING_SECRET"), os.environ.get("AGENT_SIGNING_KID")
if not secret:
return _not_configured("AGENT_SIGNING_SECRET")
if not kid:
return _not_configured("AGENT_SIGNING_KID")
return JsonResponse({"identityToken": sign_identity_token(str(request.user.pk), secret, kid)})
# The embed's POST carries no Django CSRF token; the Origin, X-Agent-Embed and Sec-Fetch-Site checks
# below are this route's CSRF defense. Every other view keeps CsrfViewMiddleware.
@csrf_exempt
@never_cache
@require_POST
def agent_session(request):
origin = request.headers.get("Origin")
if (
origin not in agent_config.allowed_origins
or request.headers.get("X-Agent-Embed") != "1"
or request.headers.get("Sec-Fetch-Site") != "same-origin"
):
return _forbidden()
if not request.user.is_authenticated:
return _unauthorized()
key = os.environ.get("AGENT_EMBED_SECRET_KEY")
if not key:
return _not_configured("AGENT_EMBED_SECRET_KEY")
upstream = urllib.request.Request(
f"{agent_config.api_base}/v1/embed/sessions",
method="POST",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
data=json.dumps(
{"publicKey": agent_config.public_key, "mode": "signed", "userId": str(request.user.pk), "origin": origin}
).encode(),
)
try:
try:
with urllib.request.urlopen(upstream, timeout=15) as res:
status, body, content_type = res.status, res.read(), res.headers.get("Content-Type")
except urllib.error.HTTPError as err: # a non-2xx answer from the API: forward it unchanged
with err:
status, body, content_type = err.code, err.read(), err.headers.get("Content-Type")
except (OSError, http.client.HTTPException): # unreachable, timed out or a broken response
return JsonResponse({"error": "session_service_unavailable"}, status=502)
return HttpResponse(body, status=status, content_type=content_type or "application/json")
agent/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("api/agent-identity", views.agent_identity),
path("api/agent-session", views.agent_session),
]
example/urls.py
from django.urls import include, path
urlpatterns = [
path("", include("agent.urls")),
path("", include("demo.urls")),
]

request.user is the seam to your authentication: any backend that populates it through AuthenticationMiddleware works. Bunkhouse receives the user’s primary key, so it must be stable and opaque. The example’s custom user model uses string keys such as fixture-alice; a UUIDField primary key works the same way. If your keys are sequential integers or reusable, pass a dedicated opaque per-user ID instead of request.user.pk, never a username or email address.

demo/models.py (example user model)
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
# A stable, opaque primary key (fixture-alice), so request.user.pk is what Bunkhouse receives.
id = models.CharField(primary_key=True, max_length=64, editable=False)
last_note = models.CharField(max_length=200, null=True, blank=True)

Make the public configuration available to templates with a context processor, next to Django’s csp processor that provides the nonce:

agent/context_processors.py
from .config import agent_config
def agent_embed(request):
"""Public Bunkhouse configuration for the loader tags; nothing secret belongs here."""
return {"agent_embed": {"public_key": agent_config.public_key, "embed_origin": agent_config.embed_origin}}
example/settings.py (excerpt)
# csp exposes the per-response nonce, agent_embed the public Bunkhouse configuration.
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.template.context_processors.csrf",
"django.template.context_processors.csp",
"django.contrib.auth.context_processors.auth",
"agent.context_processors.agent_embed",
],
},
},
]

In the base template’s head, include the identity hook, the auth-headers hook (see Auth headers) and then the loader, before your application’s own scripts. window.AgentEmbedConfig must be complete before the loader runs. Both hooks are external same-origin scripts served from your static files, so no inline script is needed:

templates/agent/embed_identity.html
{# Identity token mode (recommended): both page hooks, then the loader, before the app's scripts. #}
{% load static %}
<script src="{% static 'agent-identity.js' %}" {% csp_nonce_attr %}></script>
<script src="{% static 'agent-auth-headers.js' %}" {% csp_nonce_attr %}></script>
<script src="{{ agent_embed.embed_origin }}/embed.js" {% csp_nonce_attr %}
data-public-key="{{ agent_embed.public_key }}" data-hints-url="/agent-hints.json"></script>
static/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/base.html (excerpt)
{% if user.is_authenticated %}<meta name="csrf-token" content="{{ csrf_token }}" />{% endif %}
{# The page hooks and the loader for one installation mode. #}
{% with "agent/embed_"|add:mode|add:".html" as agent_tags %}{% include agent_tags %}{% endwith %}
<!-- The application's own JavaScript comes after the loader. -->
<script src="{% static 'app.js' %}" {% csp_nonce_attr %} defer></script>

The example’s base template includes one of the three agent/embed_*.html templates, selected by a test-only ?mode= switch. Your application includes exactly one.

Instead of the identity hook, add data-session-endpoint to the loader; the auth-headers hook still comes 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. #}
{% load static %}
<script src="{% static 'agent-auth-headers.js' %}" {% csp_nonce_attr %}></script>
<script src="{{ agent_embed.embed_origin }}/embed.js" {% csp_nonce_attr %}
data-public-key="{{ agent_embed.public_key }}" data-hints-url="/agent-hints.json"
data-session-endpoint="/api/agent-session"></script>

agent_session in agent/views.py 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 server; the identity view does not need it.

The embed’s POST carries no Django CSRF token, so CsrfViewMiddleware would reject it. @csrf_exempt applies to this one view only, and its three header checks are that route’s protection; every other write in the app still goes through the middleware.

Anonymous sessions need no identity hook or session endpoint: only the loader, here after the auth-headers hook. 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. #}
{% load static %}
<script src="{% static 'agent-auth-headers.js' %}" {% csp_nonce_attr %}></script>
<script src="{{ agent_embed.embed_origin }}/embed.js" {% csp_nonce_attr %}
data-public-key="{{ agent_embed.public_key }}" data-hints-url="/agent-hints.json"></script>

The example signs in with an ordinary form post that calls django.contrib.auth.login() and redirects back to the same page, and signs out through Django’s LogoutView (a CSRF-protected POST). login() rotates the session key and the CSRF token. 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. Session and CSRF cookies are HttpOnly and SameSite:

example/settings.py (excerpt)
# HttpOnly, SameSite=Lax session and CSRF cookies; Secure everywhere except the loopback HTTP test origin.
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = HTTPS
CSRF_COOKIE_HTTPONLY = True # pages read the token from the DOM, not the cookie
CSRF_COOKIE_SECURE = HTTPS

If your Django app changes the signed-in user without a full page load (a fetch login, htmx swapping the page body, a single-page frontend), call window.AgentEmbed?.resetSession() right after the login, logout or account switch succeeds and before the new user’s requests. Include the loader once in the base template’s <head>, not in a block that child templates or partial page updates replace.

The assistant calls your API with the page’s cookies; supply any extra header your API requires with getAuthHeaders. Django’s CSRF protection expects the token in the X-CSRFToken header (CSRF_HEADER_NAME). The base template renders {{ csrf_token }} into a <meta> tag for signed-in users, and the hook sends it:

static/agent-auth-headers.js
// Django's CSRF token (rendered by the server for the signed-in session) for the assistant's calls to
// this app's API, sent as the X-CSRFToken header CsrfViewMiddleware checks. 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-CSRFToken": token } : {};
},
};

Each templates/agent/embed_*.html loads this hook before the loader. It merges into window.AgentEmbedConfig, so it combines with the identity hook. Its values never reach the model or the sandbox. If your hints declare credential headers for these operations, name it x-csrftoken.

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. Django 6.1’s ContentSecurityPolicyMiddleware replaces CSP.NONCE with a fresh nonce per response, and {% csp_nonce_attr %} puts it on the script tags; no 'unsafe-inline' or 'unsafe-eval' is needed:

example/settings.py (excerpt)
# AuthenticationMiddleware sets request.user for the Bunkhouse views; CsrfViewMiddleware keeps protecting
# every write except the mint endpoint; ContentSecurityPolicyMiddleware sends SECURE_CSP below.
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.middleware.csp.ContentSecurityPolicyMiddleware",
]
example/settings.py (excerpt)
# Your existing policy plus the Bunkhouse sources: the embed origin in script-src and frame-src, the API
# in connect-src. CSP.NONCE becomes a fresh nonce per response, rendered with {% csp_nonce_attr %}.
SECURE_CSP = {
"default-src": [CSP.SELF],
"script-src": [CSP.SELF, CSP.NONCE, agent_config.embed_origin],
"style-src": [CSP.SELF, CSP.NONCE],
"frame-src": [agent_config.embed_origin],
"connect-src": [CSP.SELF, agent_config.api_base],
"img-src": [CSP.SELF, "data:"],
"object-src": [CSP.NONE],
"base-uri": [CSP.SELF],
"form-action": [CSP.SELF],
"frame-ancestors": [CSP.SELF],
}

Do not override this policy with @csp_override on views that render the loader: overrides replace the whole policy rather than merging with it.

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

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

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. The example serves over loopback HTTP, so it does not exercise Secure cookies or HSTS. 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 AuthenticationMiddleware runs after SessionMiddleware, and the request carries your session cookie.
A 302 to the login page instead of 401 The Bunkhouse views must not be wrapped in @login_required or a login-required middleware; they answer 401 themselves.
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 403 page from /api/agent-session means @csrf_exempt is missing on that view.
403 from your own API through the assistant getAuthHeaders must return X-CSRFToken for the signed-in user’s current token.
503 The signing secret, key ID or mint key for the mode you use is not set.
The pane reports a CSP block Add the embed origin to script-src and frame-src and the API to connect-src in SECURE_CSP, and render {% csp_nonce_attr %} on the script tags.
Startup requests are not discovered The loader must come before your application’s scripts in the head.
Two launchers Include the loader once, in the base template.
A secret in page source Only AGENT_PUBLIC_KEY and origins belong in templates and context processors; keep ss_ and sk_ values in server environment variables.