Design: Web UI
Refined by SPEC-0013 (Operator Board) — the screen set grows to the five-view operator board and styling moves to the ADR-0016 token/component layer (Pico.css is not adopted). This document remains the baseline design record for sessions, SSE plumbing, security posture, and error handling.
Context
SPEC-0012 realizes ADR-0001: switchboard serves a small, server-rendered, human-facing UI from the single Go binary alongside the webhook receivers, the MCP/agent surface, and the SSE stream. The UI is the operator's board — log in, register agents, and vend/revoke the scoped MCP endpoints that agents plug into Claude Code Channels (ADR-0013). The data lives in PostgreSQL (ADR-0002) behind the same service; there is no API-first/mobile-client story, so rendering HTML on the server and swapping fragments is the natural shape.
ADR-0001 fixes the stack: net/http + chi (router/middleware), stdlib html/template (contextual
auto-escaping, composes with embed.FS), HTMX core + htmx-ext-sse (vendored, not CDN), Pico.css
classless + a small tokens.css palette, and inline SVG icons (Lucide for chrome, Simple Icons for
brands). SSE is a plain http.Flusher handler — no library. Everything ships embedded in the binary
via embed.FS; there is no Node build. The implementation is internal/web/web.go (handlers +
templates under internal/web/templates/) and internal/server/server.go (route wiring, static/embed
serving, RequireHuman grouping, and the background lease reaper). Authentication is OIDC against
Pocket ID with an HttpOnly/SameSite=Lax/Secure-when-TLS session cookie, plus a dev-only local
login (internal/auth/auth.go).
Goals / Non-Goals
Goals
- One binary, one PostgreSQL pool, several surfaces — the web layer composes cleanly with the long-lived MCP server and background goroutines.
- Live-updating screens with near-zero client state: SSE + HTMX fragment swaps, no SPA, no bundler.
- Offline/CSP-friendly single-binary operation: all assets vendored and embedded.
- Accessible and themeable for near-zero effort: semantic HTML, classless CSS,
currentColorinline SVG, WCAG 2.1 AA.
Non-Goals
- A JSON API for a client-side framework (rejected in ADR-0001 — screens are server-rendered tables).
- A CSS utility/design system (Tailwind/Bootstrap rejected — Pico classless + a token layer).
- A CDN or runtime-fetched assets (vendored/embedded instead).
- Multi-tenant admin surfaces beyond the four MVP screens.
Decisions
net/http + chi + stdlib html/template over a framework/SPA
Choice: Standard library HTTP with chi for routing/middleware, stdlib html/template for
rendering, HTMX for interactivity.
Rationale: The HTTP surface is webhook receivers (raw body + HMAC) and server-rendered HTML —
neither benefits from a framework's binding/validation or a SPA's JSON API. html/template gives
contextual auto-escaping for free and composes with embed.FS.
Alternatives considered:
- Gin/Echo/Fiber: rejected — framework request/response model is overhead; Fiber's fasthttp complicates SSE and stdlib/MCP-SDK interop.
- React/Vue/Svelte SPA: rejected — forces a JSON API, a bundler, and client/server state duplication for server-rendered tables.
- templ/quicktemplate: rejected — adds a codegen step not warranted at this screen count.
SSE via http.Flusher, no library; degrade gracefully
Choice: Live updates over a plain net/http SSE handler using http.Flusher, subscribed from the
DOM by htmx-ext-sse; a missed event is non-fatal because PostgreSQL is authoritative.
Rationale: A few dozen lines of stdlib gives live screens with no client-side state and no SSE
dependency. Treating SSE as best-effort presentation keeps the UI correct on reload.
Alternatives considered:
- WebSockets / an SSE library: rejected — heavier than needed for one-way status/log fan-out.
Vendored + embedded assets (embed.FS), CSP-friendly
Choice: HTMX core, htmx-ext-sse, Pico.css, tokens.css, and inline SVG icons are vendored under
static/ and embedded; served from embed.FS, never a CDN.
Rationale: Single-binary, offline-friendly, and compatible with a strict default-src 'self' CSP.
Alternatives considered:
- CDN-loaded assets: rejected — breaks offline/CSP and adds an external dependency.
Ownership checks + auth-by-default grouping
Choice: Authenticated routes are grouped behind RequireHuman; agent/endpoint handlers re-check
ownership (GetAgentOwned, owner-scoped revoke) and return 404 for non-owned resources.
Rationale: Auth-by-default with per-resource ownership prevents horizontal privilege escalation and
avoids leaking existence of another human's agents.
Alternatives considered:
- Trusting the session alone without per-resource ownership: rejected — would allow cross-tenant reads/writes by id guessing.
Architecture
One chi router serves webhooks, the vended agent API, auth endpoints, the human UI (behind
RequireHuman), static assets, and health. UI handlers read the authenticated human from context,
query the store, and render an embedded layout + per-page content template. The browser drives
navigation with HTMX fragment GET/POST and subscribes live regions to the SSE stream; all assets load
from embed.FS.
Risks / Trade-offs
- HTMX is less familiar than React to some contributors → Accepted for a single-maintainer service where simplicity and no build step win.
- Vendored assets must be updated by hand (no CDN auto-refresh) → Accepted as the price of
offline/CSP-friendly single-binary operation; assets are pinned under
static/. - SSE is best-effort → A missed event never corrupts state because PostgreSQL is authoritative and a reload reflects current truth.
- Dev login bypasses OIDC → MUST be enabled only when
SWITCHBOARD_DEV_LOGINis set and MUST NOT be present in production; the login page labels it a dev-only bypass. - Inline styles/SVG require
style-src 'unsafe-inline'→ Accepted; a stricter nonce-based CSP is a possible future hardening (see Open Questions).
Migration Plan
Greenfield — the MVP web UI is already built (internal/web/, templates, routes in
internal/server/server.go). No data migration. Hardening items called out in the spec (security
headers middleware, http.MaxBytesReader on form handlers, a synchronizer CSRF token, Referer
validation on revoke, and aria-live/landmark ARIA attributes on templates) are forward, additive
changes to the existing handlers/templates and MUST NOT alter the route set or data model.
Open Questions
- CSRF token mechanism: adopt a synchronizer-token library vs. hand-rolled per-session token; and
whether
SameSite=Laxalone is deemed sufficient for the single-operator MVP before the token lands. - Strict CSP: can inline
<style>and inline SVG be moved to embedded external files with a nonce-based CSP to dropstyle-src 'unsafe-inline'? - SSE surface: which live regions (status strip, event log) subscribe to the stream in the MVP UI, and what events the hub publishes to the human audience vs. the agent audience.
- Theme toggle: the palette supports bakelite-dark and operator-cream; whether/how to expose a user-facing theme switch while preserving WCAG AA contrast in both.