diff --git a/CHANGELOG.md b/CHANGELOG.md
index 03f4fbc..0c39d4f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,48 @@
All notable changes to Chat Switchboard.
+## [0.22.8] — 2026-03-04
+
+### Changed
+- **Naming cleanup: preset → persona.** Unified domain terminology across the entire codebase. Routes `/presets` → `/personas` (user, team, admin). JSON response keys, request fields, Go struct fields, handler names, JS functions, CSS classes, DOM IDs, template strings, display text — all consistently use "persona". File renamed: `presets.go` → `personas.go`. Dual JSON keys (`"personas"` + `"presets"`) collapsed to single `"personas"` key. Config key `user_presets` → `user_personas`. Banner color presets intentionally left as "presets" (different domain concept).
+- **Naming cleanup: APIConfigID → ProviderConfigID.** All Go structs and JSON fields now use `provider_config_id` consistently.
+- **Removed aliases.** `chat_id` field removed from completion handler. `/models` route alias removed (use `/models/enabled`).
+- **Shared app-state.js.** Extracted `App` state object, `fetchModels()`, and `resolveCapabilities()` from chat-only `app.js` into new `app-state.js` loaded by `base.html` for all surfaces. Settings and admin surfaces now have access to `App` state for model dropdowns, settings, and user context.
+- **Auth tokens loaded for all surfaces.** `API.loadTokens()` now runs in `base.html` inline script so settings, admin, editor, and notes surfaces can make authenticated API calls.
+- **Script loading consolidated in base.html.** `ui-core.js` moved from per-surface loads to `base.html`. `ui-primitives-additions.js` (never loaded by any surface) now loaded in `base.html`. All surfaces get the complete shared stack: app-state → api → events → ui-primitives → ui-primitives-additions → ui-core → pages.
+
+### Added
+- **Settings surface functions.** `UI.loadGeneralSettings()` populates the general settings form (model dropdown, system prompt, temperature, max tokens, thinking toggle) with server data. `UI.saveAppearance()` persists theme, scale, and font size. `UI.loadTeamsSettings()` renders team membership on the settings teams tab.
+- **Design documentation.** `docs/DESIGN-SURFACES.md` (four-layer surface architecture, extension hooks, trust model), `docs/ICD-API.md` (domain-organized API contract, 240 routes), `docs/ICD-AUDIT.md` (cross-reference audit with resolution status).
+
+## [0.22.7] — 2026-03-03
+
+### Added
+- **Theme system.** New `theme.css` provides complete light/dark theming via CSS custom properties with `[data-theme]` attribute switching. Variables cover backgrounds, surfaces, text, accents, borders, shadows, and component-specific tokens. System preference auto-detection via `prefers-color-scheme` media query. Old variables (`--bg-primary`, `--text-primary`) bridged via fallback chains (`var(--bg, var(--bg-primary, #0e0e10))`) — zero breakage of existing CSS.
+- **ChatPane component.** New `chat-pane.js` provides a mountable, self-contained chat pane factory replacing the hardcoded singleton pattern. `ChatPane.create(opts)` returns an instance with `renderMessages()`, `appendChunk()`, `finalizeStream()`, `appendTyping()`, `removeTyping()`, `scrollToBottom()`, `showWelcome()`, `clear()`, `getInputValue()`, `setInputValue()`, `focusInput()`, `destroy()`. Lookup via `ChatPane.get(id)`, `ChatPane.forChannel(channelId)`, `ChatPane.active()`. Each instance owns its own DOM elements and channel binding.
+- **Chat pane template component.** New `components/chat-pane.html` Go template partial: `{{template "chat-pane" dict "ID" "main"}}` renders a complete chat scaffold with messages container, input bar, model selector, send button, and toolbar mount points. Used by editor surface assist pane.
+- **Splash/login surface.** Login page rewritten from minimal form to split-panel splash layout: animated grid canvas on hero side, tabbed auth card (Login + Register) on right. Registration form includes avatar upload, display name, email. Powered by new `pages-splash.js` module with `Pages.initSplash()`, grid animation, and `POST /api/v1/auth/register` + `PUT /api/v1/users/me/avatar` integration.
+- **UI primitives additions.** New `ui-primitives-additions.js` extends the shared primitive library: `Toast` (show/success/error/warning/info with auto-dismiss stacking), `renderBadge()` (6 color variants), `renderIcon()` (20+ SVG icon set), `renderIconBtn()` (icon button factory with active state), `Theme` (init/set/get/resolved/renderToggle with localStorage persistence), `showConfirmDialog()` (enhanced with variant/callbacks), `mountAvatarUpload()` (file input with preview).
+- **Settings BYOK gate.** Settings surface nav now conditionally shows "My Providers", "Model Roles", and "Usage" tabs only when BYOK is enabled. Server-side gate via `{{if .Data.BYOKEnabled}}` reads `GlobalConfig` key `user_providers.enabled`. Includes BYOK status indicator in nav footer with UEK encryption notice.
+- **Settings User Personas gate.** Settings surface conditionally shows "My Personas" tab when user-created personas are enabled. Server-side gate via `{{if .Data.UserPersonasEnabled}}` reads `GlobalConfig` key `user_presets.enabled`.
+- **Settings Models tab.** New "Models" nav link in settings surface for user model visibility preferences.
+- **Editor assist pane.** Editor surface now includes a `chat-pane` template component for the right-side assist pane with split handle, wired up via `ChatPane.create()` on DOM ready.
+
+### Changed
+- **`base.html` template.** Added `data-theme` attribute on `` element for theme system. Google Fonts preconnect + DM Sans / JetBrains Mono stylesheet. `theme.css` loaded before `styles.css`. New shared script tags for `ui-primitives-additions.js` and `chat-pane.js` (available on all surfaces). Added `window.__SETTINGS__` global from `PageData.SurfaceSettings`.
+- **`PageData` struct** (`pages.go`). New fields: `Theme` (string), `SurfaceSettings` (any, serialized to `__SETTINGS__`), `InstanceName`, `LogoURL`, `Tagline`, `RegistrationOpen`. `Render()` defaults `Theme` to `"dark"` when unset.
+- **`RenderLogin()`** (`pages.go`). Now calls `loadBranding()` and `isRegistrationOpen()` to populate splash/login template fields from `GlobalConfig` keys `branding.*` and `registration.enabled`.
+- **`SettingsPageData`** (`loaders.go`). Added `BYOKEnabled` and `UserPersonasEnabled` boolean fields. `settingsLoader()` reads from `GlobalConfig` keys `user_providers.enabled` and `user_presets.enabled`.
+- **`settings.html` template.** Reorganized nav: base tabs (General, Appearance, Models, My Presets) always visible; BYOK section (My Providers, Model Roles, Usage) gated; User Personas gated; then Knowledge, Memory, Notifications, Teams. Added `my-personas` to dynamic section loaders.
+- **`editor.html` template.** Added `chat-pane` component in right-side assist pane with split handle and `ChatPane.create()` initialization.
+
+### New Files
+- `src/css/theme.css` — 305 lines: CSS custom properties, shared components, surface layouts
+- `src/js/chat-pane.js` — 162 lines: mountable chat pane factory
+- `src/js/ui-primitives-additions.js` — 199 lines: Toast, Badge, Icons, Theme, confirm, avatar
+- `src/js/pages-splash.js` — 154 lines: splash init, login, register, grid canvas
+- `server/pages/templates/components/chat-pane.html` — 23 lines: reusable template partial
+
## [0.22.6] — 2026-03-03
### Fixed
diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md
new file mode 100644
index 0000000..c9f918c
--- /dev/null
+++ b/IMPLEMENTATION.md
@@ -0,0 +1,2 @@
+# v0.22.7 Changeset
+See files for details.
diff --git a/VERSION b/VERSION
index 18fb7fe..eca6a4a 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.22.6
+0.22.8
diff --git a/docs/DESIGN-SURFACES.md b/docs/DESIGN-SURFACES.md
new file mode 100644
index 0000000..c42fba8
--- /dev/null
+++ b/docs/DESIGN-SURFACES.md
@@ -0,0 +1,746 @@
+# DESIGN — Surface & Extension Architecture
+
+**Status:** Accepted
+**Scope:** Primitives, Components, Surfaces, Extension hooks, Themes
+**Depends on:** v0.22.5 (Go template engine), v0.22.7 (ChatPane)
+**Informs:** v0.22.8+, EXTENSIONS.md rewrite, ARCHITECTURE.md update, ICD-API, ICD-SURFACE
+
+---
+
+## Overview
+
+The UI is a four-layer architecture: Primitives, Components, Surfaces,
+and Banners. Each layer has a single responsibility and a strict
+dependency direction — primitives know nothing, components compose
+primitives, surfaces compose components, banners are platform chrome
+outside the surface boundary.
+
+Extensions participate at every layer through six defined hooks.
+Admin-installed extensions are privileged (full DOM, direct API access,
+own routes). Themes are pure CSS custom property overrides that
+propagate through every layer uniformly.
+
+This document defines the layer contracts, extension hook system,
+display content model, trust boundaries, and theme architecture.
+
+---
+
+## Definitions
+
+A **surface** is a full-page layout — a composition of components with
+a route, a data loader, and a boot script. Chat is a surface. The
+editor is a surface. A custom triage intake form is a surface.
+
+An **extension** is a package — a manifest plus code plus assets that
+participates in the platform through hooks. An extension can do one,
+some, or all of:
+
+- Register tools the LLM can call (Hook 5: Tool Bridge)
+- Transform content during streaming (Hook 2: Stream Processing)
+- Enhance rendered messages (Hook 3: Post-Render)
+- Attach visual content to messages (Hook 4: Display Content)
+- Inject panels or sections into existing surfaces (Hook 6: Surface Injection)
+- Create entirely new surfaces with their own routes (Surface Registration)
+
+The five **core surfaces** (Chat, Editor, Notes, Admin, Settings) exist
+without extensions. They are built from the same primitive and component
+layers that extensions use, but they are registered directly in Go
+rather than through a manifest.
+
+An extension that creates a surface gets its own route (`/s/:slug`),
+its own data loader, and the full primitive/component library. An
+extension that only registers a post-render hook (like a block
+renderer) doesn't create any surfaces at all.
+
+**Examples:**
+
+| Extension | Creates Surface? | Hooks Used |
+|-----------|-----------------|------------|
+| Mermaid renderer | No | Post-Render |
+| KaTeX renderer | No | Post-Render |
+| Calculator tool | No | Tool Bridge |
+| Image Generator | Yes (`/s/gallery`) | Tool Bridge, Stream Processing, Post-Render, Display Content, Surface |
+| Custom Dashboard | Yes (`/s/dashboard`) | Surface, Surface Injection (admin section) |
+
+---
+
+## Existing Extension Mapping
+
+The current extension mechanisms map directly into this architecture:
+
+| Current Mechanism | Architecture Equivalent |
+|-------------------|------------------------|
+| Block renderers (`ctx.renderers.register()`) | Hook 3: Post-Render |
+| Tool bridge (`ctx.tools.register()`) | Hook 5: Tool Bridge |
+| `ctx.ui.toast()`, `ctx.ui.openPreview()` | Primitive layer (unchanged API) |
+| `ctx.ui.isDark()`, `ctx.ui.isMobile()` | Theme layer queries |
+| `ctx.surfaces.getCurrent()` | Returns `window.__SURFACE__` |
+
+The manifest schema gains optional new fields (`hooks`, `surfaces`,
+`surface_injections`, `theme`). Existing fields retain their meaning.
+
+`Extensions.boot()` is the single entry point for extension
+initialization on every surface. It loads manifests, registers
+renderers, and initializes tool bridges — same sequence, available
+on every surface rather than only chat.
+
+---
+
+## Layer Model
+
+```
+┌──────────────────────────────────────────────┐
+│ Banner (top) │ ← Platform config
+├──────────────────────────────────────────────┤
+│ │
+│ Surface │ ← Layout + lifecycle
+│ ┌─────────────┬──────────────────────────┐ │
+│ │ Component │ Component │ │ ← Domain-aware
+│ │ (ChatPane) │ (NoteEditor) │ │
+│ │ ┌─────────┐ │ ┌────────┐ ┌──────────┐ │ │
+│ │ │Primitive│ │ │Primitv.│ │Primitive │ │ │ ← Atomic UI
+│ │ │ (input) │ │ │ (menu) │ │ (toggle) │ │ │
+│ │ └─────────┘ │ └────────┘ └──────────┘ │ │
+│ └─────────────┴──────────────────────────┘ │
+│ │
+├──────────────────────────────────────────────┤
+│ Banner (bottom) │ ← Platform config
+└──────────────────────────────────────────────┘
+```
+
+Four layers, strict dependency direction: Primitives know nothing.
+Components use Primitives. Surfaces compose Components. Banners are
+global chrome outside the surface boundary.
+
+---
+
+## Layer 1: Primitives
+
+Atomic UI elements. No domain logic. A toggle doesn't know if it's
+toggling a KB or a theme — it takes a label, a state, and a callback.
+
+Styled entirely via CSS custom properties (the theme contract). Every
+primitive reads from the same property namespace, so theme changes
+propagate instantly.
+
+### Catalog
+
+| Primitive | Description |
+|-----------|-------------|
+| `Input` | Text, textarea, number, password, with label + validation |
+| `Select` | Dropdown, single or multi |
+| `Toggle` | Boolean switch with label |
+| `Checkbox` | Checkbox with label |
+| `Button` | Text button, variants: primary, secondary, danger, ghost |
+| `IconButton` | Icon-only button with tooltip |
+| `ColorPicker` | Color input with hex text field |
+| `Menu` | Dropdown or context menu, item list with icons + shortcuts |
+| `Dialog` | Modal: confirm, prompt, or custom form content |
+| `Toast` | Transient notification: success, error, warning, info |
+| `Badge` | Inline label: accent, success, danger, warning, muted |
+| `Avatar` | Image circle with upload affordance |
+| `Tabs` | Tab bar with content switching |
+| `FormGroup` | Label + input + validation message + help text |
+| `SectionHeader` | Titled section divider |
+| `EmptyState` | Placeholder with icon + message + action |
+| `Spinner` | Loading indicator |
+| `Table` | Sortable, paginated data table |
+
+### Implementation
+
+Each primitive is a factory function that returns a DOM element (or
+attaches to an existing one). No classes, no inheritance — just
+functions.
+
+```js
+// Example — not prescriptive API, just the pattern:
+Primitives.toggle({ label: 'Auto-search', value: true, onChange: fn })
+Primitives.menu({ anchor: el, items: [...], onSelect: fn })
+Primitives.dialog({ title: 'Confirm', body: el, onConfirm: fn })
+```
+
+Current locations: `ui-primitives.js`, `ui-primitives-additions.js`,
+and Go template components (`model-select.html`, `team-select.html`,
+`file-upload.html`). These converge into one coherent set.
+
+### Go Template Primitives
+
+Some primitives need server-rendered initial state (e.g., model-select
+needs the model list, team-select needs the team list). These are Go
+template partials that render the HTML + initial data, then JS hydrates
+interactivity on load.
+
+```html
+{{template "model-select" dict "ID" "channelModel" "Models" .Models "Type" "chat"}}
+```
+
+The JS primitive attaches to the server-rendered DOM by ID, not by
+replacing it.
+
+---
+
+## Layer 2: Components
+
+Domain-aware compositions of primitives. Each component owns its DOM
+subtree, manages its own state, and exposes a clean API for the surface
+to interact with.
+
+### Catalog
+
+| Component | Primitives Used | Domain |
+|-----------|----------------|--------|
+| `ChatPane` | Input, Button, Menu, Spinner, Toast | Channels, Messages, Streaming |
+| `NoteEditor` | Input (CM6), Menu, Tabs, Badge | Notes, Wikilinks, Folders |
+| `FileTree` | Menu (context), Button, Spinner | Workspaces, Files |
+| `ModelSelector` | Select, Badge, Spinner | Models, Capabilities, Health |
+| `PersonaPicker` | Select, Badge, Avatar | Personas, Scopes |
+| `KBPicker` | Toggle, Badge, Select | Knowledge Bases, Discoverability |
+| `ProjectSidebar` | Menu, Tabs, Badge, EmptyState | Projects, Channels, DnD |
+| `AuditLog` | Table, Select, Badge | Audit, Filtering, Pagination |
+| `CodeEditor` | Input (CM6), Select, Tabs | Workspaces, Languages |
+| `SettingsForm` | FormGroup, Toggle, Select, Button | User/Admin Settings |
+
+### Instance Pattern
+
+Components use the factory pattern established by ChatPane:
+
+```js
+const pane = ChatPane.create({
+ messagesEl: document.getElementById('editorChatMessages'),
+ inputEl: document.getElementById('editorChatInput'),
+ sendBtnEl: document.getElementById('editorSendBtn'),
+ channelId: workspaceChatId,
+ standalone: true,
+});
+
+// Lifecycle
+pane.renderMessages(msgs);
+pane.streamResponse(resp, msgs);
+pane.destroy();
+```
+
+Components can be instantiated multiple times on the same page (editor
+has a ChatPane, notes has a ChatPane — independent instances).
+
+### Server-Rendered Shell + JS Hydration
+
+Components have a Go template partial for the server-rendered scaffold:
+
+```html
+{{template "chat-pane" dict "ID" "editor"}}
+```
+
+This renders the DOM structure with predictable IDs. The JS component
+attaches to these IDs on DOMContentLoaded. No client-side DOM
+construction for the initial layout.
+
+---
+
+## Layer 3: Surfaces
+
+A surface is a full-page layout that composes components. Each surface
+declares:
+
+1. **What components it uses** (ChatPane, NoteEditor, FileTree, etc.)
+2. **How they're arranged** (CSS grid/flex layout)
+3. **What data it needs on load** (Go data loader)
+4. **What JS runs on boot** (script block or module)
+
+### Current Surfaces
+
+| Surface | Components | Data Loader |
+|---------|-----------|-------------|
+| Chat | ChatPane, ProjectSidebar, ModelSelector, PersonaPicker | channels, personas, models, projects |
+| Editor | FileTree, CodeEditor, ChatPane (assist) | workspace, files, models |
+| Notes | NoteEditor, NoteGraph, ChatPane (assist) | notes, folders, graph |
+| Admin | AuditLog, SettingsForm, Table (various) | users, configs, models, health |
+| Settings | SettingsForm, KBPicker, PersonaPicker | profile, preferences, policies |
+
+### Surface Registration
+
+Today: hardcoded in Go (`pageEngine.RenderSurface("chat")`).
+
+Future: manifest-driven. An admin-installed extension declares a
+surface in its manifest:
+
+```json
+{
+ "surfaces": [{
+ "id": "triage-intake",
+ "route": "/s/triage",
+ "title": "Triage Intake",
+ "components": ["chat-pane", "settings-form"],
+ "data_requires": ["personas", "models"],
+ "script": "surfaces/triage.js",
+ "auth": "authenticated"
+ }]
+}
+```
+
+The page engine reads registered surfaces, generates routes, assembles
+data loaders from the declared requirements, and renders a shell
+template that loads the surface's script.
+
+**Route namespace:** Admin-created surfaces live under `/s/:slug` to
+avoid collision with core routes.
+
+### Data Loaders
+
+Each surface declares what data it needs. The page engine has a
+registry of data providers:
+
+```
+"personas" → loads personas for the current user
+"models" → loads enabled models with health status
+"channels" → loads user's channels
+"workspace" → loads workspace by :wsId param
+"notes" → loads notes list
+...
+```
+
+The surface's `data_requires` field pulls from this registry. Data is
+injected into the page as `window.__PAGE_DATA__` (existing pattern).
+The surface's JS reads from there on boot — no waterfall of API calls
+on page load.
+
+---
+
+## Layer 4: Banners
+
+The banner is global chrome outside the surface boundary. It exists
+at the top, bottom, or both — configured by the platform admin via
+`PUT /admin/settings/banner`.
+
+```json
+{
+ "enabled": true,
+ "text": "DEVELOPMENT",
+ "position": "both|top|bottom",
+ "bg": "#007a33",
+ "fg": "#ffffff"
+}
+```
+
+The Go template base layout renders banners before and after the
+surface content area. CSS custom properties (`--banner-top-height`,
+`--banner-bottom-height`, `--banner-bg`, `--banner-fg`) allow surfaces
+to account for banner space without knowing banner state.
+
+Banners are not configurable by extensions or themes. They are a
+platform-level trust signal.
+
+---
+
+## Themes
+
+A theme is a set of CSS custom property overrides applied to ``.
+
+```css
+[data-theme="corporate"] {
+ --bg: #f5f5f5;
+ --text: #1a1a1a;
+ --accent: #0066cc;
+ --border: #d1d5db;
+ --font-ui: 'Inter', sans-serif;
+ --font-code: 'Fira Code', monospace;
+ --radius: 4px;
+ /* ... full property set */
+}
+```
+
+### What a Theme Can Do
+
+- Override any CSS custom property in the theme contract
+- Change colors, fonts, border radii, spacing scale
+- Switch between light and dark base palettes
+- Apply to every primitive, component, and surface uniformly
+
+### What a Theme Cannot Do
+
+- Add or remove DOM elements
+- Execute JavaScript
+- Modify component behavior or layout
+- Override banner appearance (platform chrome)
+- Access APIs or user data
+
+### Theme Contract
+
+The set of CSS custom properties that all primitives read from. This
+is the stable API between themes and the UI. Properties are namespaced:
+
+```
+--bg, --bg-secondary, --bg-tertiary (backgrounds)
+--text, --text-secondary, --text-muted (typography)
+--accent, --accent-hover, --accent-muted (interactive)
+--border, --border-strong (edges)
+--success, --warning, --danger (semantic)
+--font-ui, --font-code (typefaces)
+--radius, --radius-lg (shapes)
+--shadow, --shadow-lg (elevation)
+--banner-bg, --banner-fg (read-only, set by platform)
+```
+
+Themes are admin-installed. An admin uploads a CSS file that declares
+a `[data-theme="name"]` rule set. Users can select from installed
+themes in Settings → Appearance.
+
+Built-in themes: `dark` (default), `light`. The system preference
+auto-detection (`prefers-color-scheme`) continues to work.
+
+---
+
+## Extension Hooks
+
+Extensions interact with the application through a defined set of
+hooks. Each hook has a specific trigger point in the lifecycle and
+a clear contract for what the extension receives and returns.
+
+### Hook 1: Pre-Completion
+
+**When:** After the user sends a message, before the API request fires.
+**Receives:** The completion request object (model, messages, tools, etc.)
+**Returns:** Modified request object (or unmodified to pass through).
+**Use case:** Inject additional tools, modify system prompt, add context.
+
+```js
+ctx.hooks.preCompletion(request => {
+ request.tools.push(myCustomTool);
+ return request;
+});
+```
+
+### Hook 2: Stream Processing
+
+**When:** On each SSE chunk during streaming.
+**Receives:** The chunk (content delta, tool_use, tool_result, etc.)
+**Returns:** Modified chunk (or null to suppress).
+**Use case:** Transform content, intercept tool calls, accumulate data.
+
+```js
+ctx.hooks.streamChunk((chunk, context) => {
+ if (chunk.type === 'tool_result' && chunk.name === 'image_gen') {
+ context.displayContent.push({ type: 'image', src: chunk.data.url });
+ return null; // suppress from text content
+ }
+ return chunk;
+});
+```
+
+### Hook 3: Post-Render
+
+**When:** After a message is rendered into the DOM.
+**Receives:** The message container element, the message object.
+**Returns:** Nothing (mutates DOM in place).
+**Use case:** Add action buttons, wrap elements, enhance display.
+
+This is how extensions compose with each other's output. Extension A
+produces an image via display content. Extension B's post-render hook
+finds `` elements and wraps them with action buttons. They don't
+know about each other — they agree on the DOM contract.
+
+```js
+ctx.hooks.postRender((containerEl, message) => {
+ containerEl.querySelectorAll('img[data-display-content]').forEach(img => {
+ const actions = document.createElement('div');
+ actions.className = 'image-actions';
+ actions.innerHTML = '';
+ img.parentElement.appendChild(actions);
+ });
+});
+```
+
+### Hook 4: Display Content
+
+**When:** After a completion finishes (all chunks received).
+**Receives:** The assistant message object, accumulated display content.
+**Returns:** Display content items to attach to the message.
+
+Display content is **message-scoped, rendered inline, but excluded from
+the LLM context window**. It's not in `messages.content` and not sent
+back on the next turn.
+
+```
+Message
+├── content (text — goes to LLM)
+├── attachments (files — go to LLM via multimodal assembly)
+└── display_content[] ← rendered inline, NOT sent to LLM
+ ├── { type: "image", src: "...", extension_id: "img-gen" }
+ └── { type: "html", content: "
...", extension_id: "editor" }
+```
+
+**Storage:** Display content is persisted in a `display_content` JSONB
+column on the message (or a junction table). It survives page reload
+and is included in `GET /channels/:id/path` responses.
+
+### Hook 5: Tool Bridge
+
+**When:** The LLM invokes a tool registered by the extension.
+**Receives:** Tool name and input parameters.
+**Returns:** Tool result (string or structured).
+
+Existing mechanism — the WebSocket tool bridge from EXTENSIONS.md.
+Tool is registered via manifest, exposed to the LLM through the
+tools list, and executed client-side (browser tier) or server-side
+(starlark/sidecar tiers).
+
+### Hook 6: Surface Injection
+
+**When:** Surface boot (DOMContentLoaded).
+**Receives:** The surface ID and available mount points.
+**Returns:** Nothing (attaches to mount points).
+
+Extensions declare which surfaces they target and which mount points
+they use:
+
+```json
+{
+ "surface_injections": [{
+ "surface": "chat",
+ "mount_point": "side-panel",
+ "component": "my-panel.js"
+ }, {
+ "surface": "admin",
+ "mount_point": "section",
+ "section_id": "my-admin-section",
+ "label": "Image Gen Settings"
+ }]
+}
+```
+
+Mount points are declared by each surface template:
+
+```html
+
+
+```
+
+---
+
+## Trust Model
+
+Two tiers. The admin is the trust boundary.
+
+### Privileged (Admin-Installed)
+
+| Capability | Allowed |
+|-----------|---------|
+| Full DOM access | Yes |
+| Direct API calls (user's JWT) | Yes |
+| Modify other extensions' output (post-render) | Yes |
+| Create surfaces (own routes) | Yes |
+| Register tools | Yes |
+| Access all hook types | Yes |
+| Go template data loader | Yes |
+| Read `window.__PAGE_DATA__` | Yes |
+
+The admin chose to install it. Same trust model as a VS Code extension
+or a WordPress plugin — you trust the publisher.
+
+### Sandboxed (User-Installed, Future)
+
+| Capability | Allowed |
+|-----------|---------|
+| Shadow DOM / iframe only | Yes |
+| API calls through message bridge | Yes (proxied, scoped) |
+| Modify other extensions' output | No |
+| Create surfaces | No |
+| Register tools | Limited (user-scoped) |
+| Access hooks | Post-render own output only |
+| Go template data loader | No |
+| Read `window.__PAGE_DATA__` | No |
+
+Post-1.0 scope. Documented here to confirm the architecture
+accommodates it without redesign.
+
+---
+
+## Extension Lifecycle
+
+### Boot Sequence
+
+```
+1. Page loads (Go template renders surface shell + banner)
+2. Primitives initialize (CSS loaded, JS factories available)
+3. Components hydrate (attach to server-rendered DOM)
+4. Extensions.boot() — idempotent, runs on every surface
+ a. Load extension manifests
+ b. Register hooks (pre-completion, post-render, etc.)
+ c. Register surface injections for current surface
+ d. Initialize tool bridges
+5. Surface-specific JS runs (app.js for chat, editor-mode.js, etc.)
+```
+
+`Extensions.boot()` replaces the current chat-specific
+`Extensions.loadAll()` / `Extensions.initAll()` pair and runs on
+every surface.
+
+### Manifest Declaration
+
+```json
+{
+ "id": "image-generator",
+ "name": "AI Image Generator",
+ "version": "1.0.0",
+ "tier": "browser",
+ "hooks": {
+ "pre_completion": "hooks/pre-completion.js",
+ "stream_chunk": "hooks/stream.js",
+ "post_render": "hooks/post-render.js"
+ },
+ "tools": [{
+ "name": "generate_image",
+ "description": "Generate an image from a text description",
+ "parameters": { ... }
+ }],
+ "surfaces": [{
+ "id": "image-gallery",
+ "route": "/s/gallery",
+ "title": "Image Gallery",
+ "components": ["chat-pane"],
+ "data_requires": ["channels"],
+ "script": "surfaces/gallery.js"
+ }],
+ "surface_injections": [{
+ "surface": "chat",
+ "mount_point": "message-actions",
+ "script": "injections/image-actions.js"
+ }],
+ "theme": null,
+ "settings_schema": { ... }
+}
+```
+
+---
+
+## Display Content — Data Model
+
+### Message Extension
+
+```sql
+ALTER TABLE messages ADD COLUMN display_content JSONB DEFAULT '[]';
+```
+
+Array of display items:
+
+```json
+[
+ {
+ "type": "image",
+ "src": "data:image/png;base64,...",
+ "alt": "A cat in a spacesuit",
+ "extension_id": "image-generator",
+ "metadata": { "model": "dall-e-3", "size": "1024x1024" }
+ },
+ {
+ "type": "html",
+ "content": "
...
",
+ "extension_id": "data-viz",
+ "metadata": {}
+ }
+]
+```
+
+### API Contract
+
+Display content is included in message objects returned by
+`GET /channels/:id/path` and `GET /channels/:id/messages`.
+
+It is **excluded** from the message array sent to the LLM provider
+in `POST /chat/completions`. The completion handler strips
+`display_content` when assembling the provider request.
+
+### Rendering
+
+During `renderMessages()`, after the message content is rendered,
+each `display_content` item is rendered below the text content:
+
+```html
+
+
Here's the image you requested:
+
+
+
+
+```
+
+The `data-display-content` attribute is the DOM contract that
+post-render hooks use to discover display content from any extension.
+
+---
+
+## Migration Path
+
+| Current State | Target State |
+|---------------|-------------|
+| `ui-primitives.js` + `ui-primitives-additions.js` | Primitive catalog with stable factory API |
+| `ChatPane.create()` (v0.22.7) | Component catalog with shared instance pattern |
+| 5 Go template routes | Surface registry (hardcoded core, manifest-driven extensions) |
+| `ctx.ui.replace()` / `ctx.ui.inject()` | Hook 6: Surface Injection with declared mount points |
+| `ctx.surfaces.register()` | Manifest `surfaces` field → page engine route registration |
+| Block renderers in `Extensions.initAll()` | `Extensions.boot()` on every surface |
+| Message content only (text + attachments) | `display_content` column for render-only visual content |
+| Block renderer pipeline (mermaid, katex) | Post-render hook + DOM contract (`data-display-content`) |
+| `[data-theme]` with CSS variables | Theme contract: stable CSS custom property namespace |
+
+---
+
+## Implementation Sequence
+
+This does not prescribe version numbers. It describes dependency order.
+
+**Phase 1: Primitive consolidation**
+- Audit `ui-primitives.js`, `ui-primitives-additions.js`, and Go
+ template components
+- Define the primitive catalog and factory API
+- Define the CSS custom property contract (theme API)
+- Migrate existing callers to the consolidated API
+
+**Phase 2: Component formalization**
+- ChatPane already exists (v0.22.7). Formalize the instance pattern.
+- Extract NoteEditor, FileTree, ModelSelector as components
+- Each component gets a Go template partial + JS hydration
+
+**Phase 3: Extension hooks**
+- `Extensions.boot()` on every surface (v0.22.8)
+- Pre-completion and post-render hooks
+- Display content data model (message column + render pipeline)
+- Stream processing hook
+
+**Phase 4: Surface registry**
+- Manifest-driven surface registration
+- Dynamic route generation in page engine
+- Data loader registry
+- Mount point declaration and injection
+
+**Phase 5: Theme system**
+- Stable CSS custom property contract
+- Admin theme upload
+- User theme selection in Settings → Appearance
+
+---
+
+## Open Questions
+
+1. **Display content storage:** JSONB column on messages vs. separate
+ `message_display_content` table. Column is simpler; table allows
+ independent lifecycle (delete display content without touching
+ message). Leaning column for KISS.
+
+2. **Display content size limits:** Base64 images in JSONB could get
+ large. May need blob storage for display content with URL references
+ instead of inline data. Deferred until real usage patterns emerge.
+
+3. **Extension ordering:** Post-render hooks from multiple extensions
+ run in what order? Manifest priority field? Installation order?
+ Alphabetical? Matters when Extension B adds buttons to Extension A's
+ images — A must render first.
+
+4. **Surface layout persistence:** If a surface supports resizable
+ panes (editor: file tree width, chat pane width), where is that
+ persisted? User settings? Per-workspace? Per-surface?
+
+5. **Extension settings storage:** Extensions need per-user config
+ (API keys for image gen, preferences for behavior). Current
+ `UpdateUserExtensionSettings` endpoint exists. Is the schema
+ sufficient or does it need structured validation from
+ `settings_schema` in the manifest?
diff --git a/docs/ICD-API.md b/docs/ICD-API.md
new file mode 100644
index 0000000..b3e19a6
--- /dev/null
+++ b/docs/ICD-API.md
@@ -0,0 +1,2225 @@
+# ICD-API — Chat Switchboard Backend API Contract
+
+**Version:** 0.22.7
+**Updated:** 2026-03-03
+**Audience:** Frontend developers, integrators, API consumers
+
+> **Organization principle:** Each section is one domain — the complete story.
+> Every endpoint that touches Knowledge Bases is in §5, every endpoint that
+> touches Personas is in §4, etc. Admin and user endpoints live together in
+> their domain section. Cross-cutting platform admin (user management, global
+> settings, audit) lives in §16.
+
+---
+
+## Table of Contents
+
+1. [Conventions](#1-conventions)
+2. [Auth](#2-auth)
+3. [Channels & Conversations](#3-channels--conversations)
+4. [Personas](#4-personas)
+5. [Knowledge Bases](#5-knowledge-bases)
+6. [Notes](#6-notes)
+7. [Workspaces & Git](#7-workspaces--git)
+8. [Projects](#8-projects)
+9. [Memory](#9-memory)
+10. [Providers & Routing](#10-providers--routing)
+11. [Models & Preferences](#11-models--preferences)
+12. [Notifications](#12-notifications)
+13. [Extensions](#13-extensions)
+14. [User Profile & Settings](#14-user-profile--settings)
+15. [Teams & Access Control](#15-teams--access-control)
+16. [Platform Administration](#16-platform-administration)
+17. [Export & Utility](#17-export--utility)
+18. [WebSocket Protocol](#18-websocket-protocol)
+19. [Appendix: Enums](#19-appendix-enums)
+
+---
+
+## 1. Conventions
+
+### Base URL
+
+```
+{scheme}://{host}{BASE_PATH}/api/v1
+```
+
+`BASE_PATH` is empty by default. In path-routed K8s deployments (e.g.
+`/staging/`), all routes shift accordingly. The frontend reads `BASE_PATH`
+from a `` tag injected by the Go template engine.
+
+### Authentication
+
+JWT bearer tokens. Every request to a `protected` or `admin` route requires:
+
+```
+Authorization: Bearer {access_token}
+```
+
+**Access token:** HS256 JWT, 15-minute TTL. Claims: `user_id`, `email`,
+`role`, `exp`, `iat`, `jti`.
+
+**Refresh token:** Opaque, DB-stored, 7-day TTL. Used to obtain new
+access tokens without re-login.
+
+**WebSocket fallback:** `?token={access_token}` query parameter when
+the `Authorization` header isn't available.
+
+**Cookie sync:** On every token save/refresh, the frontend writes
+`sb_token` as a cookie. Go template page routes read this cookie via
+`AuthOrRedirect` middleware for server-rendered surfaces.
+
+### Authorization Tiers
+
+| Tier | Middleware | Who |
+|------|-----------|-----|
+| Public | none | Anyone (health, login, register, public settings) |
+| Authenticated | `Auth()` | Any logged-in user |
+| Team Admin | `RequireTeamAdmin()` | Admin of the specific team |
+| Platform Admin | `RequireAdmin()` | Users with `role = "admin"` |
+
+### Error Envelope
+
+All errors return:
+
+```json
+{ "error": "human-readable message" }
+```
+
+Standard HTTP status codes: 400 (bad request), 401 (not authenticated),
+403 (not authorized), 404 (not found), 409 (conflict), 500 (server error),
+502 (upstream provider failure).
+
+### Pagination Envelope
+
+Endpoints that paginate return:
+
+```json
+{
+ "data": [...],
+ "page": 1,
+ "per_page": 50,
+ "total": 142,
+ "total_pages": 3
+}
+```
+
+Query params: `?page=1&per_page=50`. Not all list endpoints paginate —
+some return `{ "data": [...] }` or a bare array in a named key.
+
+### ID Format
+
+All resource IDs are UUIDv4 strings, generated application-side via
+`store.NewID()` (Go `uuid.New()`). This ensures compatibility across
+both Postgres and SQLite backends.
+
+### Timestamps
+
+ISO 8601 with timezone: `"2025-06-15T14:30:00Z"`. Stored as
+`TIMESTAMPTZ` (Postgres) or `TEXT` (SQLite).
+
+### Naming Note
+
+The domain concept is **Persona** (identity + system prompt + model +
+KB bindings + grant scoping). API routes currently use `/personas` for
+historical reasons. A pre-1.0 rename to `/personas` is planned. This
+document uses "Persona" for the concept and shows the current route
+paths as-is.
+
+---
+
+## 2. Auth
+
+Four endpoints. No token required for any of them.
+
+### Register
+
+```
+POST /auth/register
+```
+
+```json
+{
+ "username": "jdoe",
+ "password": "...",
+ "email": "jdoe@example.com", // optional
+ "display_name": "Jane Doe" // optional
+}
+```
+
+Gated by `allow_registration` policy. Returns same shape as Login.
+
+### Login
+
+```
+POST /auth/login
+```
+
+```json
+{ "username": "jdoe", "password": "..." }
+```
+
+**Response:**
+
+```json
+{
+ "access_token": "eyJ...",
+ "refresh_token": "opaque-string",
+ "user": {
+ "id": "uuid",
+ "username": "jdoe",
+ "email": "jdoe@example.com",
+ "display_name": "Jane Doe",
+ "role": "user",
+ "avatar_url": "/api/v1/profile/avatar?v=1234",
+ "created_at": "...",
+ "last_login": "..."
+ }
+}
+```
+
+### Refresh
+
+```
+POST /auth/refresh
+```
+
+```json
+{ "refresh_token": "opaque-string" }
+```
+
+Returns new `access_token` and `refresh_token`. Old refresh token is
+invalidated (rotation).
+
+### Logout
+
+```
+POST /auth/logout
+```
+
+```json
+{ "refresh_token": "opaque-string" }
+```
+
+Revokes the refresh token. Returns `{ "message": "logged out" }`.
+
+---
+
+## 3. Channels & Conversations
+
+The **channel** is the universal conversation container. Messages,
+tool activity, attachments, and KB bindings all hang off a channel.
+Today channels are single-user direct chats; the architecture
+anticipates multi-participant channels for v0.23.0.
+
+### 3.1 Channel CRUD
+
+**List channels** — paginated, sorted by last activity.
+
+```
+GET /channels?page=1&per_page=50
+```
+
+Returns pagination envelope. Each channel:
+
+```json
+{
+ "id": "uuid",
+ "user_id": "uuid",
+ "title": "My Chat",
+ "type": "direct",
+ "description": "",
+ "model": "claude-sonnet-4-20250514",
+ "provider_config_id": "uuid|null",
+ "system_prompt": "",
+ "project_id": "uuid|null",
+ "folder": "string|null",
+ "tags": ["tag1", "tag2"],
+ "created_at": "...",
+ "updated_at": "..."
+}
+```
+
+**Create channel:**
+
+```
+POST /channels
+```
+
+```json
+{
+ "title": "New Chat",
+ "type": "direct",
+ "description": "",
+ "model": "claude-sonnet-4-20250514",
+ "provider_config_id": "uuid",
+ "system_prompt": "",
+ "folder": null,
+ "tags": []
+}
+```
+
+On create, if `model` and `provider_config_id` are provided, the
+channel model roster (`channel_models`) is auto-populated with an
+initial entry.
+
+**Get channel:**
+
+```
+GET /channels/:id
+```
+
+Returns single channel object.
+
+**Update channel:**
+
+```
+PUT /channels/:id
+```
+
+Accepts partial updates — only fields present in the body are changed.
+Same field set as create.
+
+**Delete channel:**
+
+```
+DELETE /channels/:id
+```
+
+Cascades: deletes messages, attachments, channel_models, channel_kbs.
+
+### 3.2 Message Tree
+
+Switchboard uses a **tree** model for messages, not a flat list. Each
+message has a `parent_id` (NULL for root). Editing creates a sibling
+branch; regeneration creates an alternative sibling. The **cursor**
+tracks which child is "active" at each branch point.
+
+**Get active path** — the primary endpoint for loading chat history:
+
+```
+GET /channels/:id/path
+```
+
+Returns the messages along the active branch from root to leaf,
+following the cursor at each fork. This is what the UI renders.
+
+```json
+{
+ "messages": [
+ {
+ "id": "uuid",
+ "channel_id": "uuid",
+ "parent_id": "uuid|null",
+ "role": "user|assistant|system|tool",
+ "content": "...",
+ "model": "claude-sonnet-4-20250514|null",
+ "provider_config_id": "uuid|null",
+ "thinking": "...|null",
+ "tool_calls": [...],
+ "tool_results": [...],
+ "attachments": [...],
+ "has_siblings": true,
+ "sibling_index": 0,
+ "sibling_count": 2,
+ "created_at": "..."
+ }
+ ]
+}
+```
+
+**List all messages** — includes all branches, flat:
+
+```
+GET /channels/:id/messages
+```
+
+Legacy/debug endpoint. Returns every message in the channel regardless
+of branch. Not used by the standard frontend.
+
+**Create message:**
+
+```
+POST /channels/:id/messages
+```
+
+```json
+{
+ "role": "user",
+ "content": "Hello",
+ "parent_id": "uuid|null",
+ "attachments": ["attachment-uuid-1"]
+}
+```
+
+**Edit message** — creates a sibling at the same level:
+
+```
+POST /channels/:id/messages/:msgId/edit
+```
+
+```json
+{ "content": "Revised question" }
+```
+
+Creates a new message with the same `parent_id` as the original.
+Automatically updates the cursor to point to the new sibling.
+
+**Regenerate** — creates an alternative assistant response:
+
+```
+POST /channels/:id/messages/:msgId/regenerate
+```
+
+```json
+{
+ "model": "claude-sonnet-4-20250514",
+ "provider_config_id": "uuid"
+}
+```
+
+Creates a new empty assistant message as a sibling of `msgId`,
+then streams the completion into it. The cursor is updated.
+
+**Update cursor** — switch to a different branch:
+
+```
+PUT /channels/:id/cursor
+```
+
+```json
+{
+ "message_id": "uuid",
+ "child_id": "uuid"
+}
+```
+
+Sets which child is active at the `message_id` branch point.
+Subsequent `GET /channels/:id/path` will follow the new branch.
+
+**List siblings** — all children of a message's parent:
+
+```
+GET /channels/:id/messages/:msgId/siblings
+```
+
+Returns `{ "siblings": [...] }` with abbreviated message objects
+(id, role, content preview, created_at).
+
+### 3.3 Completions & Streaming
+
+The single most complex endpoint. Sends a message to an LLM provider
+and streams the response via Server-Sent Events.
+
+```
+POST /chat/completions
+```
+
+**Request:**
+
+```json
+{
+ "channel_id": "uuid",
+ "message": "User's message text",
+ "model": "claude-sonnet-4-20250514",
+ "provider_config_id": "uuid",
+ "persona_id": "uuid|null",
+ "parent_id": "uuid|null",
+ "tools_enabled": true,
+ "tool_ids": ["web_search", "calculator"],
+ "attachments": ["attachment-uuid"],
+ "extra_body": {}
+}
+```
+
+`channel_id` is required. `chat_id` is accepted as a deprecated alias
+(maps to `channel_id`; frontend no longer sends it — removal planned).
+
+`extra_body` is a freeform JSON object merged into the provider request
+(provider-specific parameters like `temperature`, `reasoning`, etc.).
+
+**Persona resolution:** If `persona_id` is set, the handler loads the
+persona's system prompt, model, provider_config_id, and KB bindings.
+Per-message `model`/`provider_config_id` override the persona's defaults.
+
+**Routing resolution:** After config resolution, the routing policy
+evaluator (`evaluateRouting()`) may redirect to a different provider
+based on active policies (see §10.4). The winning provider config's
+credentials are loaded and used for the actual API call.
+
+**SSE Stream Format:**
+
+The response is `Content-Type: text/event-stream`. Events:
+
+```
+data: {"content":"Hello"}
+
+data: {"content":" world"}
+
+data: {"reasoning":"Let me think..."}
+
+event: tool_use
+data: {"id":"call_1","name":"web_search","input":{"query":"..."}}
+
+event: tool_result
+data: {"id":"call_1","content":"Search results..."}
+
+data: {"content":"Based on the search..."}
+
+event: finish
+data: {"message_id":"uuid","model":"claude-sonnet-4-20250514","usage":{"input_tokens":150,"output_tokens":42}}
+
+data: [DONE]
+```
+
+| Event | `event:` field | Payload |
+|-------|---------------|---------|
+| Content delta | _(unnamed)_ | `{ "content": "text" }` |
+| Reasoning delta | _(unnamed)_ | `{ "reasoning": "text" }` |
+| Tool invocation | `tool_use` | `{ "id", "name", "input" }` |
+| Tool result | `tool_result` | `{ "id", "content" }` |
+| Stream complete | `finish` | `{ "message_id", "model", "usage" }` |
+| End sentinel | _(unnamed)_ | `[DONE]` (literal string) |
+| Error mid-stream | `error` | `{ "error": "message" }` |
+
+**Tool cycle:** Tool use and tool result events can repeat up to
+`max_tool_iterations` (configured server-side). The handler executes
+tools, feeds results back to the model, and continues streaming.
+
+**Response headers:**
+
+```
+X-Switchboard-Provider: providerID/configID
+X-Switchboard-Route: policy-name (when routing policy is active)
+```
+
+**List available tools:**
+
+```
+GET /tools
+```
+
+Returns `{ "tools": [...] }` where each tool has `name`, `description`,
+`parameters` (JSON Schema), and `category`.
+
+### 3.4 Multi-Model Roster
+
+Channels can have multiple AI models. The `channel_models` table tracks
+the roster. `@mention` in message content routes to specific models.
+
+```
+GET /channels/:id/models → { "models": [...] }
+POST /channels/:id/models ← { "model": "...", "provider_config_id": "...", "display_name": "..." }
+PATCH /channels/:id/models/:modelId ← { "display_name": "..." }
+DELETE /channels/:id/models/:modelId
+```
+
+Each roster entry:
+
+```json
+{
+ "id": "uuid",
+ "channel_id": "uuid",
+ "model": "claude-sonnet-4-20250514",
+ "provider_config_id": "uuid",
+ "display_name": "Claude",
+ "is_default": true,
+ "created_at": "..."
+}
+```
+
+### 3.5 Channel Utilities
+
+**Summarize channel** (compaction):
+
+```
+POST /channels/:id/summarize
+```
+
+Triggers conversation summarization via the utility model role.
+Returns `{ "message": "summarized", "summary_id": "uuid" }`.
+
+**Generate title:**
+
+```
+POST /channels/:id/generate-title
+```
+
+Uses the utility model to auto-generate a title from the first
+exchange. Returns `{ "title": "Generated Title" }`.
+
+### 3.6 Attachments
+
+File attachments are channel-scoped. Upload stores the file in blob
+storage; a background extraction pipeline processes documents (PDF,
+DOCX, XLSX, etc.) into searchable text.
+
+**Upload:**
+
+```
+POST /channels/:id/attachments
+Content-Type: multipart/form-data
+```
+
+Field: `file`. Returns the attachment object.
+
+**List by channel:**
+
+```
+GET /channels/:id/attachments
+```
+
+Returns `{ "attachments": [...] }`.
+
+**Get metadata:**
+
+```
+GET /attachments/:id
+```
+
+```json
+{
+ "id": "uuid",
+ "channel_id": "uuid",
+ "user_id": "uuid",
+ "filename": "report.pdf",
+ "content_type": "application/pdf",
+ "size": 1048576,
+ "storage_key": "attachments/chan-id/att-id_report.pdf",
+ "extracted_text_key": "...|null",
+ "metadata": {},
+ "created_at": "..."
+}
+```
+
+**Download:**
+
+```
+GET /attachments/:id/download
+```
+
+Returns the file with appropriate `Content-Type` and
+`Content-Disposition` headers.
+
+**Delete:**
+
+```
+DELETE /attachments/:id
+```
+
+---
+
+## 4. Personas
+
+A **Persona** is the unit of access control and AI identity: system
+prompt + model + provider config + KB bindings + grant scoping. Users
+interact with Personas, not raw provider configs. Admins curate which
+Personas are available; team admins create team-scoped Personas.
+
+Three scopes, three route namespaces, one domain:
+
+| Scope | Routes | Who manages |
+|-------|--------|-------------|
+| Personal | `GET/POST/PUT/DELETE /personas` | The user |
+| Team | `GET/POST/PUT/DELETE /teams/:teamId/personas` | Team admin |
+| Global (admin) | `GET/POST/PUT/DELETE /admin/personas` | Platform admin |
+
+All three return the same Persona object shape:
+
+```json
+{
+ "id": "uuid",
+ "name": "Code Reviewer",
+ "description": "Reviews code for bugs and style",
+ "system_prompt": "You are a senior code reviewer...",
+ "model": "claude-sonnet-4-20250514",
+ "provider_config_id": "uuid|null",
+ "scope": "personal|team|global",
+ "owner_id": "uuid|null",
+ "is_default": false,
+ "avatar_url": "/api/v1/personas/uuid/avatar?v=123",
+ "created_at": "...",
+ "updated_at": "..."
+}
+```
+
+### 4.1 Personal Personas
+
+Gated by `allow_user_personas` policy.
+
+```
+GET /personas → { "personas": [...], "personas": [...] }
+POST /personas ← { "name", "description", "system_prompt", "model", ... }
+PUT /personas/:id ← partial update
+DELETE /personas/:id
+```
+
+**Note:** List response currently sends both `"personas"` and `"personas"`
+keys (identical arrays) for backward compatibility. The `"personas"` key
+is what the frontend reads. Dual keys will be collapsed pre-1.0.
+
+### 4.2 Team Personas
+
+```
+GET /teams/:teamId/personas → { "personas": [...], "personas": [...] }
+POST /teams/:teamId/personas ← same shape
+PUT /teams/:teamId/personas/:id
+DELETE /teams/:teamId/personas/:id
+```
+
+### 4.3 Admin (Global) Personas
+
+```
+GET /admin/personas → { "personas": [...], "personas": [...] }
+POST /admin/personas ← same shape
+PUT /admin/personas/:id
+DELETE /admin/personas/:id
+```
+
+### 4.4 Persona KB Bindings
+
+A Persona can have Knowledge Bases bound to it. When a channel uses
+that Persona, the bound KBs are automatically scoped into `kb_search`
+tool calls. Users never see or manage the underlying KBs directly —
+they talk to the Persona.
+
+**Get bindings:**
+
+```
+GET /personas/:id/knowledge-bases → { "data": [KB objects] }
+GET /teams/:teamId/personas/:id/knowledge-bases → { "data": [...] }
+GET /admin/personas/:id/knowledge-bases → { "data": [...] }
+```
+
+**Set bindings** (replace all):
+
+```
+PUT /personas/:id/knowledge-bases
+PUT /teams/:teamId/personas/:id/knowledge-bases
+PUT /admin/personas/:id/knowledge-bases
+```
+
+```json
+{
+ "kb_ids": ["kb-uuid-1", "kb-uuid-2"],
+ "auto_search": {
+ "kb-uuid-1": true,
+ "kb-uuid-2": false
+ }
+}
+```
+
+`auto_search`: when `true`, the KB is always searched (prepended to
+context). When `false`, it's available via the `kb_search` tool but
+not auto-injected.
+
+### 4.5 Persona Avatars
+
+```
+POST /personas/:id/avatar ← multipart/form-data (field: "avatar")
+DELETE /personas/:id/avatar
+POST /admin/personas/:id/avatar
+DELETE /admin/personas/:id/avatar
+```
+
+### 4.6 Resource Grants
+
+See §15.3 for the grant system. Personas use grants to control who can
+see and use them beyond their base scope.
+
+---
+
+## 5. Knowledge Bases
+
+The **Knowledge Base** (KB) is the RAG system: upload documents → chunk
+→ embed (pgvector / app-level cosine for SQLite) → search via
+`kb_search` tool during completions.
+
+### 5.1 KB CRUD
+
+```
+GET /knowledge-bases → { "data": [KB objects] }
+POST /knowledge-bases ← { "name", "description", "scope", "team_id" }
+GET /knowledge-bases/:id → KB object
+PUT /knowledge-bases/:id ← partial update
+DELETE /knowledge-bases/:id
+```
+
+KB object:
+
+```json
+{
+ "id": "uuid",
+ "name": "Product Docs",
+ "description": "Internal product documentation",
+ "scope": "personal|team|global",
+ "owner_id": "uuid|null",
+ "team_id": "uuid|null",
+ "discoverable": true,
+ "embedding_model": "text-embedding-3-small",
+ "chunk_size": 512,
+ "chunk_overlap": 50,
+ "document_count": 12,
+ "created_at": "...",
+ "updated_at": "..."
+}
+```
+
+### 5.2 Documents
+
+**Upload:**
+
+```
+POST /knowledge-bases/:id/documents
+Content-Type: multipart/form-data
+```
+
+Field: `file`. Supported: PDF, DOCX, TXT, MD, HTML, CSV, XLSX, PPTX.
+Returns the document object with `status: "pending"`.
+
+**List:**
+
+```
+GET /knowledge-bases/:id/documents
+```
+
+Returns `{ "data": [document objects] }`.
+
+**Status** (poll during processing):
+
+```
+GET /knowledge-bases/:id/documents/:docId/status
+```
+
+Status progression: `pending → chunking → embedding → ready | error`.
+
+**Delete:**
+
+```
+DELETE /knowledge-bases/:id/documents/:docId
+```
+
+### 5.3 Search
+
+```
+POST /knowledge-bases/:id/search
+```
+
+```json
+{
+ "query": "How do I configure SSO?",
+ "limit": 5
+}
+```
+
+Returns `{ "results": [{ "content", "score", "document_id", "metadata" }] }`.
+
+### 5.4 Rebuild
+
+Re-chunks and re-embeds all documents:
+
+```
+POST /knowledge-bases/:id/rebuild
+```
+
+### 5.5 Channel KB Bindings
+
+A channel can have KBs explicitly bound to it (in addition to any KBs
+coming from the active Persona or parent Project).
+
+**Get:**
+
+```
+GET /channels/:id/knowledge-bases
+```
+
+Returns `{ "data": [ChannelKB objects] }`:
+
+```json
+{
+ "kb_id": "uuid",
+ "kb_name": "Product Docs",
+ "enabled": true,
+ "document_count": 12
+}
+```
+
+**Set** (replace all):
+
+```
+PUT /channels/:id/knowledge-bases
+```
+
+```json
+{ "kb_ids": ["kb-uuid-1", "kb-uuid-2"] }
+```
+
+### 5.6 Discoverable KBs
+
+The `discoverable` flag controls whether a KB appears in the user's KB
+picker. Non-discoverable KBs are hidden from direct access but still
+searchable through Persona bindings (enterprise KB mode).
+
+**List discoverable** (for KB picker UI):
+
+```
+GET /knowledge-bases-discoverable
+```
+
+Returns `{ "data": [KB objects] }` — only KBs the user can see
+(personal + team + discoverable global).
+
+**Toggle discoverability** (admin-enforced in handler):
+
+```
+PUT /knowledge-bases/:id/discoverable
+```
+
+```json
+{ "discoverable": false }
+```
+
+### 5.7 KB Resolution Chain
+
+When a completion fires, KBs are resolved in priority order:
+
+```
+Persona KBs → Project KBs → Channel KBs → Personal KBs
+```
+
+The `BuildKBHint` function assembles all applicable KBs into the
+system prompt, and the `kb_search` tool searches across all of them.
+
+### 5.8 Persona KB Bindings
+
+See §4.4. Persona-KB binding is the primary enterprise mechanism for
+controlled KB access.
+
+---
+
+## 6. Notes
+
+**Obsidian-style** knowledge graph with `[[wikilinks]]`, backlinks,
+graph visualization, and folder organization. Notes are user-scoped
+(personal notebook). Future: channel-scoped notes for workflow
+artifacts.
+
+### 6.1 CRUD
+
+```
+GET /notes → paginated list of noteListItem
+POST /notes ← { "title", "content", "folder" }
+GET /notes/:id → full note object
+PUT /notes/:id ← { "title", "content", "folder" }
+DELETE /notes/:id
+```
+
+Note object:
+
+```json
+{
+ "id": "uuid",
+ "user_id": "uuid",
+ "title": "Meeting Notes",
+ "content": "# Meeting Notes\n\nDiscussed [[Project Alpha]]...",
+ "folder": "work",
+ "source_message_id": "uuid|null",
+ "created_at": "...",
+ "updated_at": "..."
+}
+```
+
+`source_message_id` links a note back to the chat message that
+created it (provenance for AI-generated notes).
+
+`noteListItem` is the same but with `content` truncated or omitted.
+
+### 6.2 Search
+
+**Full-text search:**
+
+```
+GET /notes/search?q=project+alpha
+```
+
+Uses `plainto_tsquery` (Postgres) or `LIKE` fallback (SQLite).
+Returns `{ "data": [searchResult objects] }` with match highlights.
+
+**Title search** (lightweight, for wikilink autocomplete):
+
+```
+GET /notes/search-titles?q=proj
+```
+
+Returns `{ "data": [{ "id", "title" }] }`.
+
+### 6.3 Graph
+
+**Full graph topology:**
+
+```
+GET /notes/graph
+```
+
+```json
+{
+ "nodes": [{ "id": "uuid", "title": "Note Title" }],
+ "edges": [{ "source": "uuid", "target": "uuid" }],
+ "unresolved": [{ "source": "uuid", "target_title": "Missing Note" }]
+}
+```
+
+Edges are directed: source contains `[[target_title]]`. Unresolved
+edges have a `target_title` but no matching note (dangling wikilink).
+When a note with that title is created, the edge auto-resolves.
+
+**Backlinks** (who links to this note):
+
+```
+GET /notes/:id/backlinks
+```
+
+Returns `{ "data": [note objects] }` — all notes containing
+`[[this note's title]]`.
+
+### 6.4 Folders
+
+```
+GET /notes/folders
+```
+
+Returns `{ "folders": ["work", "personal", "archive"] }` — distinct
+folder values across all user notes.
+
+### 6.5 Bulk Operations
+
+```
+POST /notes/bulk-delete
+```
+
+```json
+{ "ids": ["uuid-1", "uuid-2"] }
+```
+
+---
+
+## 7. Workspaces & Git
+
+The **Workspace** is a virtual filesystem for the editor surface.
+Each workspace has a root directory, file operations, optional Git
+integration, and full-text indexing for code search.
+
+### 7.1 Workspace CRUD
+
+```
+GET /workspaces → { "data": [...] }
+POST /workspaces ← { "name", "owner_type", "owner_id" }
+GET /workspaces/:id → workspace object
+PATCH /workspaces/:id ← partial update
+DELETE /workspaces/:id
+```
+
+Workspace object:
+
+```json
+{
+ "id": "uuid",
+ "name": "my-project",
+ "owner_type": "user|project",
+ "owner_id": "uuid",
+ "status": "active|archived",
+ "storage_bytes": 1048576,
+ "file_count": 42,
+ "created_at": "...",
+ "updated_at": "..."
+}
+```
+
+### 7.2 File Operations
+
+All paths are relative to the workspace root.
+
+```
+GET /workspaces/:id/files?path=/ → { "files": [entries] }
+GET /workspaces/:id/files/read?path=/a.md → { "content": "...", "size": 123 }
+PUT /workspaces/:id/files/write ← { "path": "/a.md", "content": "..." }
+DELETE /workspaces/:id/files/delete ← { "path": "/a.md" }
+POST /workspaces/:id/files/mkdir ← { "path": "/src" }
+```
+
+File entry:
+
+```json
+{
+ "name": "main.go",
+ "path": "/src/main.go",
+ "is_dir": false,
+ "size": 4096,
+ "modified": "..."
+}
+```
+
+### 7.3 Archive
+
+**Download** (tar.gz of entire workspace):
+
+```
+GET /workspaces/:id/archive/download
+```
+
+**Upload** (restore from tar.gz):
+
+```
+POST /workspaces/:id/archive/upload
+Content-Type: multipart/form-data
+```
+
+### 7.4 Reconcile, Stats, Index
+
+**Reconcile** (sync filesystem state with DB metadata):
+
+```
+POST /workspaces/:id/reconcile
+```
+
+**Stats:**
+
+```
+GET /workspaces/:id/stats
+```
+
+Returns `{ "file_count", "storage_bytes", "indexed_files", ... }`.
+
+**Index status:**
+
+```
+GET /workspaces/:id/index-status
+```
+
+Returns indexing progress for full-text search.
+
+### 7.5 Git Integration
+
+All Git operations are workspace-scoped.
+
+```
+POST /workspaces/:id/git/clone ← { "url", "branch", "credential_id" }
+POST /workspaces/:id/git/pull
+POST /workspaces/:id/git/push
+GET /workspaces/:id/git/status → { "files": [{ "path", "status" }] }
+GET /workspaces/:id/git/diff → { "diff": "..." }
+POST /workspaces/:id/git/commit ← { "message", "files": [...] }
+GET /workspaces/:id/git/log → { "commits": [...] }
+GET /workspaces/:id/git/branches → { "branches": [...], "current": "main" }
+POST /workspaces/:id/git/checkout ← { "branch": "feature-x" }
+```
+
+### 7.6 Git Credentials
+
+User-owned, encrypted credential storage for Git operations.
+Encrypted with the user's UEK (same as BYOK keys — admins cannot
+recover).
+
+```
+POST /git-credentials ← { "name", "auth_type", "token"|"username"+"password"|"ssh_key" }
+GET /git-credentials → { "data": [GitCredentialSummary] }
+DELETE /git-credentials/:id
+```
+
+`auth_type`: `https_pat`, `https_basic`, or `ssh_key`.
+
+Summary response (never exposes encrypted data):
+
+```json
+{
+ "id": "uuid",
+ "name": "GitHub PAT",
+ "auth_type": "https_pat",
+ "created_at": "..."
+}
+```
+
+---
+
+## 8. Projects
+
+**Projects** are organizational containers that group channels,
+knowledge bases, notes, and files. They follow the scope model
+(personal, team, global).
+
+### 8.1 Project CRUD
+
+```
+GET /projects → { "data": [...] }
+POST /projects ← { "name", "description", "scope", "team_id", "persona_id", "system_prompt" }
+GET /projects/:id → project object
+PUT /projects/:id ← partial update
+DELETE /projects/:id
+```
+
+Project object:
+
+```json
+{
+ "id": "uuid",
+ "name": "Q3 Research",
+ "description": "...",
+ "scope": "personal|team|global",
+ "owner_id": "uuid",
+ "team_id": "uuid|null",
+ "persona_id": "uuid|null",
+ "system_prompt": "...|null",
+ "is_archived": false,
+ "channel_count": 5,
+ "created_at": "...",
+ "updated_at": "..."
+}
+```
+
+### 8.2 Channel Association
+
+```
+GET /projects/:id/channels → { "data": [channel objects] }
+POST /projects/:id/channels ← { "channel_id": "uuid" }
+DELETE /projects/:id/channels/:channelId
+PUT /projects/:id/channels/reorder ← { "channel_ids": ["uuid1", "uuid2"] }
+```
+
+A channel can only belong to one project. `POST` performs an atomic
+move if the channel is already in a different project.
+
+### 8.3 KB Association
+
+```
+GET /projects/:id/knowledge-bases → { "data": [KB objects] }
+POST /projects/:id/knowledge-bases ← { "kb_id": "uuid" }
+DELETE /projects/:id/knowledge-bases/:kbId
+```
+
+KBs bound to a project are automatically available to every channel
+in that project (see §5.7 resolution chain).
+
+### 8.4 Note Association
+
+```
+GET /projects/:id/notes → { "data": [note objects] }
+POST /projects/:id/notes ← { "note_id": "uuid" }
+DELETE /projects/:id/notes/:noteId
+```
+
+### 8.5 Project Files
+
+```
+GET /projects/:id/files → { "files": [...] }
+POST /projects/:id/files ← multipart/form-data
+```
+
+Project-level file uploads (distinct from workspace files and channel
+attachments).
+
+### 8.6 Admin Project Management
+
+```
+GET /admin/projects → { "data": [...] }
+DELETE /admin/projects/:id
+```
+
+Cross-instance visibility for platform admins. BYOK personal projects
+remain private (admin can delete but not read content).
+
+---
+
+## 9. Memory
+
+**Long-term memory** extracted from conversations. The system
+identifies facts, preferences, and context about the user and stores
+them for injection into future conversations.
+
+### 9.1 User Memory
+
+```
+GET /memories → { "data": [memory objects] }
+GET /memories/count → { "count": 42 }
+PUT /memories/:id ← { "content": "updated text" }
+DELETE /memories/:id
+POST /memories/:id/approve → approve a pending memory
+POST /memories/:id/reject → reject a pending memory
+```
+
+Memory object:
+
+```json
+{
+ "id": "uuid",
+ "user_id": "uuid",
+ "persona_id": "uuid|null",
+ "scope": "user|persona",
+ "content": "User prefers Go for backend work",
+ "source_channel_id": "uuid",
+ "status": "pending|approved|rejected",
+ "confidence": 0.85,
+ "created_at": "...",
+ "updated_at": "..."
+}
+```
+
+`scope`: `user` memories apply across all conversations. `persona`
+memories are specific to interactions with that Persona.
+
+### 9.2 Admin Memory Review
+
+```
+GET /admin/memories/pending → { "data": [memory objects with user info] }
+POST /admin/memories/bulk-approve ← { "ids": ["uuid1", "uuid2"] }
+```
+
+Platform admin can review and bulk-approve pending memories across
+all users.
+
+---
+
+## 10. Providers & Routing
+
+The **multi-provider** system. One or more LLM providers are configured,
+each with their own API keys, endpoints, and model catalogs. The routing
+layer decides which provider handles each request.
+
+### 10.1 User BYOK Provider Configs
+
+Gated by `allow_user_byok` policy. Personal API keys are encrypted with
+the user's UEK (per-user encryption key, Argon2id-derived). Platform
+admins cannot recover personal keys.
+
+```
+GET /api-configs → { "configs": [safeConfig objects] }
+POST /api-configs ← { "name", "provider", "endpoint", "api_key", ... }
+GET /api-configs/:id → safeConfig
+PUT /api-configs/:id ← partial update (api_key optional)
+DELETE /api-configs/:id
+```
+
+`safeConfig` — API keys are **never** returned:
+
+```json
+{
+ "id": "uuid",
+ "name": "My OpenAI",
+ "provider": "openai",
+ "endpoint": "https://api.openai.com/v1",
+ "model_default": "gpt-4o",
+ "scope": "personal",
+ "owner_id": "uuid",
+ "is_active": true,
+ "has_key": true,
+ "config": {},
+ "headers": {},
+ "settings": {},
+ "created_at": "..."
+}
+```
+
+**List models for a user config:**
+
+```
+GET /api-configs/:id/models
+```
+
+Returns `{ "models": [catalog entries] }`.
+
+**Fetch/sync models from provider API:**
+
+```
+POST /api-configs/:id/models/fetch
+```
+
+Calls the provider's model list API, upserts into the local catalog.
+
+### 10.2 Admin Global Provider Configs
+
+```
+GET /admin/configs → { "configs": [configWithKey objects] }
+POST /admin/configs ← { "name", "provider", "endpoint", "api_key", "config", "headers", "settings", "is_private" }
+PUT /admin/configs/:id ← partial update
+DELETE /admin/configs/:id
+```
+
+`configWithKey` is the same as `safeConfig` but comes from
+`ListGlobal` — still redacts API keys, just adds the `has_key` flag.
+
+Admin configs use the `ENCRYPTION_KEY` env var (not per-user UEK).
+`is_private`: when true, the config is available for admin-created
+Personas but not directly selectable by users.
+
+### 10.3 Model Catalog (Admin)
+
+The catalog is populated by fetching from provider APIs and stores
+model metadata (capabilities, context window, pricing).
+
+```
+GET /admin/models → { "models": [catalog entries] }
+PUT /admin/models/:id ← { "visibility", "display_name", ... }
+PUT /admin/models/bulk ← { "provider_config_id", "visibility" }
+DELETE /admin/models/:id
+POST /admin/models/fetch ← { "provider_config_id": "uuid|empty" }
+```
+
+**Fetch** with empty `provider_config_id` syncs ALL active global
+providers. Returns:
+
+```json
+{
+ "message": "models synced",
+ "added": 5,
+ "updated": 12,
+ "total": 47
+}
+```
+
+Or for multi-provider fetch: `{ "added", "updated", "total", "errors": [...] }`.
+
+**Bulk visibility** sets all models for a provider (or all models
+globally if no `provider_config_id`) to the specified visibility
+(`enabled`, `disabled`, `team`).
+
+### 10.4 Provider Health
+
+Real-time health tracking per provider config. The health accumulator
+records success/failure/latency on every completion, flushes to DB
+every 60 seconds.
+
+**Get all:**
+
+```
+GET /admin/providers/health
+```
+
+Returns `{ "data": [ProviderHealth objects] }`:
+
+```json
+{
+ "provider_config_id": "uuid",
+ "provider_config_name": "OpenAI Production",
+ "status": "healthy|degraded|down",
+ "error_rate": 0.02,
+ "avg_latency_ms": 450,
+ "timeout_rate": 0.01,
+ "rate_limit_count": 3,
+ "last_check": "...",
+ "last_error": "...|null"
+}
+```
+
+**Get single:**
+
+```
+GET /admin/providers/:id/health
+```
+
+**Auto-disable:** After `PROVIDER_AUTO_DISABLE_THRESHOLD` consecutive
+"down" windows (default: 3), the provider is automatically deactivated.
+
+### 10.5 Capability Overrides
+
+Admin can override any model capability detected by the catalog or
+heuristic layer.
+
+```
+GET /admin/capability-overrides → { "data": [...] }
+GET /admin/models/:id/capabilities → capabilities for one model
+PUT /admin/models/:id/capabilities ← { "field": "value" }
+DELETE /admin/models/:id/capabilities/:overrideId
+```
+
+Override fields: `supports_vision`, `supports_tools`, `supports_thinking`,
+`context_window`, `max_output_tokens`, etc.
+
+### 10.6 Routing Policies
+
+Policy-based request routing. Evaluated after model/config resolution,
+before provider dispatch.
+
+**Admin CRUD:**
+
+```
+GET /admin/routing/policies → { "data": [...] }
+GET /admin/routing/policies/:id → policy object
+POST /admin/routing/policies ← { "name", "scope", "team_id", "priority", "policy_type", "config", "is_active" }
+PUT /admin/routing/policies/:id
+DELETE /admin/routing/policies/:id
+```
+
+Policy object:
+
+```json
+{
+ "id": "uuid",
+ "name": "Prefer Anthropic",
+ "scope": "global|team",
+ "team_id": "uuid|null",
+ "priority": 10,
+ "policy_type": "provider_prefer|team_route|cost_limit|model_alias",
+ "config": {},
+ "is_active": true
+}
+```
+
+| Policy type | Config | Behavior |
+|------------|--------|----------|
+| `provider_prefer` | `{ "providers": ["cfg-1", "cfg-2"] }` | Ordered fallback list |
+| `team_route` | `{ "providers": ["cfg-1"] }` | Restrict team to specific providers |
+| `cost_limit` | `{ "max_cost_per_request": 0.50 }` | Heuristic cost cap |
+| `model_alias` | `{ "alias": "fast", "target_model": "...", "target_config": "..." }` | Alias → provider+model rewrite |
+
+**Dry-run test:**
+
+```
+POST /admin/routing/test
+```
+
+```json
+{
+ "model": "claude-sonnet-4-20250514",
+ "user_id": "uuid",
+ "team_id": "uuid|null"
+}
+```
+
+Returns the ranked candidate list with health status for each.
+
+### 10.7 Provider Types
+
+Registry of supported provider types with metadata.
+
+```
+GET /admin/provider-types
+```
+
+Returns `{ "types": [...] }` with name, display name, default endpoint,
+profile schema, and supported features per type.
+
+---
+
+## 11. Models & Preferences
+
+What models are available to the current user and how they control
+visibility.
+
+### 11.1 Enabled Models
+
+The primary endpoint for populating model selectors:
+
+```
+GET /models/enabled
+```
+
+> **Note:** `GET /models` exists as an alias. The alias is unused by
+> the frontend and will be removed pre-1.0.
+
+Returns `{ "models": [UserModel objects] }`:
+
+```json
+{
+ "id": "composite-id",
+ "model_id": "claude-sonnet-4-20250514",
+ "provider_config_id": "uuid",
+ "provider_config_name": "Anthropic",
+ "provider": "anthropic",
+ "display_name": "Claude Sonnet 4",
+ "model_type": "chat|embedding|image",
+ "context_window": 200000,
+ "max_output_tokens": 8192,
+ "supports_vision": true,
+ "supports_tools": true,
+ "supports_thinking": true,
+ "supports_streaming": true,
+ "input_price_per_m": 3.00,
+ "output_price_per_m": 15.00,
+ "provider_status": "healthy|degraded|down|null",
+ "scope": "global|team|personal",
+ "source": "catalog|heuristic"
+}
+```
+
+The capabilities on each model are resolved through the three-tier
+chain: catalog DB → heuristic inference → admin overrides (see §10.5).
+
+### 11.2 User Model Preferences
+
+Users can hide models they don't want to see.
+
+```
+GET /models/preferences → { "preferences": { "model-id": { "hidden": true } } }
+PUT /models/preferences ← { "model_id": "...", "hidden": true }
+POST /models/preferences/bulk ← { "preferences": { "id1": { "hidden": true }, "id2": { ... } } }
+```
+
+---
+
+## 12. Notifications
+
+Real-time notification infrastructure with WebSocket delivery and
+optional email transport.
+
+### 12.1 CRUD
+
+```
+GET /notifications → paginated list
+GET /notifications/unread-count → { "count": 5 }
+PATCH /notifications/:id/read → mark one as read
+POST /notifications/mark-all-read → mark all as read
+DELETE /notifications/:id
+```
+
+Notification object:
+
+```json
+{
+ "id": "uuid",
+ "user_id": "uuid",
+ "type": "role.fallback|memory.extracted|system.announcement|...",
+ "title": "Role Fallback Triggered",
+ "body": "Primary model unavailable, using fallback",
+ "metadata": {},
+ "read": false,
+ "created_at": "..."
+}
+```
+
+### 12.2 Preferences
+
+Users control per-type delivery:
+
+```
+GET /notifications/preferences → { "preferences": [...] }
+PUT /notifications/preferences/:type ← { "in_app": true, "email": false }
+DELETE /notifications/preferences/:type → reset to default
+```
+
+Resolution: specific type pref → user wildcard `*` pref → system
+default (`in_app=true`, `email=false`).
+
+---
+
+## 13. Extensions
+
+Plugin system with three tiers: Browser JS (client-side), Starlark
+sandbox (server-side, future), Sidecar containers (server-side, future).
+Currently only Browser tier is implemented.
+
+### 13.1 User Extensions
+
+```
+GET /extensions → { "extensions": [...] }
+POST /extensions/:id/settings ← { "enabled": true, "config": {...} }
+GET /extensions/:id/manifest → full manifest.json
+GET /extensions/tools → { "tools": [tool schema objects] }
+```
+
+### 13.2 Admin Extension Management
+
+```
+GET /admin/extensions → { "extensions": [...] }
+POST /admin/extensions ← { manifest + script content }
+PUT /admin/extensions/:id ← updated manifest/script
+DELETE /admin/extensions/:id
+```
+
+### 13.3 Asset Serving
+
+```
+GET /extensions/:id/assets/*path
+```
+
+Public (no auth required). Serves static assets (icons, CSS, JS)
+from extension bundles.
+
+---
+
+## 14. User Profile & Settings
+
+### 14.1 Profile
+
+```
+GET /profile → profileResponse
+PUT /profile ← { "display_name", "email" }
+```
+
+```json
+{
+ "id": "uuid",
+ "username": "jdoe",
+ "email": "jdoe@example.com",
+ "display_name": "Jane Doe",
+ "role": "user|admin",
+ "avatar_url": "...",
+ "created_at": "...",
+ "last_login": "..."
+}
+```
+
+### 14.2 Avatar
+
+```
+POST /profile/avatar ← multipart/form-data (field: "avatar")
+DELETE /profile/avatar
+```
+
+### 14.3 Password
+
+```
+POST /profile/password
+```
+
+```json
+{
+ "current_password": "...",
+ "new_password": "..."
+}
+```
+
+On password change, the UEK is re-wrapped with the new password
+(BYOK keys remain accessible without re-encryption).
+
+### 14.4 App Settings
+
+```
+GET /settings → { "settings": {...} }
+PUT /settings ← { "theme", "editor_keybindings", ... }
+```
+
+User-level preferences (theme, keybindings, default model, etc.).
+
+---
+
+## 15. Teams & Access Control
+
+### 15.1 My Teams
+
+```
+GET /teams/mine → { "teams": [...] }
+```
+
+Returns teams the current user is a member of.
+
+### 15.2 Team Administration
+
+Team-admin-scoped routes (require `RequireTeamAdmin` middleware):
+
+**Team CRUD (platform admin):**
+
+```
+GET /admin/teams → { "teams": [...] }
+POST /admin/teams
+GET /admin/teams/:id
+PUT /admin/teams/:id
+DELETE /admin/teams/:id
+```
+
+**Members:**
+
+```
+GET /admin/teams/:id/members → { "members": [...] }
+POST /admin/teams/:id/members ← { "user_id", "role" }
+PUT /admin/teams/:id/members/:memberId ← { "role" }
+DELETE /admin/teams/:id/members/:memberId
+```
+
+**Team-scoped routes** (team admin, not platform admin):
+
+```
+GET /teams/:teamId/members
+POST /teams/:teamId/members ← { "user_id", "role" }
+PUT /teams/:teamId/members/:memberId
+DELETE /teams/:teamId/members/:memberId
+```
+
+**Team Providers:**
+
+```
+GET /teams/:teamId/providers → { "configs": [...] }
+POST /teams/:teamId/providers ← { "name", "provider", "endpoint", "api_key", ... }
+PUT /teams/:teamId/providers/:id
+DELETE /teams/:teamId/providers/:id
+GET /teams/:teamId/providers/:id/models → { "models": [...] }
+```
+
+**Team Models:**
+
+```
+GET /teams/:teamId/models → { "models": [...] }
+```
+
+Available models for this team (global + team-scoped).
+
+**Team Personas:** See §4.2.
+
+**Team Roles:**
+
+```
+GET /teams/:teamId/roles → { "roles": [...] }
+PUT /teams/:teamId/roles/:role ← { "permissions": {...} }
+DELETE /teams/:teamId/roles/:role
+```
+
+**Team Audit:**
+
+```
+GET /teams/:teamId/audit → paginated audit log
+GET /teams/:teamId/audit/actions → { "actions": [...] } (distinct action types)
+```
+
+**Team Usage:**
+
+```
+GET /teams/:teamId/usage → { "totals": {...}, "results": [...] }
+```
+
+### 15.3 Groups & Resource Grants
+
+Groups are ACL containers that decouple access from team membership.
+A resource grant controls who can see a Persona or KB beyond its base
+scope.
+
+**My Groups:**
+
+```
+GET /groups/mine → { "groups": [...] }
+```
+
+**Admin Group CRUD:**
+
+```
+GET /admin/groups → { "groups": [...] }
+POST /admin/groups ← { "name", "scope", "team_id" }
+GET /admin/groups/:id
+PUT /admin/groups/:id
+DELETE /admin/groups/:id
+```
+
+**Group Members:**
+
+```
+GET /admin/groups/:id/members → { "members": [...] }
+POST /admin/groups/:id/members ← { "user_id" }
+DELETE /admin/groups/:id/members/:userId
+```
+
+**Resource Grants:**
+
+```
+GET /admin/grants/:type/:id → { "grant": {...} }
+PUT /admin/grants/:type/:id ← { "grant_type": "team_only|global|groups", "group_ids": [...] }
+DELETE /admin/grants/:type/:id
+```
+
+`:type` is `persona` or `kb`. `:id` is the resource ID.
+
+Grant types:
+
+| `grant_type` | Visibility |
+|--------------|-----------|
+| `team_only` | Only the owning team |
+| `global` | All authenticated users |
+| `groups` | Members of specified groups |
+
+---
+
+## 16. Platform Administration
+
+Cross-cutting admin operations that don't belong to a specific domain.
+
+### 16.1 User Management
+
+```
+GET /admin/users → { "users": [...] }
+POST /admin/users ← { "username", "password", "email", "role" }
+PUT /admin/users/:id/role ← { "role": "admin|user" }
+PUT /admin/users/:id/active ← { "active": false }
+DELETE /admin/users/:id
+POST /admin/users/:id/reset-password ← { "new_password": "..." }
+POST /admin/users/:id/vault/reset → destroys user's UEK (BYOK keys lost)
+```
+
+### 16.2 Global Settings & Policies
+
+Settings are key-value pairs in `global_settings`. Policies are
+boolean flags that gate features.
+
+```
+GET /admin/settings → all settings
+GET /admin/settings/:key → single setting
+PUT /admin/settings/:key ← { "value": ... }
+```
+
+The handler auto-detects: if the value is a string and the key is a
+known policy name, it writes to the `policies` table. Otherwise it
+writes to `global_config` as JSON.
+
+**Public settings** (non-admin, for FE bootstrapping):
+
+```
+GET /settings/public
+```
+
+```json
+{
+ "banner": { "enabled": true, "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff", "position": "both" },
+ "branding": { "instance_name": "Switchboard", "logo_url": "...", "tagline": "..." },
+ "has_admin_prompt": true,
+ "storage_configured": true,
+ "paste_to_file_chars": 2000,
+ "policies": {
+ "allow_registration": "true",
+ "allow_user_byok": "true",
+ "allow_user_personas": "true"
+ }
+}
+```
+
+### 16.3 Banner Configuration
+
+The environment banner is stored as a single `global_config` entry
+under the key `"banner"`. It's a simple object:
+
+```json
+{
+ "enabled": true,
+ "text": "DEVELOPMENT",
+ "position": "both|top|bottom",
+ "bg": "#007a33",
+ "fg": "#ffffff"
+}
+```
+
+Set via `PUT /admin/settings/banner` with `{ "value": { ... } }`.
+The admin UI provides a color picker, position selector, and preset
+dropdown. The Go template base layout reads the banner from
+`PageData` and renders top/bottom strips with CSS custom properties.
+
+### 16.4 Platform Stats
+
+```
+GET /admin/stats
+```
+
+```json
+{
+ "users": 42,
+ "channels": 156,
+ "messages": 12847
+}
+```
+
+### 16.5 Storage & Vault
+
+**Storage status:**
+
+```
+GET /admin/storage/status → { "backend", "healthy", "file_count", "total_bytes", ... }
+GET /admin/storage/orphans → { "count": 3 }
+POST /admin/storage/cleanup → removes orphaned blobs
+GET /admin/storage/extraction → extraction queue status
+```
+
+**Vault:**
+
+```
+GET /admin/vault/status → { "encryption_key_set", "user_vaults_count", ... }
+```
+
+### 16.6 Audit Log
+
+```
+GET /admin/audit?page=1&per_page=50&action=user.create&actor_id=uuid
+GET /admin/audit/actions → { "actions": ["user.create", "policy.update", ...] }
+```
+
+Audit entry:
+
+```json
+{
+ "id": "uuid",
+ "actor_id": "uuid",
+ "action": "user.create",
+ "resource_type": "user",
+ "resource_id": "uuid",
+ "metadata": {},
+ "created_at": "..."
+}
+```
+
+### 16.7 Usage & Pricing
+
+```
+GET /admin/usage → { "totals", "results" }
+GET /admin/usage/teams/:id → team-specific usage
+GET /admin/usage/users/:id → user-specific usage
+GET /admin/pricing → { "data": [...] }
+PUT /admin/pricing ← { "provider", "model", "input_per_m", "output_per_m" }
+DELETE /admin/pricing/:provider/:model
+```
+
+**Personal usage** (non-admin):
+
+```
+GET /usage → { "totals", "results" }
+```
+
+Scoped to the user's BYOK provider usage only.
+
+### 16.8 Roles (Platform)
+
+```
+GET /admin/roles → { "roles": [...] }
+GET /admin/roles/:role
+PUT /admin/roles/:role ← { "permissions": {...} }
+POST /admin/roles/:role/test ← test role permissions
+```
+
+### 16.9 Email Test
+
+```
+POST /admin/notifications/test-email
+```
+
+```json
+{ "to": "admin@example.com" }
+```
+
+Sends a test email using the configured SMTP settings.
+
+---
+
+## 17. Export & Utility
+
+### 17.1 Health Check
+
+```
+GET /health → { "status": "ok" }
+```
+
+Public, no auth. Also available at `/api/v1/health`.
+
+### 17.2 Export
+
+Convert markdown to PDF or DOCX via pandoc:
+
+```
+POST /export
+```
+
+```json
+{
+ "content": "# My Document\n\nBody text...",
+ "format": "pdf|docx",
+ "filename": "my-document"
+}
+```
+
+Returns the binary file with appropriate Content-Type. Requires
+`pandoc` in the container (available in the unified and backend
+Docker images).
+
+---
+
+## 18. WebSocket Protocol
+
+Real-time bidirectional event bus. Single connection per client.
+
+### 18.1 Connection
+
+```
+ws://{host}{BASE_PATH}/ws?token={access_token}
+```
+
+**Lifecycle:**
+- Server sends `ping` every 54 seconds
+- Client must respond with `pong` within 60 seconds or disconnection
+- Max message size: 4 KB
+- Write deadline: 10 seconds
+
+### 18.2 Event Envelope
+
+All messages are JSON:
+
+```json
+{
+ "type": "event.type",
+ "payload": { ... }
+}
+```
+
+### 18.3 Room Subscription
+
+Clients subscribe to rooms for scoped event delivery:
+
+```json
+{ "type": "subscribe", "payload": { "room": "channel:uuid" } }
+{ "type": "unsubscribe", "payload": { "room": "channel:uuid" } }
+```
+
+### 18.4 Event Routing Table
+
+| Prefix | Direction | Description |
+|--------|-----------|-------------|
+| `channel.created` | → client | New channel created |
+| `channel.updated` | → client | Channel metadata changed |
+| `channel.deleted` | → client | Channel removed |
+| `message.created` | → client | New message in subscribed channel |
+| `message.updated` | → client | Message content changed |
+| `message.deleted` | → client | Message removed |
+| `cursor.updated` | → client | Branch cursor moved |
+| `typing.start` | ↔ both | User/AI started typing |
+| `typing.stop` | ↔ both | Typing ended |
+| `model.changed` | → client | Channel model roster changed |
+| `persona.updated` | → client | Persona metadata changed |
+| `kb.updated` | → client | Knowledge base changed |
+| `kb.document.status` | → client | Document processing status update |
+| `note.created` | → client | Note created |
+| `note.updated` | → client | Note content changed |
+| `note.deleted` | → client | Note removed |
+| `notification.new` | → client | New notification |
+| `notification.read` | → client | Notification marked read |
+| `memory.extracted` | → client | New memory extracted |
+| `memory.status` | → client | Memory approved/rejected |
+| `role.fallback` | → client | Role fallback triggered |
+| `workspace.updated` | → client | Workspace state changed |
+| `project.updated` | → client | Project metadata changed |
+| `extension.updated` | → client | Extension config changed |
+| `settings.updated` | → client | Global settings changed |
+| `user.updated` | → client | User profile changed |
+
+Direction: `→ client` = server-to-client only, `← client` = client-to-server
+only, `↔ both` = bidirectional.
+
+---
+
+## 19. Appendix: Enums
+
+### Channel Types
+
+`direct` (current default), `group` (v0.23.0), `workflow` (v0.25.0)
+
+### Message Roles
+
+`user`, `assistant`, `system`, `tool`
+
+### Scopes
+
+`personal`, `team`, `global`
+
+### Visibility (model catalog)
+
+`enabled`, `disabled`, `team`
+
+### User Roles
+
+`admin`, `user`
+
+### Team Member Roles
+
+`admin`, `member`
+
+### Provider Types
+
+`openai`, `anthropic`, `openrouter`, `venice` (extensible via registry)
+
+### Model Types
+
+`chat`, `embedding`, `image`
+
+### Memory Scopes
+
+`user`, `persona`
+
+### Memory Statuses
+
+`pending`, `approved`, `rejected`
+
+### Workspace Owner Types
+
+`user`, `project`
+
+### Workspace Statuses
+
+`active`, `archived`
+
+### Index Statuses
+
+`pending`, `indexing`, `ready`, `error`
+
+### KB Document Statuses
+
+`pending`, `chunking`, `embedding`, `ready`, `error`
+
+### Provider Health Statuses
+
+`healthy`, `degraded`, `down`
+
+### Routing Policy Types
+
+`provider_prefer`, `team_route`, `cost_limit`, `model_alias`
+
+### Extension Tiers
+
+`browser` (implemented), `starlark` (future), `sidecar` (future)
+
+### Grant Types
+
+`team_only`, `global`, `groups`
+
+### Resource Grant Scopes
+
+`persona`, `kb`
+
+### Notification Types
+
+`role.fallback`, `memory.extracted`, `system.announcement`, plus
+extensible via notification service.
+
+### Git Auth Types
+
+`https_pat`, `https_basic`, `ssh_key`
+
+### Policies
+
+| Key | Default | Description |
+|-----|---------|-------------|
+| `allow_registration` | `"true"` | Allow new user self-registration |
+| `allow_user_byok` | `"true"` | Allow users to add personal API keys |
+| `allow_user_personas` | `"true"` | Allow users to create personal Personas |
+| `allow_team_providers` | `"true"` | Allow team admins to configure team providers |
+| `kb_direct_access` | `"true"` | Show KB picker in channel (false = strict enterprise mode) |
+| `require_email` | `"false"` | Require email on registration |
+| `default_system_prompt` | `""` | Injected into all conversations (admin override) |
+
+---
+
+## Page Routes (Non-API)
+
+Server-rendered Go template surfaces. Not REST endpoints — these
+return HTML pages.
+
+| Route | Surface | Description |
+|-------|---------|-------------|
+| `/login` | Login | Standalone login/register page |
+| `/` | Chat | Main chat interface |
+| `/chat/:chatID` | Chat | Chat with specific channel loaded |
+| `/editor` | Editor | Workspace file editor |
+| `/editor/:wsId` | Editor | Editor with specific workspace |
+| `/notes` | Notes | Notes interface |
+| `/notes/:noteId` | Notes | Notes with specific note loaded |
+| `/admin` | Admin | Platform administration |
+| `/admin/:section` | Admin | Admin with specific section |
+| `/settings` | Settings | User settings |
+| `/settings/:section` | Settings | Settings with specific section |
+
+All page routes (except `/login`) require authentication via
+`AuthOrRedirect` middleware (reads `sb_token` cookie). Admin routes
+additionally require `RequireAdminPage()`.
+
+**Dynamic surface routing** for admin-installed extension surfaces is
+not yet implemented. The current five surfaces are hardcoded in Go
+route registration. Future: extension manifests declare surface routes,
+the page engine dynamically registers them with appropriate data
+loaders. See EXTENSIONS.md for the design direction.
diff --git a/docs/ICD-AUDIT.md b/docs/ICD-AUDIT.md
new file mode 100644
index 0000000..03c442a
--- /dev/null
+++ b/docs/ICD-AUDIT.md
@@ -0,0 +1,265 @@
+# ICD Audit: API Contract vs ROADMAP / ARCHITECTURE
+
+**Date:** 2026-03-03 | **Baseline:** v0.22.7
+**Scope:** Cross-reference ICD-API.md against ROADMAP.md, ARCHITECTURE.md, and live codebase.
+**Status:** Items §2 (naming), §3 (cleanup) resolved in v0.22.8 naming cleanup pass. See ICD-API.md (domain-organized v2) and DESIGN-SURFACES.md.
+
+---
+
+## 1. ARCHITECTURE.md — Staleness Report
+
+ARCHITECTURE.md is stamped **v0.20** — six minor versions behind. The following sections are materially stale:
+
+### 1.1 Frontend File Structure (§ Frontend Architecture)
+
+ARCHITECTURE lists 19 JS files. Codebase has **34**. Fifteen files are undocumented:
+
+| Missing from ARCHITECTURE | Added In | Purpose |
+|---------------------------|----------|---------|
+| `admin-scaffold.js` | v0.22.7 | Admin section DOM scaffolding |
+| `channel-models.js` | v0.20.0 | Multi-model channel roster UI |
+| `chat-pane.js` | v0.22.7 | Reusable ChatPane component |
+| `editor-mode.js` | v0.21.5 | Editor surface (listed but not in file tree) |
+| `extensions.js` | v0.11.0 | Extension loader |
+| `notification-prefs.js` | v0.20.0 | Notification preference UI |
+| `notifications.js` | v0.20.0 | Bell dropdown + badge |
+| `pages.js` | v0.22.5 | Page-level auth + routing |
+| `pages-splash.js` | v0.22.7 | Login/register surface |
+| `panels.js` | v0.18.1 | Side panel architecture |
+| `persona-kb.js` | v0.17.0 | Persona-KB binding UI |
+| `repl.js` | v0.21.3 | REPL surface |
+| `surface-nav.js` | v0.22.8 | Surface navigation overrides |
+| `tools-toggle.js` | v0.13.1 | Per-tool enable/disable UI |
+| `ui-format.js` | v0.22.7 | Message formatting helpers |
+| `ui-primitives.js` | v0.10.5 | Shared render primitives |
+| `ui-primitives-additions.js` | v0.22.7 | Toast, badge, icon extensions |
+
+ARCHITECTURE also lists `knowledge.js` — **renamed** to `knowledge-ui.js`.
+
+### 1.2 SPA Shell References
+
+ARCHITECTURE §Deployment Modes still says:
+
+> "The frontend entrypoint (`docker-entrypoint-fe.sh`) handles `BASE_PATH` injection into `index.html`"
+
+And the file tree lists:
+
+> `├── index.html # Single-page app shell`
+
+Both are wrong since v0.22.6. `index.html` is now a 16-line redirect stub. The real entry points are Go-rendered surface templates. The entrypoint now generates `proxy_pass` blocks to the backend for surface routes.
+
+### 1.3 Deployment Diagram
+
+The split-deployment description is functionally correct but the mechanism is wrong. Frontend no longer serves an SPA — it reverse-proxies page routes to the backend, which renders Go templates. The diagram should show the backend serving both API and HTML.
+
+### 1.4 Backward Compatibility Section
+
+The section says:
+
+> "JSON field rename: `api_config_id` → `provider_config_id`... Frontend updated to match."
+
+This is now ancient history (v0.9). The rename is complete everywhere. This section should either be removed or reduced to a single sentence noting historical context.
+
+### 1.5 Missing Sections
+
+ARCHITECTURE has **no documentation** for:
+
+- Go template engine (`server/pages/`)
+- Surface architecture (5 surfaces: chat, editor, notes, admin, settings)
+- Template components (`model-select`, `team-select`, `file-upload`, `chat-pane`)
+- Page data loaders (`loaders.go`)
+- Provider Extensions / hook system (v0.22.1)
+- Routing policies (v0.22.2)
+- Export system (v0.22.4)
+- Git integration (v0.21.4)
+- The `treepath/` package (cursor, path, siblings)
+
+**Recommendation:** Bump to v0.22 and rewrite §Frontend Architecture, §Deployment Modes, and §Backward Compatibility. Add §Template Engine, §Surface Architecture, §Provider Extensions sections.
+
+---
+
+## 2. ROADMAP.md — Housekeeping
+
+### 2.1 Naming Inconsistency: Presets vs Personas
+
+The ROADMAP uses "Persona" consistently. The API routes use `/personas`. The Go handler type is `PersonaHandler`. The JSON response sends **both** `"personas"` and `"presets"` keys (backward compat). The frontend reads the `presets` key exclusively.
+
+This is a naming barnacle that bleeds into every layer:
+
+| Layer | Name Used |
+|-------|-----------|
+| ROADMAP / ARCHITECTURE | Persona |
+| Go types | `Persona`, `PersonaPatch` |
+| Go handler | `PersonaHandler` |
+| API routes | `/personas`, `/admin/personas` |
+| JSON response keys | `"personas"` AND `"presets"` (dual) |
+| Frontend JS | `presets` (reads `data.presets`) |
+| UI labels | Mixed ("Preset" in some places, "Persona" in others) |
+
+**Recommendation:** Pick one. The domain concept is "Persona" (it carries identity, system prompt, model, KB bindings — it's not just a preset). Two options:
+
+- **Option A — Route rename**: `/personas` → `/personas`. Update FE. Remove dual JSON keys. This is a clean break and pre-1.0 is the time to do it.
+- **Option B — Accept "presets" everywhere**: Rename Go types to `Preset`, update docs. Simpler but loses semantic precision.
+
+### 2.2 `chat_id` Backward Compat Alias
+
+`completionRequest` has:
+
+```go
+ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id
+```
+
+The frontend doesn't send `chat_id` anymore. This is dead code in the handler that adds 3 lines of aliasing logic. Safe to remove.
+
+### 2.3 Go Field Name Barnacle: `APIConfigID`
+
+Throughout `channels.go` and `completion.go`, the Go struct field is named `APIConfigID` with json tag `"provider_config_id"`. The JSON tag is correct, so this is invisible to the API — but it's a source of confusion for anyone reading the Go code. Rename the Go field to `ProviderConfigID` for consistency.
+
+### 2.4 Dual JSON Keys on Persona List
+
+Three `PersonaHandler` methods return:
+
+```go
+gin.H{"personas": personas, "presets": personas}
+```
+
+The `"presets"` key exists solely for backward compat that was needed in v0.9. The FE only reads `"presets"`. If we go with Option A (§2.1), remove the `"presets"` key. If Option B, remove the `"personas"` key. Either way, stop sending both.
+
+### 2.5 Route Alias: `/models` → `/models/enabled`
+
+`main.go` has:
+
+```go
+protected.GET("/models/enabled", modelH.ListEnabledModels)
+protected.GET("/models", modelH.ListEnabledModels) // alias
+```
+
+Plus the admin has its own `/admin/models`. Having `/models` as an alias for `/models/enabled` is confusing when `/admin/models` is a completely different thing. The ICD documents both, but one should go away. The FE uses `listEnabledModels()` → `/models/enabled`. The alias is unused.
+
+### 2.6 ROADMAP v0.22.8 — Status vs Reality
+
+The ROADMAP shows v0.22.8 as the next incomplete version with checkboxes for ChatPane Bridge, Theme Integration, Admin Surface, and Settings Surface. The last two sessions partially implemented some of these (admin scaffold, surface-nav, prototype overrides) but things broke and were rolled back/lost. The ROADMAP doesn't reflect the partial state — it still shows all items as unchecked.
+
+**Recommendation:** After the ICD is finalized and the FE rebuild begins properly, update v0.22.8 checkboxes to reflect actual completion. Consider splitting v0.22.8 into smaller increments since it's become a catch-all.
+
+---
+
+## 3. ICD Gaps (things the backend implements but ICD doesn't fully cover)
+
+### 3.1 Admin Models — Specific Endpoints
+
+The ICD §24.5 lists admin model routes but doesn't document request/response shapes for:
+
+- `POST /admin/models/fetch` — triggers provider sync, returns updated catalog
+- `PUT /admin/models/bulk` — bulk visibility updates (request shape)
+- `PUT /admin/models/:id` — single model update (what fields?)
+
+**Fix:** Extract shapes from `admin.go` handler methods and add to ICD.
+
+### 3.2 Admin Stats Response Shape
+
+ICD §24.3 says "returns aggregate counts" but doesn't specify the shape. Should document:
+
+```json
+{
+ "users": 42,
+ "channels": 156,
+ "messages": 12847,
+ ...
+}
+```
+
+### 3.3 Admin Global Config — configWithKey Shape
+
+ICD §24.4 says admin configs include `has_key` flag but doesn't show the full shape. The actual `configWithKey` response differs from the user-facing `safeConfig` — it includes `config`, `headers`, `settings` objects.
+
+### 3.4 Team Provider Models
+
+`GET /teams/:teamId/providers/:id/models` is in `main.go` but not clearly called out in the ICD §18.2 team admin section.
+
+### 3.5 Team Audit Endpoints
+
+`GET /teams/:teamId/audit` and `GET /teams/:teamId/audit/actions` are registered but not in the ICD team section.
+
+### 3.6 Knowledge Base — Missing Detail
+
+ICD §14 doesn't document:
+
+- `GET /channels/:id/knowledge-bases` response shape (array of `ChannelKB`)
+- `PUT /channels/:id/knowledge-bases` request shape
+- `GET /knowledge-bases-discoverable` vs `GET /knowledge-bases` distinction
+- KB document status progression: `pending → chunking → embedding → ready | error`
+
+### 3.7 Export Endpoint Shape
+
+ICD §21.2 has the right route but should note that `pandoc` must be available in the container. Also missing: supported format list beyond pdf/docx (the handler may accept more).
+
+---
+
+## 4. ICD Barnacles (things in the ICD that should be cleaned up)
+
+### 4.1 `chat_id` Alias
+
+The ICD §5.1 notes `chat_id` as a deprecated alias in the completion request. Once the dead code is removed from the handler, remove from ICD.
+
+### 4.2 Legacy ListMessages
+
+ICD §4.2 documents `GET /channels/:id/messages` as "Legacy/Debug." If it's truly not for frontend use, consider either removing the route or moving it to the admin namespace. Currently it's in the protected (user) namespace, which means any user can dump all messages (including branches) for their channels.
+
+---
+
+## 5. ROADMAP/ARCHITECTURE Reorganization Suggestions
+
+### 5.1 ARCHITECTURE Should Reference the ICD
+
+Instead of maintaining API route tables in multiple places, ARCHITECTURE should say "See ICD-API.md for the complete endpoint reference" and focus on:
+
+- Design principles, scope model, resolution chains (what it does well today)
+- Package structure (update to v0.22)
+- Frontend architecture (rewrite for surfaces/templates)
+- Security model (mostly current)
+- New: Template engine, provider extensions, routing
+
+### 5.2 ROADMAP Completed Versions — Collapse
+
+The ROADMAP has 900+ lines of completed release details (v0.9 through v0.22.7). These are duplicated in CHANGELOG.md. The ROADMAP should collapse completed versions to one-liners (as it does for v0.9.x through v0.16.0) and push detailed history to CHANGELOG.
+
+Currently v0.17.0 through v0.22.7 have full checkbox-level detail in the ROADMAP — that's useful while in-progress but noise once shipped. Collapse them.
+
+### 5.3 Three-Document Model
+
+Post-audit, the documentation should be:
+
+| Document | Audience | Content |
+|----------|----------|---------|
+| `ARCHITECTURE.md` | Developers | Design principles, package structure, data model, resolution chains, security, deployment. **No API details.** |
+| `ICD-API.md` | FE developers, integrators | Complete API contract: routes, shapes, auth, streaming, WebSocket. |
+| `ROADMAP.md` | Stakeholders, contributors | Version plan, dependency graph, future features. Completed versions collapsed to one-liners with CHANGELOG refs. |
+
+Future additions:
+
+| Document | Audience | Content |
+|----------|----------|---------|
+| `ICD-SURFACE.md` | FE developers | Surface contract: templates, PageData, DOM IDs, JS globals, lifecycle. |
+| `ICD-EXTENSION.md` | Extension authors | Extension contract: manifest, tiers, hooks, tool bridge, asset serving. |
+
+### 5.4 ROADMAP Dependency Graph — Update
+
+The ASCII dependency graph stops at v0.22.7. The v0.22.8 → v0.23.0 → v0.24.0 path should be added.
+
+---
+
+## 6. Cleanup Punch List
+
+Priority-ordered actions that could all happen in a single commit:
+
+| # | Action | Risk | Lines Changed |
+|---|--------|------|---------------|
+| 1 | Remove `chat_id` alias from `completionRequest` | None — FE doesn't use it | ~5 |
+| 2 | Rename Go field `APIConfigID` → `ProviderConfigID` in channels.go + completion.go | None — json tags unchanged | ~30 |
+| 3 | Pick persona vs preset naming, remove dual JSON keys | Low — FE reads one key | ~10 |
+| 4 | Remove `/models` alias (keep `/models/enabled` only) | Low — check FE uses | ~1 |
+| 5 | Bump ARCHITECTURE.md to v0.22, rewrite §Frontend | Medium — documentation | ~100 |
+| 6 | Collapse ROADMAP completed versions (v0.17–v0.22.7) to one-liners | None — CHANGELOG has detail | ~600 removed |
+| 7 | Add missing ICD shapes (§3 above) | None — documentation | ~80 added |
+| 8 | Update ICD to remove chat_id and document any cleanup from #1–#4 | None | ~10 |
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index dd0a81a..4ae4a12 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -109,6 +109,11 @@ v0.22.5 Surfaces + Go Templates + Admin Bug Fixes ✅
│
v0.22.6 Split Deployment Fix + CI Timeout + Code Pruning ✅
│
+ |
+v0.22.7 Surface Prototypes (Theme + ChatPane + Splash) ✅
+ |
+v0.22.8 Surface Integration (Wire into existing JS)
+ |
v0.23.0 Multi-Participant Channels + Presence
│
v0.24.0 Auth Strategy (mTLS/OIDC) + Full RBAC + Group permissions
@@ -1102,6 +1107,97 @@ replaced by server-rendered surfaces.
---
+---
+
+## v0.22.7 — Surface Prototypes (Theme + ChatPane + Splash) ✅
+
+Foundation CSS/JS for the v0.22.5 surface architecture: theme system,
+reusable chat pane component, splash/login redesign, and UI primitive
+extensions. All files target the correct tree locations established in
+v0.22.5. Settings surface gains BYOK and User Personas feature gates.
+
+Depends on: surfaces + Go templates (v0.22.5), code pruning (v0.22.6).
+
+**Theme System:**
+- [x] `theme.css`: CSS custom properties for light/dark themes via `[data-theme]` attribute
+- [x] System preference auto-detection (`prefers-color-scheme` media query)
+- [x] Variable bridge: new vars (`--bg`, `--text`) alias old vars (`--bg-primary`, `--text-primary`)
+- [x] Shared component styles: toast stack, badges, buttons, icon buttons, form fields, confirm dialog, theme toggle, avatar upload, section headers
+- [x] Surface layout styles: splash, chat-pane, admin, settings, editor
+- [x] Google Fonts: DM Sans (UI) + JetBrains Mono (code)
+- [x] `base.html`: `data-theme` on ``, `theme.css` before `styles.css`
+
+**ChatPane Component:**
+- [x] `chat-pane.js`: factory pattern — `ChatPane.create(opts)` returns self-contained instance
+- [x] Instance API: renderMessages, appendChunk, finalizeStream, appendTyping, removeTyping, scrollToBottom, showWelcome, clear, getInputValue, setInputValue, focusInput, destroy
+- [x] Lookup: `ChatPane.get(id)`, `ChatPane.forChannel(channelId)`, `ChatPane.active()`
+- [x] `components/chat-pane.html`: Go template partial for server-rendered mount points
+- [x] Editor surface: `chat-pane` template in assist pane with `ChatPane.create()` boot
+
+**Splash / Login:**
+- [x] `login.html` rewritten: split hero + auth card layout
+- [x] Animated grid canvas (40px grid, scrolling offset, `--border` color)
+- [x] Auth tabs (Login / Register) with server-gated registration
+- [x] Register form: username, display name, email, password, avatar upload
+- [x] `pages-splash.js`: `Pages.initSplash()`, grid animation, POST login, POST register + PUT avatar
+- [x] `PageData` gains `InstanceName`, `LogoURL`, `Tagline`, `RegistrationOpen`
+- [x] `loadBranding()` + `isRegistrationOpen()` from GlobalConfig
+
+**UI Primitives Additions:**
+- [x] `Toast`: show/success/error/warning/info with auto-dismiss, stacking
+- [x] `renderBadge()`: 6 color variants (accent, success, danger, warning, purple, muted)
+- [x] `renderIcon()`: 20+ SVG icon set
+- [x] `renderIconBtn()`: icon button factory with active state
+- [x] `Theme`: init/set/get/resolved/renderToggle with localStorage
+- [x] `showConfirmDialog()`: enhanced with variant, onConfirm/onCancel
+- [x] `mountAvatarUpload()`: file input with preview, onUpload callback
+
+**Settings Surface Gates:**
+- [x] BYOK gate: My Providers, Model Roles, Usage tabs shown when `user_providers.enabled`
+- [x] User Personas gate: My Personas tab shown when `user_presets.enabled`
+- [x] Models tab added to base navigation
+- [x] `SettingsPageData` gains `BYOKEnabled`, `UserPersonasEnabled` from GlobalConfig
+- [x] BYOK status indicator in nav footer
+
+**Go Changes:**
+- [x] `PageData`: Theme, SurfaceSettings, InstanceName, LogoURL, Tagline, RegistrationOpen
+- [x] `Render()` defaults Theme to "dark"
+- [x] `RenderLogin()` populates splash fields from GlobalConfig
+- [x] `settingsLoader()` reads feature gates
+
+---
+
+## v0.22.8 — Surface Integration (Wire into Existing JS)
+
+Connect v0.22.7 prototypes to the existing frontend JS modules.
+ChatPane.primary replaces singleton UI.* calls. Theme system wired
+into appearance settings. Admin surface hybrid loader completed.
+
+Depends on: surface prototypes (v0.22.7).
+
+**ChatPane Bridge:**
+- [ ] `ChatPane.primary` creation in `startApp()` from server-rendered mount points
+- [ ] `ui-core.js` delegation: `UI.renderMessages` → `ChatPane.primary.renderMessages`, etc.
+- [ ] `editor-mode.js` ChatPane creation for workspace chat
+- [ ] Branding init from `window.__SETTINGS__` (instanceName, logoURL)
+
+**Theme Integration:**
+- [ ] Wire `Theme` into appearance settings save/load cycle
+- [ ] CM6 theme sync on theme change
+- [ ] Persist theme preference to user settings API
+
+**Admin Surface:**
+- [ ] Complete hybrid section loaders for all JS-loaded sections
+- [ ] Admin surface stats/overview server-rendered content
+
+**Settings Surface:**
+- [ ] Models visibility toggles (API.saveModelPreferences)
+- [ ] User Personas CRUD (add/edit/delete with confirm dialogs)
+- [ ] BYOK provider CRUD in settings context
+- [ ] Teams tab content
+
+---
+
## v0.23.0 — Multi-Participant Channels + Presence
The channel foundation for workflows and real-time collaboration. Today
diff --git a/server/capabilities/resolver.go b/server/capabilities/resolver.go
index cd15ef6..6919ee5 100644
--- a/server/capabilities/resolver.go
+++ b/server/capabilities/resolver.go
@@ -15,8 +15,8 @@ import (
//
// Tier | enabled | team | disabled
// ----------+----------------+----------------------+---------
-// Global | All users | Team admin → presets | Hidden
-// Team | Team members | Team admin → presets | Hidden
+// Global | All users | Team admin → personas | Hidden
+// Team | Team members | Team admin → personas | Hidden
// Personal | Owner direct | N/A | Hidden
//
// Sources aggregated:
@@ -175,7 +175,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
}
}
// Fallback: look up any provider's catalog entry for this model
- // (covers auto-resolve presets where provider_config_id is NULL)
+ // (covers auto-resolve personas where provider_config_id is NULL)
if catalogCaps == nil {
if entry, err := stores.Catalog.GetByModelIDAny(ctx, p.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
@@ -209,12 +209,11 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
ProviderName: provName,
ProviderType: provType,
Capabilities: caps,
- IsPreset: true,
- PresetID: p.ID,
- PresetScope: p.Scope,
- PresetAvatar: p.Avatar,
- PresetTeamName: teamName(p, stores, ctx),
+ IsPersona: true,
PersonaID: p.ID,
+ PersonaScope: p.Scope,
+ PersonaAvatar: p.Avatar,
+ PersonaTeamName: teamName(p, stores, ctx),
Description: p.Description,
Icon: p.Icon,
Avatar: p.Avatar,
@@ -245,7 +244,7 @@ func ResolveForPersona(ctx context.Context, stores store.Stores, persona *models
catalogCaps = &entry.Capabilities
}
}
- // Fallback: any provider's catalog entry (auto-resolve presets)
+ // Fallback: any provider's catalog entry (auto-resolve personas)
if catalogCaps == nil {
if entry, err := stores.Catalog.GetByModelIDAny(ctx, persona.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
diff --git a/server/handlers/avatar.go b/server/handlers/avatar.go
index ce3d663..02e671c 100644
--- a/server/handlers/avatar.go
+++ b/server/handlers/avatar.go
@@ -164,10 +164,10 @@ func bilinearMix(c00, c10, c01, c11 color.Color, xf, yf float64) color.Color {
}
}
-// ── Preset Avatar Upload ────────────────────
-// POST /api/v1/presets/:id/avatar (user) or /api/v1/admin/presets/:id/avatar (admin)
-func UploadPresetAvatar(c *gin.Context) {
- presetID := c.Param("id")
+// ── Persona Avatar Upload ────────────────────
+// POST /api/v1/personas/:id/avatar (user) or /api/v1/admin/personas/:id/avatar (admin)
+func UploadPersonaAvatar(c *gin.Context) {
+ personaID := c.Param("id")
var req uploadAvatarRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -208,7 +208,7 @@ func UploadPresetAvatar(c *gin.Context) {
result, err := database.DB.Exec(
database.Q(`UPDATE personas SET avatar = $1, updated_at = NOW() WHERE id = $2`),
- dataURI, presetID,
+ dataURI, personaID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"})
@@ -216,20 +216,20 @@ func UploadPresetAvatar(c *gin.Context) {
}
rows, _ := result.RowsAffected()
if rows == 0 {
- c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
+ c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
c.JSON(http.StatusOK, gin.H{"avatar": dataURI})
}
-// DeletePresetAvatar clears a preset's avatar.
-func DeletePresetAvatar(c *gin.Context) {
- presetID := c.Param("id")
+// DeletePersonaAvatar clears a persona's avatar.
+func DeletePersonaAvatar(c *gin.Context) {
+ personaID := c.Param("id")
result, err := database.DB.Exec(
database.Q(`UPDATE personas SET avatar = '', updated_at = NOW() WHERE id = $1`),
- presetID,
+ personaID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"})
@@ -237,7 +237,7 @@ func DeletePresetAvatar(c *gin.Context) {
}
rows, _ := result.RowsAffected()
if rows == 0 {
- c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
+ c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
diff --git a/server/handlers/channels.go b/server/handlers/channels.go
index d154883..edbe797 100644
--- a/server/handlers/channels.go
+++ b/server/handlers/channels.go
@@ -23,7 +23,7 @@ type createChannelRequest struct {
Description string `json:"description,omitempty"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
- APIConfigID *string `json:"provider_config_id,omitempty"`
+ ProviderConfigID *string `json:"provider_config_id,omitempty"`
Folder string `json:"folder,omitempty"`
Tags []string `json:"tags,omitempty"`
}
@@ -33,7 +33,7 @@ type updateChannelRequest struct {
Description *string `json:"description,omitempty"`
Model *string `json:"model,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
- APIConfigID *string `json:"provider_config_id,omitempty"`
+ ProviderConfigID *string `json:"provider_config_id,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"`
IsPinned *bool `json:"is_pinned,omitempty"`
Folder *string `json:"folder,omitempty"`
@@ -49,7 +49,7 @@ type channelResponse struct {
Type string `json:"type"`
Description *string `json:"description"`
Model *string `json:"model"`
- APIConfigID *string `json:"provider_config_id"`
+ ProviderConfigID *string `json:"provider_config_id"`
SystemPrompt *string `json:"system_prompt"`
IsArchived bool `json:"is_archived"`
IsPinned bool `json:"is_pinned"`
@@ -231,7 +231,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
var ch channelResponse
var tags []string
err := rows.Scan(
- &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
+ &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
scanTags(&tags), scanJSON(&ch.Settings),
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
@@ -288,7 +288,7 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
system_prompt, provider_config_id, folder, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, userID, req.Title, channelType, req.Description, req.Model,
- req.SystemPrompt, req.APIConfigID, req.Folder, writeTagsArg(req.Tags),
+ req.SystemPrompt, req.ProviderConfigID, req.Folder, writeTagsArg(req.Tags),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
@@ -301,7 +301,7 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
tags, settings,
created_at, updated_at
FROM channels WHERE id = ?`, id).Scan(
- &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
+ &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
)
@@ -315,10 +315,10 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at
- `, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
+ `, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.ProviderConfigID,
req.Folder, pq.Array(req.Tags),
).Scan(
- &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
+ &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
pq.Array(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
)
@@ -356,13 +356,13 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, is_default)
VALUES (?, ?, ?, ?, 1)
ON CONFLICT DO NOTHING
- `, store.NewID(), ch.ID, req.Model, req.APIConfigID)
+ `, store.NewID(), ch.ID, req.Model, req.ProviderConfigID)
} else {
_, _ = database.DB.Exec(`
INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default)
VALUES ($1, $2, $3, true)
ON CONFLICT DO NOTHING
- `, ch.ID, req.Model, req.APIConfigID)
+ `, ch.ID, req.Model, req.ProviderConfigID)
}
}
@@ -389,7 +389,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
) mc ON mc.channel_id = c.id
WHERE c.id = $1 AND c.user_id = $2
`), channelID, userID).Scan(
- &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
+ &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
scanTags(&tags), scanJSON(&ch.Settings),
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
@@ -462,8 +462,8 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
if req.SystemPrompt != nil {
addClause("system_prompt", *req.SystemPrompt)
}
- if req.APIConfigID != nil {
- addClause("provider_config_id", *req.APIConfigID)
+ if req.ProviderConfigID != nil {
+ addClause("provider_config_id", *req.ProviderConfigID)
}
if req.IsArchived != nil {
addClause("is_archived", *req.IsArchived)
diff --git a/server/handlers/completion.go b/server/handlers/completion.go
index a6b1653..77cfc2e 100644
--- a/server/handlers/completion.go
+++ b/server/handlers/completion.go
@@ -32,12 +32,11 @@ import (
// ── Request Types ───────────────────────────
type completionRequest struct {
- ChannelID string `json:"channel_id"` // preferred; validated manually below
- ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id
- Content string `json:"content" binding:"required"`
- Model string `json:"model,omitempty"`
- PresetID string `json:"preset_id,omitempty"` // if set, unwraps preset → base model + config
- APIConfigID string `json:"provider_config_id,omitempty"`
+ ChannelID string `json:"channel_id"`
+ Content string `json:"content" binding:"required"`
+ Model string `json:"model,omitempty"`
+ PersonaID string `json:"persona_id,omitempty"`
+ ProviderConfigID string `json:"provider_config_id,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
@@ -194,11 +193,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
return
}
- // Support chat_id as alias during frontend transition
channelID := req.ChannelID
- if channelID == "" {
- channelID = req.ChatID
- }
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id is required"})
return
@@ -211,60 +206,60 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
return
}
- // ── Preset unwrap: preset overrides defaults, explicit request fields win ──
- var presetSystemPrompt string
+ // ── Persona unwrap: persona overrides defaults, explicit request fields win ──
+ var personaSystemPrompt string
var personaID string // tracks active persona for KB scoping
- var presetThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1)
- if req.PresetID != "" {
- preset := ResolvePreset(h.stores, req.PresetID, userID)
- if preset == nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found or not accessible"})
+ var personaThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1)
+ if req.PersonaID != "" {
+ persona := ResolvePersona(h.stores, req.PersonaID, userID)
+ if persona == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "persona not found or not accessible"})
return
}
- personaID = preset.ID
- // Preset provides defaults; explicit request fields take priority
+ personaID = persona.ID
+ // Persona provides defaults; explicit request fields take priority
if req.Model == "" {
- req.Model = preset.BaseModelID
+ req.Model = persona.BaseModelID
}
- if req.APIConfigID == "" && preset.ProviderConfigID != nil {
- req.APIConfigID = *preset.ProviderConfigID
+ if req.ProviderConfigID == "" && persona.ProviderConfigID != nil {
+ req.ProviderConfigID = *persona.ProviderConfigID
}
- if req.Temperature == nil && preset.Temperature != nil {
- req.Temperature = preset.Temperature
+ if req.Temperature == nil && persona.Temperature != nil {
+ req.Temperature = persona.Temperature
}
- if req.MaxTokens == 0 && preset.MaxTokens != nil {
- req.MaxTokens = *preset.MaxTokens
+ if req.MaxTokens == 0 && persona.MaxTokens != nil {
+ req.MaxTokens = *persona.MaxTokens
}
- if preset.SystemPrompt != "" {
- presetSystemPrompt = preset.SystemPrompt
+ if persona.SystemPrompt != "" {
+ personaSystemPrompt = persona.SystemPrompt
}
- presetThinkingBudget = preset.ThinkingBudget
+ personaThinkingBudget = persona.ThinkingBudget
}
- // ── Project persona fallback (v0.19.2): if no explicit preset, check project ──
- if req.PresetID == "" && h.stores.Projects != nil {
+ // ── Project persona fallback (v0.19.2): if no explicit persona, check project ──
+ if req.PersonaID == "" && h.stores.Projects != nil {
projID, _ := h.stores.Projects.GetProjectIDForChannel(context.Background(), channelID)
if projID != "" {
if proj, err := h.stores.Projects.GetByID(context.Background(), projID); err == nil {
if pid, ok := proj.Settings["persona_id"].(string); ok && pid != "" {
- if preset := ResolvePreset(h.stores, pid, userID); preset != nil {
- personaID = preset.ID
+ if persona := ResolvePersona(h.stores, pid, userID); persona != nil {
+ personaID = persona.ID
if req.Model == "" {
- req.Model = preset.BaseModelID
+ req.Model = persona.BaseModelID
}
- if req.APIConfigID == "" && preset.ProviderConfigID != nil {
- req.APIConfigID = *preset.ProviderConfigID
+ if req.ProviderConfigID == "" && persona.ProviderConfigID != nil {
+ req.ProviderConfigID = *persona.ProviderConfigID
}
- if req.Temperature == nil && preset.Temperature != nil {
- req.Temperature = preset.Temperature
+ if req.Temperature == nil && persona.Temperature != nil {
+ req.Temperature = persona.Temperature
}
- if req.MaxTokens == 0 && preset.MaxTokens != nil {
- req.MaxTokens = *preset.MaxTokens
+ if req.MaxTokens == 0 && persona.MaxTokens != nil {
+ req.MaxTokens = *persona.MaxTokens
}
- if preset.SystemPrompt != "" {
- presetSystemPrompt = preset.SystemPrompt
+ if persona.SystemPrompt != "" {
+ personaSystemPrompt = persona.SystemPrompt
}
- presetThinkingBudget = preset.ThinkingBudget
+ personaThinkingBudget = persona.ThinkingBudget
}
}
}
@@ -287,7 +282,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
routingDecision = dec
if winConfigID != configID {
// Routing selected a different provider — reload its credentials
- req.APIConfigID = winConfigID
+ req.ProviderConfigID = winConfigID
providerCfg2, providerID2, model2, configID2, providerScope2, err := h.resolveConfig(userID, channelID, req)
if err == nil {
providerCfg = providerCfg2
@@ -307,12 +302,12 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
// ── Inject persona-level thinking budget into provider settings (v0.22.1) ──
// When a persona specifies a thinking budget, promote it into provider
// settings so hooks can activate extended thinking automatically.
- if presetThinkingBudget != nil && *presetThinkingBudget > 0 {
+ if personaThinkingBudget != nil && *personaThinkingBudget > 0 {
if providerCfg.Settings == nil {
providerCfg.Settings = make(map[string]interface{})
}
providerCfg.Settings["extended_thinking"] = true
- providerCfg.Settings["thinking_budget"] = *presetThinkingBudget
+ providerCfg.Settings["thinking_budget"] = *personaThinkingBudget
}
// ── Team policy: require_private_providers ──
@@ -337,7 +332,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// Load conversation history
- messages, err := h.loadConversation(channelID, userID, presetSystemPrompt, personaID)
+ messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
return
@@ -396,7 +391,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
parsed := mentions.Parse(req.Content, roster)
targets := mentions.ResolvedModels(parsed)
if len(targets) > 1 {
- h.multiModelStream(c, targets, messages, channelID, userID, personaID, presetSystemPrompt, workspaceID, req)
+ h.multiModelStream(c, targets, messages, channelID, userID, personaID, personaSystemPrompt, workspaceID, req)
return
}
}
@@ -449,7 +444,7 @@ func (h *CompletionHandler) multiModelStream(
c *gin.Context,
targets []models.ChannelModel,
messages []providers.Message,
- channelID, userID, personaID, presetSystemPrompt, workspaceID string,
+ channelID, userID, personaID, personaSystemPrompt, workspaceID string,
req completionRequest,
) {
// Set SSE headers once for the entire multi-model stream
@@ -485,7 +480,7 @@ func (h *CompletionHandler) multiModelStream(
targetReq := req
targetReq.Model = target.ModelID
if target.ProviderConfigID != "" {
- targetReq.APIConfigID = target.ProviderConfigID
+ targetReq.ProviderConfigID = target.ProviderConfigID
}
providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, targetReq)
@@ -998,8 +993,8 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
var configID string
// 1. Explicit config from request
- if req.APIConfigID != "" {
- configID = req.APIConfigID
+ if req.ProviderConfigID != "" {
+ configID = req.ProviderConfigID
}
// 2. Config from channel
@@ -1115,7 +1110,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
//
// Summary-aware: if the path contains a summary node (metadata.type = "summary"),
// messages before it are replaced by the summary content as a system message.
-func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt, personaID string) ([]providers.Message, error) {
+func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPrompt, personaID string) ([]providers.Message, error) {
messages := make([]providers.Message, 0)
// ── Admin system prompt (always injected first, no opt out) ──
@@ -1129,17 +1124,17 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
}
}
- // ── User/preset system prompt (appended after admin prompt) ──
+ // ── User/persona system prompt (appended after admin prompt) ──
var systemPrompt *string
_ = database.DB.QueryRow(
database.Q(`SELECT system_prompt FROM channels WHERE id = $1`), channelID,
).Scan(&systemPrompt)
- // Preset system prompt takes priority; channel system prompt is fallback
- if presetSystemPrompt != "" {
+ // Persona system prompt takes priority; channel system prompt is fallback
+ if personaSystemPrompt != "" {
messages = append(messages, providers.Message{
Role: "system",
- Content: presetSystemPrompt,
+ Content: personaSystemPrompt,
})
} else if systemPrompt != nil && *systemPrompt != "" {
messages = append(messages, providers.Message{
diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go
index 23592c3..7b8c7a9 100644
--- a/server/handlers/integration_test.go
+++ b/server/handlers/integration_test.go
@@ -200,12 +200,12 @@ func setupHarness(t *testing.T) *testHarness {
settings := NewSettingsHandler(nil)
protected.GET("/profile", settings.GetProfile)
- // Presets
- presets := NewPersonaHandler(stores)
- protected.GET("/presets", presets.ListUserPersonas)
- protected.POST("/presets", presets.CreateUserPersona)
- protected.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
- protected.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
+ // Personas
+ personas := NewPersonaHandler(stores)
+ protected.GET("/personas", personas.ListUserPersonas)
+ protected.POST("/personas", personas.CreateUserPersona)
+ protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
+ protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
// Notes
notes := NewNoteHandler(stores)
@@ -283,10 +283,10 @@ func setupHarness(t *testing.T) *testHarness {
admin.GET("/teams/:id", teams.GetTeam)
admin.GET("/teams/:id/members", teams.ListMembers)
admin.POST("/teams/:id/members", teams.AddMember)
- admin.GET("/presets", presets.ListAdminPersonas)
- admin.POST("/presets", presets.CreateAdminPersona)
- admin.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
- admin.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
+ admin.GET("/personas", personas.ListAdminPersonas)
+ admin.POST("/personas", personas.CreateAdminPersona)
+ admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
+ admin.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
// Admin groups (v0.16.0)
groupAdm := NewGroupHandler(stores)
@@ -802,14 +802,14 @@ func TestIntegration_TeamMemberManagement(t *testing.T) {
}
}
-// ── 7. Presets (Policy Gated) ────────────────
+// ── 7. Personas (Policy Gated) ───────────────
-func TestIntegration_PresetCreation_PolicyGated(t *testing.T) {
+func TestIntegration_PersonaCreation_PolicyGated(t *testing.T) {
h := setupHarness(t)
adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
_ = adminID
- // Create a provider config (presets need a valid model reference)
+ // Create a provider config (personas need a valid model reference)
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "TestProvider", "provider": "openai",
"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
@@ -821,13 +821,13 @@ func TestIntegration_PresetCreation_PolicyGated(t *testing.T) {
// Ensure allow_user_personas = false
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'false') ON CONFLICT (key) DO UPDATE SET value = 'false'")
- // Try to create a preset as admin (role=admin but policy says no)
- w = h.request("POST", "/api/v1/presets", adminToken, map[string]interface{}{
- "name": "My Preset", "base_model_id": "gpt-4o",
+ // Try to create a persona as admin (role=admin but policy says no)
+ w = h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
+ "name": "My Persona", "base_model_id": "gpt-4o",
"provider_config_id": configID, "system_prompt": "You are helpful",
})
if w.Code != http.StatusForbidden {
- t.Fatalf("preset create with policy=false: want 403, got %d: %s", w.Code, w.Body.String())
+ t.Fatalf("persona create with policy=false: want 403, got %d: %s", w.Code, w.Body.String())
}
// Enable the policy
@@ -835,18 +835,18 @@ func TestIntegration_PresetCreation_PolicyGated(t *testing.T) {
map[string]interface{}{"value": "true"})
// Now creation should succeed
- w = h.request("POST", "/api/v1/presets", adminToken, map[string]interface{}{
- "name": "My Preset", "base_model_id": "gpt-4o",
+ w = h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
+ "name": "My Persona", "base_model_id": "gpt-4o",
"provider_config_id": configID, "system_prompt": "You are helpful",
})
if w.Code != http.StatusCreated {
- t.Fatalf("preset create with policy=true: want 201, got %d: %s", w.Code, w.Body.String())
+ t.Fatalf("persona create with policy=true: want 201, got %d: %s", w.Code, w.Body.String())
}
- // List presets
- w = h.request("GET", "/api/v1/presets", adminToken, nil)
+ // List personas
+ w = h.request("GET", "/api/v1/personas", adminToken, nil)
if w.Code != http.StatusOK {
- t.Fatalf("list presets: want 200, got %d: %s", w.Code, w.Body.String())
+ t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
}
}
@@ -1105,7 +1105,7 @@ func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) {
t.Error("MISSING: model_id — required for composite ID")
}
- // source required for preset detection
+ // source required for persona detection
if m["source"] == nil || m["source"] == "" {
t.Error("MISSING: source — frontend uses this to distinguish catalog vs persona")
}
@@ -2748,7 +2748,7 @@ func TestResourceGrants(t *testing.T) {
decode(w, &group)
// Create a persona (admin)
- w = h.request("POST", "/api/v1/admin/presets", adminToken, map[string]interface{}{
+ w = h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
"name": "Test Bot",
"base_model_id": "test-model",
"scope": "global",
@@ -2840,11 +2840,11 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
}
// ── User should NOT see team persona ──
- w = h.request("GET", "/api/v1/presets", userToken, nil)
- var presetsResp map[string]interface{}
- decode(w, &presetsResp)
- presets, _ := presetsResp["presets"].([]interface{})
- for _, p := range presets {
+ w = h.request("GET", "/api/v1/personas", userToken, nil)
+ var personasResp map[string]interface{}
+ decode(w, &personasResp)
+ personas, _ := personasResp["personas"].([]interface{})
+ for _, p := range personas {
pm := p.(map[string]interface{})
if pm["id"] == personaID {
t.Fatalf("user should NOT see team persona without group access")
@@ -2872,14 +2872,14 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
}
// ── User should NOW see team persona via group grant ──
- w = h.request("GET", "/api/v1/presets", userToken, nil)
+ w = h.request("GET", "/api/v1/personas", userToken, nil)
if w.Code != http.StatusOK {
- t.Fatalf("list presets: want 200, got %d: %s", w.Code, w.Body.String())
+ t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
}
- decode(w, &presetsResp)
- presets, _ = presetsResp["presets"].([]interface{})
+ decode(w, &personasResp)
+ personas, _ = personasResp["personas"].([]interface{})
found := false
- for _, p := range presets {
+ for _, p := range personas {
pm := p.(map[string]interface{})
if pm["id"] == personaID {
found = true
@@ -2887,16 +2887,16 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
}
}
if !found {
- t.Fatalf("user should see team persona after group grant, but didn't find it in %d presets (response: %s)", len(presets), w.Body.String())
+ t.Fatalf("user should see team persona after group grant, but didn't find it in %d personas (response: %s)", len(personas), w.Body.String())
}
// ── Remove user from group → should lose access ──
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil)
- w = h.request("GET", "/api/v1/presets", userToken, nil)
- decode(w, &presetsResp)
- presets, _ = presetsResp["presets"].([]interface{})
- for _, p := range presets {
+ w = h.request("GET", "/api/v1/personas", userToken, nil)
+ decode(w, &personasResp)
+ personas, _ = personasResp["personas"].([]interface{})
+ for _, p := range personas {
pm := p.(map[string]interface{})
if pm["id"] == personaID {
t.Fatalf("user should NOT see persona after group removal")
@@ -2915,7 +2915,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// 1. Create a global persona via admin API
- w := h.request("POST", "/api/v1/admin/presets", adminToken, map[string]interface{}{
+ w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
"name": "KB Persona",
"base_model_id": "test-model",
"system_prompt": "You are a test persona.",
@@ -2941,7 +2941,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
// 3. Bind KB to persona
w = h.request("PUT",
- fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
+ fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
adminToken, map[string]interface{}{
"kb_ids": []string{kbID},
"auto_search": map[string]bool{kbID: true},
@@ -2952,7 +2952,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
// 4. Read back bindings
w = h.request("GET",
- fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
+ fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
@@ -2973,7 +2973,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
// 5. Unbind — send empty list
w = h.request("PUT",
- fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
+ fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
adminToken, map[string]interface{}{
"kb_ids": []string{},
})
@@ -2983,7 +2983,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
// 6. Verify empty
w = h.request("GET",
- fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
+ fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get empty persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
diff --git a/server/handlers/messages.go b/server/handlers/messages.go
index 91dd410..5abf385 100644
--- a/server/handlers/messages.go
+++ b/server/handlers/messages.go
@@ -47,8 +47,8 @@ type editRequest struct {
type regenerateRequest struct {
Model string `json:"model,omitempty"`
- PresetID string `json:"preset_id,omitempty"`
- APIConfigID string `json:"provider_config_id,omitempty"`
+ PersonaID string `json:"persona_id,omitempty"`
+ ProviderConfigID string `json:"provider_config_id,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
DisabledTools []string `json:"disabled_tools,omitempty"`
@@ -417,34 +417,34 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore, nil)
- var presetSystemPrompt string
+ var personaSystemPrompt string
var personaID string
model := req.Model
- apiConfigID := req.APIConfigID
+ providerConfigID := req.ProviderConfigID
temperature := req.Temperature
maxTokens := req.MaxTokens
- if req.PresetID != "" {
- preset := ResolvePreset(h.stores, req.PresetID, userID)
- if preset == nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found"})
+ if req.PersonaID != "" {
+ persona := ResolvePersona(h.stores, req.PersonaID, userID)
+ if persona == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "persona not found"})
return
}
- personaID = preset.ID
+ personaID = persona.ID
if model == "" {
- model = preset.BaseModelID
+ model = persona.BaseModelID
}
- if apiConfigID == "" && preset.ProviderConfigID != nil {
- apiConfigID = *preset.ProviderConfigID
+ if providerConfigID == "" && persona.ProviderConfigID != nil {
+ providerConfigID = *persona.ProviderConfigID
}
- if temperature == nil && preset.Temperature != nil {
- temperature = preset.Temperature
+ if temperature == nil && persona.Temperature != nil {
+ temperature = persona.Temperature
}
- if maxTokens == 0 && preset.MaxTokens != nil {
- maxTokens = *preset.MaxTokens
+ if maxTokens == 0 && persona.MaxTokens != nil {
+ maxTokens = *persona.MaxTokens
}
- if preset.SystemPrompt != "" {
- presetSystemPrompt = preset.SystemPrompt
+ if persona.SystemPrompt != "" {
+ personaSystemPrompt = persona.SystemPrompt
}
}
@@ -459,7 +459,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
providerCfg, providerID, model, configID, providerScope, err := comp.resolveConfig(userID, channelID, completionRequest{
Model: model,
- APIConfigID: apiConfigID,
+ ProviderConfigID: providerConfigID,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -475,8 +475,8 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// Build LLM message array
llmMessages := make([]providers.Message, 0, len(contextPath)+1)
- if presetSystemPrompt != "" {
- llmMessages = append(llmMessages, providers.Message{Role: "system", Content: presetSystemPrompt})
+ if personaSystemPrompt != "" {
+ llmMessages = append(llmMessages, providers.Message{Role: "system", Content: personaSystemPrompt})
} else {
var systemPrompt *string
_ = database.DB.QueryRow(database.Q(`SELECT system_prompt FROM channels WHERE id = $1`), channelID).Scan(&systemPrompt)
diff --git a/server/handlers/presets.go b/server/handlers/personas.go
similarity index 93%
rename from server/handlers/presets.go
rename to server/handlers/personas.go
index 4c5bbfc..c043025 100644
--- a/server/handlers/presets.go
+++ b/server/handlers/personas.go
@@ -30,7 +30,7 @@ func (h *PersonaHandler) ListUserPersonas(c *gin.Context) {
return
}
- c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
+ c.JSON(http.StatusOK, gin.H{"personas": personas})
}
func (h *PersonaHandler) CreateUserPersona(c *gin.Context) {
@@ -121,7 +121,7 @@ func (h *PersonaHandler) ListTeamPersonas(c *gin.Context) {
return
}
- c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
+ c.JSON(http.StatusOK, gin.H{"personas": personas})
}
func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) {
@@ -167,7 +167,7 @@ func (h *PersonaHandler) ListAdminPersonas(c *gin.Context) {
return
}
- c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
+ c.JSON(http.StatusOK, gin.H{"personas": personas})
}
func (h *PersonaHandler) CreateAdminPersona(c *gin.Context) {
@@ -253,18 +253,17 @@ func (r *personaRequest) toPersona() *models.Persona {
return p
}
-// ResolvePreset loads a persona by ID and returns it if the user has access.
+// ResolvePersona loads a persona by ID and returns it if the user has access.
// Returns nil if not found, inactive, or not accessible.
-// Used by completion.go and messages.go for preset unwrapping.
-func ResolvePreset(stores store.Stores, presetID, userID string) *models.Persona {
+func ResolvePersona(stores store.Stores, personaID, userID string) *models.Persona {
ctx := context.Background()
- p, err := stores.Personas.GetByID(ctx, presetID)
+ p, err := stores.Personas.GetByID(ctx, personaID)
if err != nil || !p.IsActive {
return nil
}
- ok, err := stores.Personas.UserCanAccess(ctx, userID, presetID)
+ ok, err := stores.Personas.UserCanAccess(ctx, userID, personaID)
if err != nil || !ok {
return nil
}
diff --git a/server/handlers/teams.go b/server/handlers/teams.go
index e6588e0..d66ce97 100644
--- a/server/handlers/teams.go
+++ b/server/handlers/teams.go
@@ -470,10 +470,10 @@ func (h *TeamHandler) MyTeams(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": teams})
}
-// ── Team Models: Available for Presets ──────
+// ── Team Models: Available for Personas ──────
// ListAvailableModels returns models with visibility 'enabled' or 'team'
-// for team admins building presets. Requires RequireTeamAdmin middleware.
+// for team admins building personas. Requires RequireTeamAdmin middleware.
// GET /api/v1/teams/:teamId/models
func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
teamID := getTeamID(c)
diff --git a/server/main.go b/server/main.go
index 83461f3..a712a4b 100644
--- a/server/main.go
+++ b/server/main.go
@@ -427,7 +427,6 @@ func main() {
modelH.SetHealthStore(healthStore)
}
protected.GET("/models/enabled", modelH.ListEnabledModels)
- protected.GET("/models", modelH.ListEnabledModels) // alias
// Model Preferences
modelPrefs := handlers.NewModelPrefsHandler(stores)
@@ -449,16 +448,16 @@ func main() {
usage := handlers.NewUsageHandler(stores)
protected.GET("/usage", usage.PersonalUsage)
- // Personas (replaces /presets)
+ // Personas
personas := handlers.NewPersonaHandler(stores)
- protected.GET("/presets", personas.ListUserPersonas) // backward compat
- protected.POST("/presets", personas.CreateUserPersona)
- protected.PUT("/presets/:id", personas.UpdateUserPersona)
- protected.DELETE("/presets/:id", personas.DeleteUserPersona)
- protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
- protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
- protected.GET("/presets/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
- protected.PUT("/presets/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
+ protected.GET("/personas", personas.ListUserPersonas)
+ protected.POST("/personas", personas.CreateUserPersona)
+ protected.PUT("/personas/:id", personas.UpdateUserPersona)
+ protected.DELETE("/personas/:id", personas.DeleteUserPersona)
+ protected.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar)
+ protected.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar)
+ protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
+ protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
// Notes
notes := handlers.NewNoteHandler(stores)
@@ -634,11 +633,11 @@ func main() {
// Team personas
teamPersonas := handlers.NewPersonaHandler(stores)
- teamScoped.GET("/presets", teamPersonas.ListTeamPersonas)
- teamScoped.POST("/presets", teamPersonas.CreateTeamPersona)
- teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona)
- teamScoped.GET("/presets/:id/knowledge-bases", teamPersonas.GetPersonaKBs) // v0.17.0
- teamScoped.PUT("/presets/:id/knowledge-bases", teamPersonas.SetPersonaKBs) // v0.17.0
+ teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
+ teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
+ teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona)
+ teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetPersonaKBs) // v0.17.0
+ teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetPersonaKBs) // v0.17.0
// Team role overrides
teamRoles := handlers.NewRolesHandler(stores, roleResolver)
@@ -697,14 +696,14 @@ func main() {
// Personas (admin global)
personaAdm := handlers.NewPersonaHandler(stores)
- admin.GET("/presets", personaAdm.ListAdminPersonas)
- admin.POST("/presets", personaAdm.CreateAdminPersona)
- admin.PUT("/presets/:id", personaAdm.UpdateAdminPersona)
- admin.DELETE("/presets/:id", personaAdm.DeleteAdminPersona)
- admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
- admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
- admin.GET("/presets/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0
- admin.PUT("/presets/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0
+ admin.GET("/personas", personaAdm.ListAdminPersonas)
+ admin.POST("/personas", personaAdm.CreateAdminPersona)
+ admin.PUT("/personas/:id", personaAdm.UpdateAdminPersona)
+ admin.DELETE("/personas/:id", personaAdm.DeleteAdminPersona)
+ admin.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar)
+ admin.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar)
+ admin.GET("/personas/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0
+ admin.PUT("/personas/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0
// Admin memory review (v0.18.0)
adminMemH := handlers.NewMemoryHandler(stores)
diff --git a/server/models/models.go b/server/models/models.go
index 6a2872a..26a2f6f 100644
--- a/server/models/models.go
+++ b/server/models/models.go
@@ -609,14 +609,13 @@ type UserModel struct {
Capabilities ModelCapabilities `json:"capabilities"`
- // Preset fields — always emitted so frontend can branch on is_preset.
- IsPreset bool `json:"is_preset"`
- PresetID string `json:"preset_id,omitempty"`
- PresetScope string `json:"preset_scope,omitempty"`
- PresetAvatar string `json:"preset_avatar,omitempty"`
- PresetTeamName string `json:"preset_team_name,omitempty"`
+ // Persona fields — always emitted so frontend can branch on is_persona.
+ IsPersona bool `json:"is_persona"`
+ PersonaID string `json:"persona_id,omitempty"`
+ PersonaScope string `json:"persona_scope,omitempty"`
+ PersonaAvatar string `json:"persona_avatar,omitempty"`
+ PersonaTeamName string `json:"persona_team_name,omitempty"`
- PersonaID string `json:"persona_id,omitempty"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Avatar string `json:"avatar,omitempty"`
diff --git a/server/pages/loaders.go b/server/pages/loaders.go
index d4a4d35..07e293c 100644
--- a/server/pages/loaders.go
+++ b/server/pages/loaders.go
@@ -112,6 +112,11 @@ type NotesPageData struct {
// SettingsPageData is what the settings surface templates receive.
type SettingsPageData struct {
Section string `json:"section"`
+
+ // v0.22.7: Feature gates from admin config.
+ // These control which nav links/tabs are visible in the settings surface.
+ BYOKEnabled bool `json:"BYOKEnabled"`
+ UserPersonasEnabled bool `json:"UserPersonasEnabled"`
}
// ── Loader registration ──────────────────────
@@ -290,7 +295,7 @@ func sectionCategory(section string) string {
switch section {
case "users", "teams", "groups":
return "people"
- case "providers", "models", "presets", "roles", "knowledgeBases", "memory":
+ case "providers", "models", "personas", "roles", "knowledgeBases", "memory":
return "ai"
case "health", "routing", "capabilities":
return "routing"
@@ -373,7 +378,6 @@ func (e *Engine) loadUsers(ctx context.Context, s store.Stores) []UserRow {
return out
}
-
// ── Editor loader ────────────────────────────
// Resolves workspace from URL param. The heavy lifting (file tree, CodeMirror)
// is done client-side by editor-mode.js.
@@ -400,13 +404,35 @@ func (e *Engine) notesLoader(c *gin.Context, s store.Stores) (any, error) {
}
// ── Settings loader ──────────────────────────
-// Most settings sections are populated client-side by existing JS.
-// Server just passes the section name for navigation highlighting.
+// v0.22.7: Reads feature gates from GlobalConfig to control
+// which nav links/tabs are visible (BYOK, User Personas).
func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) {
section := c.Param("section")
if section == "" {
section = "general"
}
- return &SettingsPageData{Section: section}, nil
+
+ data := &SettingsPageData{Section: section}
+
+ // Read feature gates from admin config
+ if s.GlobalConfig != nil {
+ ctx := context.Background()
+
+ // BYOK: check if user providers are enabled
+ if raw, err := s.GlobalConfig.Get(ctx, "user_providers"); err == nil && raw != nil {
+ if v, ok := raw["enabled"].(bool); ok {
+ data.BYOKEnabled = v
+ }
+ }
+
+ // User Personas: check if user-created personas are enabled
+ if raw, err := s.GlobalConfig.Get(ctx, "user_personas"); err == nil && raw != nil {
+ if v, ok := raw["enabled"].(bool); ok {
+ data.UserPersonasEnabled = v
+ }
+ }
+ }
+
+ return data, nil
}
diff --git a/server/pages/pages.go b/server/pages/pages.go
index 61076e5..5a3526e 100644
--- a/server/pages/pages.go
+++ b/server/pages/pages.go
@@ -4,7 +4,7 @@
//
// templates/base.html — outer shell (banner + surface block + scripts)
// templates/login.html — standalone login page
-// templates/components/*.html — reusable partials (model-select, team-select, etc.)
+// templates/components/*.html — reusable partials (model-select, team-select, chat-pane, etc.)
// templates/surfaces/*.html — one per surface (chat, editor, notes, admin, etc.)
// templates/admin/*.html — admin sub-pages (roles, routing, providers, etc.)
package pages
@@ -52,15 +52,25 @@ type BannerConfig struct {
// PageData is passed to every template render.
type PageData struct {
- Banner BannerConfig
- Surface string // active surface ID
- Section string // sub-section (for admin pages)
- CSPNonce string
+ Banner BannerConfig
+ Surface string // active surface ID
+ Section string // sub-section (for admin pages)
+ CSPNonce string
BasePath string
Version string
Environment string
- User *UserContext
- Data any // surface-specific data from loader
+ User *UserContext
+ Data any // surface-specific data from loader
+
+ // v0.22.7: Theme + settings injection
+ Theme string // "dark", "light", or "" (= use default)
+ SurfaceSettings any // JSON-serialized to window.__SETTINGS__
+
+ // v0.22.7: Login/splash page fields
+ InstanceName string // branding: instance display name
+ LogoURL string // branding: custom logo URL
+ Tagline string // branding: tagline under instance name
+ RegistrationOpen bool // whether self-registration is enabled
}
// UserContext is the authenticated user's info available to templates.
@@ -138,6 +148,11 @@ func (e *Engine) Render(c *gin.Context, name string, data PageData) {
data.CSPNonce = generateNonce()
data.Banner = e.loadBanner()
+ // v0.22.7: Default theme if not set
+ if data.Theme == "" {
+ data.Theme = "dark"
+ }
+
e.mu.RLock()
tmpl := e.templates
e.mu.RUnlock()
@@ -186,8 +201,16 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
// RenderLogin serves the standalone login page.
func (e *Engine) RenderLogin() gin.HandlerFunc {
return func(c *gin.Context) {
+ // v0.22.7: Populate splash/login fields from global config
+ instanceName, logoURL, tagline := e.loadBranding()
+ regOpen := e.isRegistrationOpen()
+
e.Render(c, "login.html", PageData{
- Surface: "login",
+ Surface: "login",
+ InstanceName: instanceName,
+ LogoURL: logoURL,
+ Tagline: tagline,
+ RegistrationOpen: regOpen,
})
}
}
@@ -229,6 +252,43 @@ func (e *Engine) loadBanner() BannerConfig {
return b
}
+// loadBranding reads instance branding from global settings.
+func (e *Engine) loadBranding() (name, logoURL, tagline string) {
+ name = "Chat Switchboard" // default
+ if e.stores.GlobalConfig == nil {
+ return
+ }
+ raw, err := e.stores.GlobalConfig.Get(context.Background(), "branding")
+ if err != nil || raw == nil {
+ return
+ }
+ if v, ok := raw["instance_name"].(string); ok && v != "" {
+ name = v
+ }
+ if v, ok := raw["logo_url"].(string); ok {
+ logoURL = v
+ }
+ if v, ok := raw["tagline"].(string); ok {
+ tagline = v
+ }
+ return
+}
+
+// isRegistrationOpen checks if self-registration is enabled.
+func (e *Engine) isRegistrationOpen() bool {
+ if e.stores.GlobalConfig == nil {
+ return false
+ }
+ raw, err := e.stores.GlobalConfig.Get(context.Background(), "registration")
+ if err != nil || raw == nil {
+ return false
+ }
+ if v, ok := raw["enabled"].(bool); ok {
+ return v
+ }
+ return false
+}
+
// Version is injected at build time via ldflags.
var Version = "dev"
diff --git a/server/pages/templates/admin/teams.html b/server/pages/templates/admin/teams.html
index 21e0f25..528dd1d 100644
--- a/server/pages/templates/admin/teams.html
+++ b/server/pages/templates/admin/teams.html
@@ -3,7 +3,7 @@
{{define "admin-teams"}}
Teams
-
Organize users into teams for scoped access to providers, presets, and policies.
+
Organize users into teams for scoped access to providers, personas, and policies.
{{if eq .Section "general"}}General{{else if eq .Section "appearance"}}Appearance{{else if eq .Section "models"}}Models{{else if eq .Section "personas"}}Personas{{else if eq .Section "profile"}}Profile{{else if eq .Section "teams"}}Teams{{else if eq .Section "providers"}}My Providers{{else if eq .Section "roles"}}Model Roles{{else if eq .Section "usage"}}My Usage{{else if eq .Section "knowledge"}}Knowledge Bases{{else if eq .Section "memory"}}Memory{{else if eq .Section "notifications"}}Notifications{{else}}Settings{{end}}
+
+ {{if eq .Section "general"}}
+
+
Chat Defaults
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0.7
+
+
+
+
+
+ {{else if eq .Section "appearance"}}
+
+
Theme
+
+ ☀ Light
+ ☾ Dark
+ ⊞ System
+
+
+
+
UI Scale
+
+
+
+ 100%
+
+
+
Message Font Size
+
+
+
+ 14px
+
+
+
+ Save
+ {{else if eq .Section "profile"}}
+
+
Profile
+
+
+ Save Profile
+
+
+
Change Password
+
+
+
+ Update Password
+
+ {{else if eq .Section "providers"}}
+
+
Your admin has disabled user-managed API keys (BYOK).
+
+
+ + Add Provider
+
+
+
Loading providers…
+ {{else if eq .Section "personas"}}
+
+ + New Persona
+
+
+
Loading personas…
+ {{else}}
Loading…
+ {{end}}
+
+
+
{{end}}
-{{define "settings-general"}}
-
-
Profile
-
-
-
-
-
-
-
-
- Save
-
-
-
-
Password
-
-
-
-
-
-
-
-
-
-
-
-
- Change Password
-
-{{end}}
-
-{{define "settings-appearance"}}
-
-
Theme
-
- Dark
- Light
-
-
-
-
Editor Keymap
-
- Standard
- Vim
- Emacs
-
-
-
-
UI Scale
-
-
- 100%
-
-
Message Font Size
-
-
- 14px
-
-
-{{end}}
-
-{{define "settings-providers"}}
-
-
Your admin has disabled user-managed API keys (BYOK). Models are available through team presets.