Echo
This guide adds Bunkhouse to a server-rendered Echo 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.
Tested setup
Section titled “Tested setup”| Framework | Echo 5.4.2 (github.com/labstack/echo/v5), html/template views through echo.TemplateRenderer, gorilla/securecookie 1.1.2 for the demo session |
| Runtime | Go 1.26 (go1.26.10 in the tested image), static binary on Debian bookworm-slim |
| Example | apps/docs/integrations/echo/ in the Bunkhouse repository |
The code uses Echo v5 signatures (func(c *echo.Context) error, Render(c *echo.Context, ...)); v4 handlers take the echo.Context interface instead.
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 an encrypted cookie 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 |
|---|---|
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 |
Identity token (recommended)
Section titled “Identity token (recommended)”Set the server environment. Only AGENT_PUBLIC_KEY and the origins ever reach the browser:
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 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" "fmt" "net/url" "os" "strconv" "strings")
type AgentConfig struct { PublicKey string AllowedOrigins []string APIBase string EmbedOrigin string}
// exactOrigin accepts only the serialized origin form: http(s), lowercase host, no userinfo, path,// query, fragment or default port.func exactOrigin(name, value string) string { u, err := url.Parse(value) ok := err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Hostname() != "" && u.User == nil if ok { host := strings.ToLower(u.Hostname()) if strings.Contains(host, ":") { host = "[" + host + "]" } origin := u.Scheme + "://" + host if p := u.Port(); p != "" { n, err := strconv.Atoi(p) ok = err == nil && n <= 65535 if n != map[string]int{"http": 80, "https": 443}[u.Scheme] { origin += ":" + strconv.Itoa(n) } } ok = ok && origin == value } if !ok { panic(fmt.Sprintf("%s must be an exact origin like https://app.example.com", name)) } return value}
func envOr(name, fallback string) string { if v := os.Getenv(name); v != "" { return v } return fallback}
func loadAgentConfig() AgentConfig { publicKey := os.Getenv("AGENT_PUBLIC_KEY") if !strings.HasPrefix(publicKey, "pk_") { panic("AGENT_PUBLIC_KEY must be the site's public key (pk_...)") } var origins []string if err := json.Unmarshal([]byte(envOr("AGENT_ALLOWED_ORIGINS", "[]")), &origins); err != nil || len(origins) == 0 { panic(`AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]`) } for _, o := range origins { exactOrigin("AGENT_ALLOWED_ORIGINS", o) } return AgentConfig{ PublicKey: publicKey, AllowedOrigins: origins, APIBase: exactOrigin("AGENT_API_BASE", envOr("AGENT_API_BASE", "https://api.bunkhouse.ai")), EmbedOrigin: exactOrigin("AGENT_EMBED_ORIGIN", envOr("AGENT_EMBED_ORIGIN", "https://embed.bunkhouse.ai")), }}The routes sign a ten-minute HS256 token with Go’s crypto/hmac for the user your session already authenticated. They reject other methods (405 from an e.Any fallback; method-specific routes take precedence), 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.
// 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" "cmp" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "io" "net/http" "os" "slices" "time"
"github.com/labstack/echo/v5")
// CurrentUserID is your existing authentication: the signed-in user's stable ID for this request, or "".type CurrentUserID func(c *echo.Context) string
func base64url(value any) string { b, _ := json.Marshal(value) return base64.RawURLEncoding.EncodeToString(b)}
// SignIdentityToken returns an HS256 token: kid header, sub = stable opaque user ID, aud = public key, 10 minutes.func SignIdentityToken(userID, publicKey, secret, kid string) string { iat := time.Now().Unix() header := base64url(struct { Alg string `json:"alg"` Typ string `json:"typ"` Kid string `json:"kid"` }{"HS256", "JWT", kid}) payload := base64url(struct { Sub string `json:"sub"` Aud string `json:"aud"` Iat int64 `json:"iat"` Exp int64 `json:"exp"` }{userID, publicKey, iat, iat + 600}) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(header + "." + payload)) return header + "." + payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))}
func methodNotAllowed(allow string) echo.HandlerFunc { return func(c *echo.Context) error { c.Response().Header().Set("Allow", allow) c.Response().Header().Set("Cache-Control", "no-store") return c.JSON(http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"}) }}
func fail(c *echo.Context, status int, code string) error { return c.JSON(status, map[string]string{"error": code})}
func notConfigured(c *echo.Context, variable string) error { c.Logger().Error("Bunkhouse: variable is not set", "variable", variable) return fail(c, http.StatusServiceUnavailable, "not_configured")}
func RegisterAgentRoutes(e *echo.Echo, cfg AgentConfig, currentUserID CurrentUserID) { client := &http.Client{Timeout: 15 * time.Second}
e.GET("/api/agent-identity", func(c *echo.Context) error { c.Response().Header().Set("Cache-Control", "no-store") if c.Request().Header.Get("Sec-Fetch-Site") != "same-origin" { return fail(c, http.StatusForbidden, "forbidden") } userID := currentUserID(c) if userID == "" { return fail(c, http.StatusUnauthorized, "unauthorized") } secret, kid := os.Getenv("AGENT_SIGNING_SECRET"), os.Getenv("AGENT_SIGNING_KID") if secret == "" { return notConfigured(c, "AGENT_SIGNING_SECRET") } if kid == "" { return notConfigured(c, "AGENT_SIGNING_KID") } return c.JSON(http.StatusOK, map[string]string{"identityToken": SignIdentityToken(userID, cfg.PublicKey, secret, kid)}) }) // Method-specific routes take precedence; every other method gets 405. e.Any("/api/agent-identity", methodNotAllowed("GET"))
e.POST("/api/agent-session", func(c *echo.Context) error { c.Response().Header().Set("Cache-Control", "no-store") // The embed's POST carries no CSRF token; these three checks are this route's CSRF defense. h := c.Request().Header origin := h.Get("Origin") if origin == "" || !slices.Contains(cfg.AllowedOrigins, origin) || h.Get("X-Agent-Embed") != "1" || h.Get("Sec-Fetch-Site") != "same-origin" { return fail(c, http.StatusForbidden, "forbidden") } userID := currentUserID(c) if userID == "" { return fail(c, http.StatusUnauthorized, "unauthorized") } key := os.Getenv("AGENT_EMBED_SECRET_KEY") if key == "" { return notConfigured(c, "AGENT_EMBED_SECRET_KEY") } body, _ := json.Marshal(map[string]string{"publicKey": cfg.PublicKey, "mode": "signed", "userId": userID, "origin": origin}) req, err := http.NewRequestWithContext(c.Request().Context(), http.MethodPost, cfg.APIBase+"/v1/embed/sessions", bytes.NewReader(body)) if err != nil { return fail(c, http.StatusBadGateway, "session_service_unavailable") } req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Content-Type", "application/json") res, err := client.Do(req) if err != nil { return fail(c, http.StatusBadGateway, "session_service_unavailable") } defer res.Body.Close() upstream, err := io.ReadAll(res.Body) if err != nil { return fail(c, http.StatusBadGateway, "session_service_unavailable") } return c.Blob(res.StatusCode, cmp.Or(res.Header.Get("Content-Type"), echo.MIMEApplicationJSON), upstream) }) e.Any("/api/agent-session", methodNotAllowed("POST"))}Register the routes where your authentication middleware runs (here, the global e.Use). currentUserID is the seam to your authentication: return the signed-in user’s stable ID from the request context, or "".
// The signed-in user's stable ID from your existing authentication, or "" when signed out.currentUserID := func(c *echo.Context) string { if u, ok := c.Get("user").(*User); ok { return u.ID } return ""}
// Bunkhouse endpoints. The global authentication middleware above runs for them, so they see the signed-in user.RegisterAgentRoutes(e, cfg, currentUserID)In the page head, define the page hooks (window.AgentEmbedConfig) and then the loader, before your application’s own scripts: the identity hook, the auth-headers hook (see Auth headers), then the loader. The hooks are external same-origin scripts, so no inline script needs a CSP exception:
{{/* 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>// 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 .CSRFToken}}<meta name="csrf-token" content="{{.CSRFToken}}" />{{end}}{{/* The mode template renders the page hooks, then the loader. */}}{{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 templates are parsed with template.ParseFS into echo.TemplateRenderer, so each file is a template named after itself. The example layout includes one of the three agent-embed-* templates, selected by a test-only ?mode= switch. Your application uses exactly one.
Server mint alternative
Section titled “Server mint alternative”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 auth-headers hook still loads before the loader.
{{/* 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. The runtime image needs CA certificates to reach https://api.bunkhouse.ai; the example’s Dockerfile copies them from the build stage.
Echo’s middleware.CSRF lets requests with Sec-Fetch-Site: same-origin through without a token, so it does not block the embed’s POST. If your CSRF configuration does reject it, exempt only /api/agent-session with the middleware’s Skipper: the embed’s POST carries no CSRF token, and the three header checks are that route’s protection.
Anonymous alternative
Section titled “Anonymous alternative”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.
{{/* Anonymous mode (enable it for the site first): no identity 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>Sign-in, sign-out and navigation
Section titled “Sign-in, sign-out and navigation”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 demo session is an HttpOnly, SameSite cookie, HMAC-signed and AES-encrypted with gorilla/securecookie; a login writes a new session with a new CSRF token. Middleware decodes it and puts the user on the Echo context, which is where currentUserID reads it:
key := func(purpose string) []byte { m := hmac.New(sha256.New, []byte(secret)) m.Write([]byte(purpose)) return m.Sum(nil)}cookies := securecookie.New(key("hash"), key("block")) // HMAC-signed, AES-encryptedcookies.MaxAge(8 * 3600)
writeSession := func(c *echo.Context, s *session) error { // Secure everywhere except the loopback HTTP test origin. cookie := &http.Cookie{Name: sessionCookie, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: strings.HasPrefix(appOrigin, "https://")} if s == nil { cookie.MaxAge = -1 } else { value, err := cookies.Encode(sessionCookie, s) if err != nil { return err } cookie.Value, cookie.MaxAge = value, 8*3600 } c.SetCookie(cookie) return nil}
// Authentication middleware: a valid session cookie puts the user and its CSRF token on the context.e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { var s session if ck, err := c.Cookie(sessionCookie); err == nil && cookies.Decode(sessionCookie, ck.Value, &s) == nil { if u, ok := users[s.Username]; ok { c.Set("user", &u) c.Set("csrfToken", s.CSRFToken) } } return next(c) }})If your Echo 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.
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 as X-CSRF-Token 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 agent-embed-* template loads this hook before the loader. It merges into window.AgentEmbedConfig, so it and the identity hook can load 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 and stores it on the context for the templates’ script tags; no 'unsafe-inline' or 'unsafe-eval' is needed:
// 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 (c.Get("cspNonce"))// goes on the page's script tags.package main
import ( "crypto/rand" "strings"
"github.com/labstack/echo/v5")
func ContentSecurityPolicy(cfg AgentConfig) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { nonce := rand.Text() // 128 random bits c.Set("cspNonce", nonce) c.Response().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'", }, "; ")) return next(c) } }}Verify
Section titled “Verify”From a checkout of the Bunkhouse repository, with Docker and local Supabase running:
node tests/framework-integrations/run.ts echoThe 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 authentication middleware does not run for these routes (for example, it is attached to a group they are not in), or currentUserID does not find the user on the 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 CSRF middleware must exempt only /api/agent-session. |
| 502 from the mint endpoint | The server cannot reach AGENT_API_BASE; a minimal image also needs CA certificates. |
| 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. |
| 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. |
Related
Section titled “Related”- Installation, Security and the Page API
- Echo: Routing, Templates