Skip to content

chi

This guide adds Bunkhouse to a server-rendered Go application that routes with chi. 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 chi 5.5.0 (github.com/go-chi/chi/v5), html/template pages, gorilla/securecookie 1.1.2 session cookie
Runtime Go 1.26 (go1.26.10 in the tested image)
Example apps/docs/integrations/chi/ 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 keeps two demo users in a signed, encrypted securecookie cookie that a middleware decodes into the request context; 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 Go standard library (crypto/hmac, crypto/sha256, net/http) and chi are used by the Bunkhouse files; no JWT or Bunkhouse package is needed.

File Purpose
config.go Validates the public Bunkhouse configuration at startup
agent.go GET /api/agent-identity and POST /api/agent-session
csp.go 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

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
config.go
// 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.
package main
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"strings"
)
type AgentConfig struct {
PublicKey string
AllowedOrigins []string
APIBase string
EmbedOrigin string
}
func LoadAgentConfig() (AgentConfig, error) {
cfg := AgentConfig{
PublicKey: os.Getenv("AGENT_PUBLIC_KEY"),
APIBase: envOr("AGENT_API_BASE", "https://api.bunkhouse.ai"),
EmbedOrigin: envOr("AGENT_EMBED_ORIGIN", "https://embed.bunkhouse.ai"),
}
if !strings.HasPrefix(cfg.PublicKey, "pk_") {
return cfg, errors.New("AGENT_PUBLIC_KEY must be the site's public key (pk_...)")
}
if json.Unmarshal([]byte(envOr("AGENT_ALLOWED_ORIGINS", "[]")), &cfg.AllowedOrigins) != nil || len(cfg.AllowedOrigins) == 0 {
return cfg, errors.New(`AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]`)
}
for name, values := range map[string][]string{"AGENT_ALLOWED_ORIGINS": cfg.AllowedOrigins, "AGENT_API_BASE": {cfg.APIBase}, "AGENT_EMBED_ORIGIN": {cfg.EmbedOrigin}} {
for _, v := range values {
if !exactOrigin(v) {
return cfg, fmt.Errorf("%s must be an exact origin like https://app.example.com", name)
}
}
}
return cfg, nil
}
// exactOrigin reports whether v is already a normalized origin: lowercase http(s) scheme and host, no
// userinfo, path, query or fragment, and no empty or default port.
func exactOrigin(v string) bool {
u, err := url.Parse(v)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || v != strings.ToLower(v) {
return false
}
if port := u.Port(); strings.HasSuffix(u.Host, ":") || (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") {
return false
}
return u.Scheme+"://"+u.Host == v
}
func envOr(name, fallback string) string {
if v := os.Getenv(name); v != "" {
return v
}
return fallback
}

The routes sign a ten-minute HS256 token for the user your session already authenticated. They reject 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. Each endpoint is its own chi subrouter, so the noStore middleware also covers chi’s 405 response, which carries the Allow header for other methods. 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.

agent.go
// 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.
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"log"
"net/http"
"os"
"slices"
"time"
"github.com/go-chi/chi/v5"
)
// CurrentUser is your existing authentication: the stable ID of the request's signed-in user, if any.
type CurrentUser func(r *http.Request) (userID string, ok bool)
// SignIdentityToken returns an HS256 identity token: kid header, sub = stable opaque user ID,
// aud = public key, 10 minutes.
func SignIdentityToken(userID, publicKey, secret, kid string) string {
segment := func(v any) string {
b, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(b)
}
iat := time.Now().Unix()
unsigned := segment(map[string]string{"alg": "HS256", "typ": "JWT", "kid": kid}) + "." +
segment(map[string]any{"sub": userID, "aud": publicKey, "iat": iat, "exp": iat + 600})
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(unsigned))
return unsigned + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// AgentRoutes mounts both endpoints. Each is its own subrouter, so the no-store header also covers
// chi's 405 response (with its Allow header) for other methods.
func AgentRoutes(r chi.Router, cfg AgentConfig, currentUser CurrentUser) {
r.Route("/api/agent-identity", func(r chi.Router) {
r.Use(noStore)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Sec-Fetch-Site") != "same-origin" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
return
}
userID, ok := currentUser(r)
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
secret, kid := os.Getenv("AGENT_SIGNING_SECRET"), os.Getenv("AGENT_SIGNING_KID")
if secret == "" {
notConfigured(w, "AGENT_SIGNING_SECRET")
return
}
if kid == "" {
notConfigured(w, "AGENT_SIGNING_KID")
return
}
writeJSON(w, http.StatusOK, map[string]string{"identityToken": SignIdentityToken(userID, cfg.PublicKey, secret, kid)})
})
})
client := &http.Client{Timeout: 15 * time.Second}
r.Route("/api/agent-session", func(r chi.Router) {
r.Use(noStore)
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
// The embed's POST carries no CSRF token; these three checks are this route's CSRF defense.
origin := r.Header.Get("Origin")
if !slices.Contains(cfg.AllowedOrigins, origin) || r.Header.Get("X-Agent-Embed") != "1" || r.Header.Get("Sec-Fetch-Site") != "same-origin" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
return
}
userID, ok := currentUser(r)
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
key := os.Getenv("AGENT_EMBED_SECRET_KEY")
if key == "" {
notConfigured(w, "AGENT_EMBED_SECRET_KEY")
return
}
payload, _ := json.Marshal(map[string]string{"publicKey": cfg.PublicKey, "mode": "signed", "userId": userID, "origin": origin})
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, cfg.APIBase+"/v1/embed/sessions", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)
var body []byte
if err == nil {
defer res.Body.Close()
body, err = io.ReadAll(io.LimitReader(res.Body, 1<<20))
}
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "session_service_unavailable"})
return
}
contentType := res.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/json"
}
w.Header().Set("Content-Type", contentType)
w.WriteHeader(res.StatusCode)
w.Write(body)
})
})
}
func noStore(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
func notConfigured(w http.ResponseWriter, variable string) {
log.Printf("Bunkhouse: %s is not set", variable)
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "not_configured"})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

currentUser is the seam to your authentication: return the signed-in user’s stable ID from whatever your session middleware put in the request context.

main.go (excerpt)
// currentUser is the seam to your authentication: the stable ID of the user your session middleware
// put in the request context.
func currentUser(r *http.Request) (string, bool) {
if s := sessionOf(r); s != nil {
return s.UserID, true
}
return "", false
}

Mount the routes after your session middleware, so r.Context() already holds the user:

main.go (excerpt)
r.Use(loadSession(codec))
// Bunkhouse endpoints, behind the session middleware so they see the signed-in user.
AgentRoutes(r, cfg, currentUser)

In the page head, load the identity hook, then the getAuthHeaders hook (see Auth headers), then the loader, all before your application’s own scripts: the page API reads window.AgentEmbedConfig when the loader starts. The hooks are external same-origin scripts, so no inline script needs a CSP exception:

templates/agent-embed-identity.html
{{/* Identity token mode (recommended): both page hooks, 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="{{.Agent.EmbedOrigin}}/embed.js" nonce="{{.Nonce}}"
data-public-key="{{.Agent.PublicKey}}" 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/layout.html (excerpt)
{{with .User}}<meta name="csrf-token" content="{{.CSRFToken}}" />{{end}}
{{if eq .Mode "mint"}}{{template "agent-embed-mint.html" .}}{{else if eq .Mode "anonymous"}}{{template "agent-embed-anonymous.html" .}}{{else}}{{template "agent-embed-identity.html" .}}{{end}}
<!-- The application's own JavaScript comes after the loader. -->
<script src="/app.js" nonce="{{.Nonce}}" defer></script>

The example layout includes one of the three agent-embed-* templates, selected by a test-only ?mode= switch. Your application uses exactly one. html/template escapes the public key and origin for their attribute context.

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. The getAuthHeaders hook still loads before the loader.

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="{{.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 agent.go 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 endpoint does not need it.

chi has no built-in CSRF middleware. Go’s http.CrossOriginProtection needs no exemption for this route: the embed’s POST is sent with Sec-Fetch-Site: same-origin, which it allows. A token-based middleware such as gorilla/csrf must 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 getAuthHeaders 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): no identity hook; the auth-headers hook, then the loader. */}}
<script src="/agent-auth-headers.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 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 middleware decodes the HttpOnly cookie into the request context; a missing, expired or tampered cookie is no session:

main.go (excerpt)
// demoSession is the signed-in user, stored in an HttpOnly cookie that securecookie signs and encrypts.
type demoSession struct {
UserID, Name, CSRFToken string
}
type sessionKey struct{}
const sessionCookie = "example_session"
// loadSession decodes the session cookie and puts the signed-in user in the request context. A missing,
// expired or tampered cookie is simply no session.
func loadSession(codec *securecookie.SecureCookie) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil {
var s demoSession
if codec.Decode(sessionCookie, c.Value, &s) == nil {
r = r.WithContext(context.WithValue(r.Context(), sessionKey{}, &s))
}
}
next.ServeHTTP(w, r)
})
}
}
func sessionOf(r *http.Request) *demoSession {
s, _ := r.Context().Value(sessionKey{}).(*demoSession)
return s
}

Sign-in replaces the cookie with a new session and a fresh CSRF token. Go’s http.CrossOriginProtection guards both form posts:

main.go (excerpt)
r.Group(func(r chi.Router) {
// Go's cross-origin protection (Fetch Metadata / Origin) guards the cookie-changing form posts.
r.Use(http.NewCrossOriginProtection().Handler)
r.Post("/login", func(w http.ResponseWriter, r *http.Request) {
user, ok := users[r.PostFormValue("username")]
if !ok || subtle.ConstantTimeCompare([]byte(r.PostFormValue("password")), []byte(demoPassword)) != 1 {
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
return
}
// A new cookie with a fresh CSRF token replaces any previous session.
user.CSRFToken = rand.Text()
value, err := codec.Encode(sessionCookie, user)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: value, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: secure})
http.Redirect(w, r, safeNext(r.PostFormValue("next")), http.StatusSeeOther)
})
r.Post("/logout", func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: secure})
http.Redirect(w, r, safeNext(r.PostFormValue("next")), http.StatusSeeOther)
})
})

If your app changes the signed-in user without a full page load (a fetch login, htmx, 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 or swapped fragments.

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 } : {};
},
};

Every mode’s template loads this hook before the loader. It merges into window.AgentEmbedConfig, so it works with the identity hook in either order. 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 middleware sets a fresh nonce per response, stores it in the request context and puts it on its script tags; no 'unsafe-inline' or 'unsafe-eval' is needed:

csp.go
// 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 (CSPNonce) goes on the
// page's script tags.
package main
import (
"context"
"crypto/rand"
"net/http"
"strings"
)
type cspNonceKey struct{}
func ContentSecurityPolicy(cfg AgentConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nonce := rand.Text() // 128+ random bits, base32
w.Header().Set("Content-Security-Policy", strings.Join([]string{
"default-src 'self'",
"script-src 'self' 'nonce-" + nonce + "' " + cfg.EmbedOrigin,
"style-src 'self' 'nonce-" + nonce + "'",
"frame-src " + cfg.EmbedOrigin,
"connect-src 'self' " + cfg.APIBase,
"img-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
}, "; "))
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), cspNonceKey{}, nonce)))
})
}
}
// CSPNonce is the current response's nonce, for the page's script tags.
func CSPNonce(r *http.Request) string {
nonce, _ := r.Context().Value(cspNonceKey{}).(string)
return nonce
}

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

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

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 AgentRoutes is registered on a router or group that does not run your session middleware, or currentUser does not find the user in r.Context().
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 token-based 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.
The pane reports a CSP block Add the embed origin to script-src and frame-src and the API to connect-src, and put the response’s nonce on every script tag.
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.