25 KiB
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.
// 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.
{{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:
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:
{{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:
- What components it uses (ChatPane, NoteEditor, FileTree, etc.)
- How they're arranged (CSS grid/flex layout)
- What data it needs on load (Go data loader)
- 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:
{
"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.
{
"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 <html>.
[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.
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.
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 <img> elements and wraps them with action buttons. They don't
know about each other — they agree on the DOM contract.
ctx.hooks.postRender((containerEl, message) => {
containerEl.querySelectorAll('img[data-display-content]').forEach(img => {
const actions = document.createElement('div');
actions.className = 'image-actions';
actions.innerHTML = '<button data-action="upscale">Upscale</button>';
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: "<div>...", 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:
{
"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:
<div data-mount="side-panel"></div>
<div data-mount="section" data-section-id="..."></div>
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
{
"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
ALTER TABLE messages ADD COLUMN display_content JSONB DEFAULT '[]';
Array of display items:
[
{
"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": "<div class='chart'>...</div>",
"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:
<div class="message-content">
<p>Here's the image you requested:</p>
</div>
<div class="message-display-content">
<img src="..." alt="..." data-display-content data-extension="image-generator">
</div>
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
-
Display content storage: JSONB column on messages vs. separate
message_display_contenttable. Column is simpler; table allows independent lifecycle (delete display content without touching message). Leaning column for KISS. -
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.
-
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.
-
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?
-
Extension settings storage: Extensions need per-user config (API keys for image gen, preferences for behavior). Current
UpdateUserExtensionSettingsendpoint exists. Is the schema sufficient or does it need structured validation fromsettings_schemain the manifest?