diff --git a/VERSION b/VERSION
index 5564d9a..81566e4 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.26.5
+0.27.0
\ No newline at end of file
diff --git a/docker-entrypoint-fe.sh b/docker-entrypoint-fe.sh
index d6fcb97..8173e04 100644
--- a/docker-entrypoint-fe.sh
+++ b/docker-entrypoint-fe.sh
@@ -78,6 +78,10 @@ $(page_proxy_block "${BASE_PATH}/editor")
$(page_proxy_block "${BASE_PATH}/notes")
$(page_proxy_block "${BASE_PATH}/settings")
$(page_proxy_block "${BASE_PATH}/w")
+ # v0.27.0: Extension surface page routes
+$(page_proxy_block "${BASE_PATH}/s")
+ # v0.27.0: Extension surface static assets (served by backend from /data/surfaces/)
+$(page_proxy_block "${BASE_PATH}/surfaces")
ROUTES
)
diff --git a/docs/DESIGN-0.27.0.md b/docs/DESIGN-0.27.0.md
new file mode 100644
index 0000000..46f27cf
--- /dev/null
+++ b/docs/DESIGN-0.27.0.md
@@ -0,0 +1,842 @@
+# DESIGN — v0.27.0+: Debt Clearance, Extension Surfaces, Tasks
+
+**Status:** Draft
+**Scope:** Deferred debt clearance (v0.27.0), Tasks / Autonomous Agents (v0.27.x), TBD pull-forward (v0.28.0)
+**Depends on:** Workflow Engine (v0.26.0), Dynamic Surfaces (v0.25.0), Permissions (v0.24.2)
+**Current version:** 0.26.5
+
+---
+
+## Versioning Plan
+
+```
+v0.27.0 Debt Clearance: Extension Surface Routes + Workflow Polish
+ │
+v0.27.1 Tasks Foundation: Service Channels + Scheduler
+ │
+v0.27.2 Task Execution: Budgets + Admin Controls
+ │
+v0.27.3 Task Chaining: Webhooks + Workflow-to-Workflow
+ │
+v0.27.4 Personal Tasks: BYOK Scheduling + User Task UI
+ │
+v0.28.0 Platform Polish (TBD pull-forward)
+```
+
+---
+
+## Code Audit — Items Already Shipped
+
+The ROADMAP lists these as deferred but the code proves they're done:
+
+| Item | Evidence | Listed as deferred in |
+|------|----------|----------------------|
+| Persona tool grant enforcement in completion handler | `completion.go:928–949` — second-pass allowlist via `GetToolGrants()` | v0.25.0, v0.26.0 |
+| Version snapshot includes persona tool grants | `workflows.go:263–273` — `stageSnapshot.ToolGrants` populated at publish | v0.25.0 |
+| Session cleanup job | Shipped in v0.26.0 (background goroutine, `SESSION_EXPIRY_DAYS`) | v0.24.3 |
+
+**Action:** Cross off all three in the ROADMAP's deferred sections and in
+the v0.25.0 / v0.26.0 milestone checklists.
+
+---
+
+## v0.27.0 — Debt Clearance
+
+All deferred items from v0.25.0 and v0.26.0 plus outstanding tech debt
+that blocks future work. No new features — just finishing what's owed.
+
+### Phase 1: Extension Surface Routes (`/s/:slug`)
+
+The longest-deferred item (originally v0.21.3). Infrastructure exists:
+`surface_registry` table, `InstallSurface` handler (zip upload + asset
+extraction), `ListEnabledSurfaces` API. What's missing is the runtime
+route mounting, template rendering, and frontend nav integration.
+
+**Problem: Hardcoded surface conditionals in `base.html`**
+
+Lines 61–66 and 118–122 of `base.html` use `{{if eq .Surface "chat"}}`
+chains. Extension surfaces fall through to "Unknown surface". Same
+pattern for CSS includes (line 25–26) and script includes (118–122).
+
+**Solution: Generic extension surface template + dynamic blocks**
+
+```
+base.html changes:
+ {{if eq .Surface "chat"}}...
+ {{else if eq .Surface "admin"}}...
+ ...
+ {{else if .Manifest}}{{template "surface-extension" .}}
+ {{else}}
Unknown surface: {{.Surface}}
+ {{end}}
+```
+
+New template `templates/surfaces/extension.html`:
+```html
+{{define "surface-extension"}}
+
+ {{/* Extension JS mounts into this container */}}
+
+
+{{end}}
+
+{{define "css-extension"}}
+{{/* Extension CSS loaded dynamically from surfacesDir */}}
+{{if .Manifest}}
+
+{{end}}
+{{end}}
+
+{{define "scripts-extension"}}
+{{if .Manifest}}
+
+{{end}}
+{{end}}
+```
+
+**Dynamic route registration at startup:**
+
+```go
+// pages.go — New() after registerCoreSurfaces():
+func (e *Engine) loadExtensionSurfaces() {
+ if e.stores.Surfaces == nil {
+ return
+ }
+ ctx := context.Background()
+ surfaces, err := e.stores.Surfaces.List(ctx)
+ if err != nil {
+ log.Printf("[pages] Failed to load extension surfaces: %v", err)
+ return
+ }
+ for _, sr := range surfaces {
+ if sr.Source == "core" {
+ continue // Core surfaces already registered
+ }
+ route, _ := sr.Manifest["route"].(string)
+ if route == "" {
+ route = "/s/" + sr.ID // Default to /s/:id
+ }
+ manifest := SurfaceManifest{
+ ID: sr.ID,
+ Route: route,
+ Title: sr.Title,
+ Template: "surface-extension",
+ Auth: authFromManifest(sr.Manifest), // default "authenticated"
+ Layout: layoutFromManifest(sr.Manifest),
+ Source: "extension",
+ }
+ e.surfaces = append(e.surfaces, manifest)
+ }
+ log.Printf("[pages] Loaded %d extension surfaces from registry",
+ len(surfaces) - countCore(surfaces))
+}
+```
+
+**nginx config — serve extension static assets:**
+
+```nginx
+# Extension surface assets
+location /surfaces/ {
+ alias /data/surfaces/;
+ expires 1h;
+ add_header Cache-Control "public, immutable";
+}
+```
+
+**Frontend nav integration:**
+
+`ListEnabledSurfaces` API already returns route + title for all enabled
+surfaces. The sidebar/nav needs to render extension surfaces as
+additional items. Extension surfaces appear in a "Surfaces" section
+of the sidebar, below the core nav items.
+
+```js
+// app.js or pages.js — on init:
+const surfaces = await App.api.get('/api/v1/surfaces');
+surfaces.filter(s => !['chat','admin','settings','editor','notes'].includes(s.id))
+ .forEach(s => renderExtensionNavItem(s));
+```
+
+**Extension surface manifest contract (`.surface` archive):**
+
+```
+my-dashboard.surface (zip)
+├── manifest.json
+├── js/
+│ └── main.js ← entry point, mounts into #extension-mount
+├── css/
+│ └── main.css ← scoped styles
+└── assets/ ← images, fonts, etc.
+```
+
+`manifest.json`:
+```json
+{
+ "id": "my-dashboard",
+ "title": "Dashboard",
+ "route": "/s/dashboard",
+ "auth": "authenticated",
+ "layout": "single",
+ "components": ["chat-pane"],
+ "hooks": ["surface"]
+}
+```
+
+The extension JS receives the standard `ctx` object (same extension API
+from v0.11.0) plus access to platform components:
+
+```js
+// main.js — extension surface entry point
+(function() {
+ const mount = document.getElementById('extension-mount');
+ const manifest = JSON.parse(
+ document.getElementById('extension-surface').dataset.manifest
+ );
+
+ // Full access to platform primitives + components
+ const chatPane = window.ChatPane?.create(mount, { role: 'assist' });
+
+ // Build custom UI
+ mount.innerHTML = 'My Dashboard
';
+})();
+```
+
+**Admin UI additions:**
+
+The admin surfaces section (already exists) gains:
+- Upload button (already wired to `InstallSurface`)
+- Enable/disable toggles (already wired)
+- Uninstall button (already wired to `DeleteSurface`)
+- Route display showing the `/s/:slug` URL
+
+**Deliverables:**
+- [ ] `surface-extension` template (HTML + CSS + scripts blocks)
+- [ ] `base.html` conditional chain extended with `{{if .Manifest}}` fallthrough
+- [ ] `loadExtensionSurfaces()` in page engine
+- [ ] nginx location block for `/surfaces/` static assets
+- [ ] Frontend nav renders extension surfaces from `ListEnabledSurfaces`
+- [ ] Admin surfaces section shows upload/enable/disable/uninstall
+- [ ] Sample `.surface` archive (hello-world dashboard) for testing
+- [ ] CSP nonce propagation for extension scripts
+
+
+### Phase 2: Workflow Engine Polish
+
+Eight items deferred from v0.26.0. All backend infrastructure exists;
+these are wiring, UI, and enforcement.
+
+**Channel header stage indicator + advance/reject controls**
+- Workflow channels show: stage name, step N of M, assignment info
+- Advance / Reject buttons (permission-gated by `workflow.manage`)
+- Renders in the chat header area (same slot as `ai_mode` context banner)
+
+**Stage persona auto-switch in chat UI**
+- On stage transition, chat UI updates the active persona display
+- Model selector reflects the stage persona's bound model
+- System prompt injection already works (v0.26.4); this is FE-only
+
+**Assignment notifications via WebSocket**
+- New workflow assignment → `workflow.assigned` WS event to team members
+- Claim confirmation → `workflow.claimed` WS event to claimer
+- Uses existing notification infrastructure (v0.20.0)
+
+**Round-robin auto-assignment**
+- Per-stage `transition_rules.auto_assign: "round_robin"` in `workflow_stages`
+- On stage entry: query team members, pick least-recently-assigned
+- `workflow_assignments.assigned_to` populated automatically
+- Falls back to unassigned pool if round-robin fails
+
+**`on_complete` workflow chaining**
+- Column exists on `workflows` table (nullable JSONB, v0.26.1)
+- Schema: `{"action": "start_workflow", "target_slug": "...", "data_map": {...}}`
+- On workflow completion: if `on_complete` is non-null, start target workflow
+ with mapped `stage_data` from completed instance
+- Reuses existing `StartInstance()` path
+
+**Workflow retention enforcement**
+- Column exists on `workflows` table (`retention` JSONB, v0.26.1)
+- Background sweep (extend staleness goroutine): completed instances
+ older than `retention.delete_after_days` → hard delete
+- `retention.mode = "archive"` → set `workflow_status = 'archived'`,
+ `ai_mode = 'off'` (already implemented for channel archive)
+
+**Drag-and-drop stage reorder in builder**
+- Backend `PATCH /api/v1/workflows/:id/stages/reorder` already exists
+- Frontend: HTML5 drag events on stage list items in workflow builder
+- Same DnD pattern as channel/folder reorder (v0.23.1)
+
+**Team-scoped workflow management UI**
+- Team admin settings surface gains "Workflows" section
+- Lists workflows owned by the team, quick-edit name/description
+- Links to full builder for stage editing
+- Mirrors the admin-level builder but scoped to `team_id`
+
+**Deliverables:**
+- [ ] Channel header workflow stage indicator component
+- [ ] Advance/reject buttons with permission gate
+- [ ] Stage persona auto-switch in chat UI
+- [ ] `workflow.assigned` / `workflow.claimed` WS events
+- [ ] Round-robin auto-assignment in stage transition handler
+- [ ] `on_complete` chaining trigger in workflow completion path
+- [ ] Retention enforcement in staleness sweep goroutine
+- [ ] Drag-and-drop stage reorder in workflow builder UI
+- [ ] Team settings → Workflows section
+
+
+### Phase 3: Workspace + Editor Debt
+
+Items deferred from v0.21.x that are low-hanging fruit.
+
+- [ ] `.gitignore` respect in workspace indexing (`workspace/indexer.go` — skip paths matching patterns from `.gitignore` in workspace root)
+- [ ] Workspace settings UI: git config section in channel/project settings (remote URL, branch, auto-commit toggle)
+- [ ] Pane state persistence per-user/per-project (save pane layout to `user_settings` JSONB, restore on surface load)
+- [ ] Editor drag-drop file reorder (workspace file tree — reorder via DnD, persist order in workspace metadata)
+
+**Explicitly deferred (not v0.27.0):**
+- User settings git credentials management UI — needs vault-encrypted git credential store (v0.28.0 candidate)
+- Integration tests: clone/commit/push/pull — needs git binary in CI container
+- Article-specific AI tools — needs article surface rebuild
+- Mobile hamburger nav — needs responsive audit pass
+
+**Deliverables:**
+- [ ] `.gitignore` filter in `workspace/indexer.go`
+- [ ] Git config section in workspace/project settings UI
+- [ ] Pane layout persistence in `user_settings`
+- [ ] Editor file tree DnD reorder
+
+
+### ROADMAP Cleanup
+
+Update the ROADMAP itself:
+- [ ] Cross off tool grant enforcement (3 locations)
+- [ ] Move "Deferred to v0.27.0" items under their proper v0.27.0 section
+- [ ] Remove duplicate entries (extension surface routes listed 4× across the file)
+- [ ] Add v0.27.0 section with phase structure
+- [ ] Update dependency graph
+
+---
+
+## v0.27.1 — Tasks Foundation: Service Channels + Scheduler
+
+The core primitive: a channel with no human participant, driven by
+a scheduler.
+
+### Service Channels
+
+`type: 'service'` — a new channel type. Service channels:
+- Have zero human participants (only persona participants)
+- Cannot be joined by users (read-only view for authorized users)
+- Are created by the scheduler or the `task_create` tool
+- Inherit the workflow engine's stage model (optional — simple tasks
+ skip stages entirely)
+- Appear in a "Tasks" sidebar section (collapsed by default)
+
+```sql
+-- No new migration needed — channel type CHECK already allows extension.
+-- Add 'service' to the channel type CHECK constraint:
+ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_type_check;
+ALTER TABLE channels ADD CONSTRAINT channels_type_check
+ CHECK (type IN ('direct','dm','group','channel','workflow','service'));
+```
+
+### Task Definitions
+
+A task is a lightweight scheduled job that creates a service channel
+and runs a completion (or instantiates a workflow).
+
+**`tasks` table:**
+```sql
+CREATE TABLE tasks (
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
+ owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
+ name TEXT NOT NULL,
+ description TEXT,
+ scope TEXT NOT NULL DEFAULT 'personal'
+ CHECK (scope IN ('personal', 'team', 'global')),
+
+ -- What to run
+ task_type TEXT NOT NULL DEFAULT 'prompt'
+ CHECK (task_type IN ('prompt', 'workflow')),
+ persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
+ model_id TEXT, -- explicit model override (nullable)
+ system_prompt TEXT, -- additional system prompt (prepended)
+ user_prompt TEXT, -- the message to send
+ workflow_id TEXT REFERENCES workflows(id) ON DELETE SET NULL,
+ tool_grants JSONB, -- explicit tool allowlist (nullable = inherit all)
+
+ -- Schedule
+ schedule TEXT NOT NULL, -- cron expression (5-field)
+ timezone TEXT NOT NULL DEFAULT 'UTC',
+ is_active BOOLEAN NOT NULL DEFAULT true,
+
+ -- Execution policy
+ max_tokens INTEGER NOT NULL DEFAULT 4096,
+ max_tool_calls INTEGER NOT NULL DEFAULT 10,
+ max_wall_clock INTEGER NOT NULL DEFAULT 300, -- seconds
+ output_mode TEXT NOT NULL DEFAULT 'channel'
+ CHECK (output_mode IN ('channel', 'note', 'webhook')),
+ output_channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
+ webhook_url TEXT,
+
+ -- Provider routing
+ provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
+
+ -- Notifications
+ notify_on_complete BOOLEAN NOT NULL DEFAULT false,
+ notify_on_failure BOOLEAN NOT NULL DEFAULT true,
+
+ -- Bookkeeping
+ last_run_at TIMESTAMPTZ,
+ next_run_at TIMESTAMPTZ,
+ run_count INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_tasks_next_run ON tasks (next_run_at) WHERE is_active = true;
+CREATE INDEX idx_tasks_owner ON tasks (owner_id);
+```
+
+**Two task types:**
+
+1. **Prompt task** (`task_type = 'prompt'`): Create a service channel,
+ send `user_prompt` with `system_prompt` prepended, collect response.
+ Simple, single-turn. Good for: news digest, stock screener, daily
+ standup prep, report generation.
+
+2. **Workflow task** (`task_type = 'workflow'`): Instantiate a workflow
+ in a service channel. Multi-stage, tool-using, potentially long-running.
+ Good for: data pipeline, automated review, research agent.
+
+### Scheduler
+
+Background goroutine (same pattern as compaction scanner, staleness sweep):
+
+```go
+type TaskScheduler struct {
+ stores store.Stores
+ interval time.Duration // poll interval: 30s
+ stop chan struct{}
+}
+
+func (s *TaskScheduler) Run() {
+ ticker := time.NewTicker(s.interval)
+ for {
+ select {
+ case <-ticker.C:
+ s.poll()
+ case <-s.stop:
+ return
+ }
+ }
+}
+
+func (s *TaskScheduler) poll() {
+ // SELECT * FROM tasks WHERE is_active AND next_run_at <= now()
+ // ORDER BY next_run_at LIMIT 10
+ due, _ := s.stores.Tasks.ListDue(ctx)
+ for _, task := range due {
+ go s.execute(task)
+ }
+}
+```
+
+Cron parsing: use `github.com/robfig/cron/v3` for 5-field expressions.
+Compute `next_run_at` after each execution.
+
+### Provider Resolution for Tasks
+
+Task execution needs a provider. Resolution order:
+
+1. Explicit `provider_config_id` on task (user chose a specific provider)
+2. Personal BYOK provider (if `scope = 'personal'` and user has BYOK keys)
+3. Team provider (if `scope = 'team'`)
+4. Global provider (fallback)
+5. Routing policy (if task's model matches a routing policy)
+
+This is the same resolution chain as completions, reusing the existing
+`resolveProvider()` path in `completion.go`.
+
+### Migration
+
+- 026_tasks.sql: `tasks` table, service channel type extension
+
+**Deliverables:**
+- [ ] `tasks` table + store interface + PG/SQLite implementations
+- [ ] `type: 'service'` channel type
+- [ ] `TaskScheduler` background goroutine
+- [ ] Cron expression parsing + `next_run_at` computation
+- [ ] Task execution path (create service channel, run completion)
+- [ ] Provider resolution for task context
+- [ ] Tasks sidebar section (read-only view of service channels)
+
+
+---
+
+## v0.27.2 — Task Execution: Budgets + Admin Controls
+
+### Execution Budgets
+
+Three limits enforced in the task execution path:
+
+1. **`max_tokens`** — total output tokens. Tracked via existing
+ `usage_log` infrastructure. Execution halts mid-stream if exceeded.
+2. **`max_tool_calls`** — total tool invocations per run. Counter
+ incremented in `streamWithToolLoop`. Execution halts on breach.
+3. **`max_wall_clock`** — seconds. `context.WithTimeout` on the
+ execution goroutine. Hard kill on timeout.
+
+Budget breach → task marked as `budget_exceeded` in run history,
+notification to owner.
+
+### Task Run History
+
+**`task_runs` table:**
+```sql
+CREATE TABLE task_runs (
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
+ status TEXT NOT NULL DEFAULT 'running'
+ CHECK (status IN ('running','completed','failed',
+ 'budget_exceeded','cancelled')),
+ started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ completed_at TIMESTAMPTZ,
+ tokens_used INTEGER DEFAULT 0,
+ tool_calls INTEGER DEFAULT 0,
+ wall_clock INTEGER DEFAULT 0, -- seconds
+ error TEXT
+);
+```
+
+### Admin Controls
+
+**Global config keys:**
+- `tasks.enabled` — master kill switch (default: true)
+- `tasks.allow_personal` — whether non-admin users can create personal
+ tasks (default: false). Same pattern as `personas.allow_personal`.
+- `tasks.max_concurrent` — max simultaneous task executions (default: 5)
+- `tasks.default_budget.max_tokens` — default ceiling (overridable per task)
+- `tasks.default_budget.max_tool_calls` — default ceiling
+- `tasks.default_budget.max_wall_clock` — default ceiling (seconds)
+
+**Admin panel — Tasks section:**
+- List all tasks (filterable by owner, team, scope, status)
+- View task run history with budget usage
+- Pause/resume individual tasks
+- Kill running executions
+- Edit default budgets
+- Toggle `tasks.allow_personal`
+
+**Permission integration:**
+- New permission: `tasks.create` — required to create tasks
+- New permission: `tasks.admin` — required to manage others' tasks
+- `Everyone` group gets `tasks.create` if `tasks.allow_personal` is true
+
+**Deliverables:**
+- [ ] Execution budget enforcement (tokens, tool calls, wall clock)
+- [ ] `task_runs` table + store
+- [ ] Budget breach notification to task owner
+- [ ] Global config keys for task admin controls
+- [ ] Admin panel Tasks section (CRUD, history, kill switch)
+- [ ] `tasks.create` and `tasks.admin` permissions
+- [ ] `tasks.allow_personal` toggle (mirrors `personas.allow_personal`)
+
+
+---
+
+## v0.27.3 — Task Chaining: Webhooks + Workflow-to-Workflow
+
+### Completion Webhooks
+
+When a task (or workflow) completes, optionally POST to an external URL:
+
+```go
+type WebhookPayload struct {
+ TaskID string `json:"task_id"`
+ TaskName string `json:"task_name"`
+ Status string `json:"status"`
+ ChannelID string `json:"channel_id"`
+ CompletedAt time.Time `json:"completed_at"`
+ Output string `json:"output"` // last assistant message
+ StageData any `json:"stage_data"` // workflow stage data (if applicable)
+}
+```
+
+- Webhook URL on task or workflow (`webhook_url` column)
+- Retry: 3 attempts with exponential backoff (1s, 5s, 25s)
+- Timeout: 10s per attempt
+- HMAC signature header (`X-Switchboard-Signature`) using a per-task secret
+
+### `task_create` Tool
+
+AI-invocable tool that spawns sub-tasks:
+
+```json
+{
+ "name": "task_create",
+ "description": "Create a new scheduled or one-shot task",
+ "parameters": {
+ "name": "string",
+ "prompt": "string",
+ "schedule": "string (cron or 'once')",
+ "persona": "string (handle, optional)",
+ "max_tokens": "integer (optional)"
+ }
+}
+```
+
+- `RequireWorkflow` or `RequireTeam` predicate (not available in
+ personal chats — prevents runaway task creation)
+- One-shot tasks: `schedule = 'once'`, `next_run_at = now()`
+- Created tasks inherit the parent's scope (team/global)
+- Depth limit: tasks cannot create tasks (no recursive spawning)
+
+### Workflow-to-Workflow Chaining
+
+Wires the `on_complete` column (v0.26.1) into the task system:
+
+- On workflow completion with `on_complete` set: create a one-shot task
+ that instantiates the target workflow with mapped data
+- Data mapping: `data_map` keys in `on_complete` JSONB map source
+ `stage_data` fields to target workflow's initial `stage_data`
+
+**Deliverables:**
+- [ ] Webhook delivery with retry + HMAC signature
+- [ ] `task_create` tool with depth limit
+- [ ] `on_complete` chaining via task system
+- [ ] Webhook secret generation per task/workflow
+
+
+---
+
+## v0.27.4 — Personal Tasks: BYOK Scheduling + User Task UI
+
+The user-facing task experience. Users create personal tasks that run
+against their BYOK providers.
+
+### Use Cases
+
+- **Morning news digest:** Daily at 6am, prompt "Summarize top tech news",
+ output to a personal service channel. Persona: "News Analyst" with
+ `web_search` tool grant.
+- **Stock screener:** Weekdays at market open, prompt "Check my watchlist
+ for unusual pre-market activity", BYOK OpenAI key for cost control.
+- **Weekly project summary:** Friday at 5pm, prompt "Summarize this
+ week's activity across my projects", uses `conversation_search` +
+ `workspace_search` tools.
+- **Daily standup prep:** Weekday mornings, reviews yesterday's notes
+ and generates standup talking points.
+
+### Settings Surface — Tasks Section
+
+New section in user settings (same pattern as BYOK, Personas):
+
+- Task list: name, schedule (human-readable), last run status, next run
+- Create task: name, persona picker, model picker, prompt editor,
+ schedule builder (preset crons + custom), budget overrides
+- Task detail: run history, output channel link, edit, pause/delete
+- BYOK indicator: shows which provider the task will use
+
+### Schedule Builder
+
+Preset schedules + custom cron for power users:
+
+| Preset | Cron |
+|--------|------|
+| Every morning (6am) | `0 6 * * *` |
+| Weekday mornings (8am) | `0 8 * * 1-5` |
+| Every hour | `0 * * * *` |
+| Weekly (Monday 9am) | `0 9 * * 1` |
+| Monthly (1st at midnight) | `0 0 1 * *` |
+| Custom... | user-entered 5-field cron |
+
+Timezone selector defaults to browser timezone.
+
+### BYOK Task Routing
+
+Personal tasks prefer the user's BYOK provider:
+
+1. If task has explicit `provider_config_id` → use it
+2. If user has a BYOK provider config for the task's model → use BYOK
+3. Fall through to team/global provider (if allowed by admin)
+
+Admin can restrict personal tasks to BYOK-only via
+`tasks.personal_require_byok` config key (default: false). When true,
+personal tasks without a BYOK provider fail with a clear error instead
+of falling through to org providers.
+
+### Output Modes
+
+Three output destinations:
+
+1. **Channel** (default): Output goes to a persistent service channel.
+ User views it like a read-only chat. Channel accumulates run outputs
+ over time (scrollable history).
+2. **Note**: Output saved as a channel-scoped note (good for structured
+ data, reports). Note title includes timestamp.
+3. **Webhook**: Output POSTed to external URL (for integration with
+ other tools — Slack, email, etc.).
+
+### Task Sidebar Section
+
+New sidebar section (below Channels, above Chats):
+
+```
+▾ Tasks (collapsible)
+ ◷ Morning News Digest ✓ 6:02am
+ ◷ Stock Screener ✓ 9:30am
+ ◷ Weekly Summary ⏳ Fri 5pm
+ + New task
+```
+
+Click opens the task's output channel. Status indicators: ✓ (last run
+succeeded), ✗ (failed), ⏳ (next run time), ▶ (running now).
+
+**Deliverables:**
+- [ ] Settings → Tasks section (CRUD, schedule builder, budget config)
+- [ ] BYOK provider routing for personal tasks
+- [ ] `tasks.personal_require_byok` config key
+- [ ] Output modes: channel, note, webhook
+- [ ] Tasks sidebar section with status indicators
+- [ ] "Run Now" button for manual trigger
+- [ ] Task output channel read-only view
+
+
+---
+
+## v0.28.0 — Platform Polish (TBD Pull-Forward)
+
+Candidates pulled from the TBD section, prioritized by value and
+dependency readiness.
+
+### Tier 1 — High value, dependencies met
+
+**Virtual scroll for long conversations**
+Conversations with 500+ messages bog down the DOM. Virtual scroll renders
+only visible messages + buffer zone. Prerequisite for heavy task output
+channels.
+
+**KB auto-injection (context-aware)**
+Top-K chunk prepend to system prompt, context budget aware, per-channel
+toggle. Useful for tasks that need domain knowledge without explicit
+`kb_search` tool calls. Latency budgeting required (embedding lookup
+adds ~200ms).
+
+**Helm chart**
+Raw k8s manifests with `${VAR}` substitution → proper Helm chart.
+`values.yaml` for replicas, image tags, ingress, storage, secrets,
+feature flags. Subchart for dev/test Postgres. Target: `helm install
+switchboard ./chart`. Now makes sense because the feature set is
+stabilizing post-tasks.
+
+**Per-provider model preferences**
+`user_model_settings` unique key is `(user_id, model_id)` — same model
+from different providers shares one visibility toggle. Needs
+`provider_config_id` dimension in DB constraint, store, API, and
+frontend `hiddenModels` keying. Natural fit now that BYOK tasks need
+per-provider model selection.
+
+### Tier 2 — Medium value, some design work needed
+
+**Memory compaction**
+Summarize old memories to save context tokens. Confidence decay: reduce
+confidence over time, prune low-confidence entries. Natural extension of
+the memory system (v0.18.0).
+
+**`capability_match` routing policy**
+"Cheapest model with tool_calling" — a new routing policy type. Useful
+for tasks that need tool use but don't need the strongest model.
+
+**New provider types via config file**
+OpenAI-compatible endpoints registrable via YAML config (no Go code).
+Enables users to point at local LLMs (Ollama, vLLM, llama.cpp) without
+a provider code change.
+
+### Tier 3 — Future (v0.29+)
+
+- Desktop app (Tauri) — large scope, independent track
+- Full PWA with offline — needs service worker rewrite
+- Plugin/extension marketplace — needs extension surface routes first (v0.27.0)
+- Starlark/sidecar extension tiers — needs extension surface routes first
+- Git credentials vault store — needs vault extension design
+- Cross-persona memory sharing — needs memory system redesign
+
+
+---
+
+## Design Decision: Task Permission Model
+
+Tasks introduce a new resource type that intersects with multiple
+existing permission boundaries:
+
+| Scope | Who can create | Provider used | Visibility |
+|-------|---------------|---------------|------------|
+| Personal | User (if `tasks.allow_personal`) | User's BYOK or team fallback | Owner only |
+| Team | User with `tasks.create` + team membership | Team provider | Team members |
+| Global | Admin | Global provider | All users (read) |
+
+Personal tasks are the BYOK sweet spot. The admin toggle
+(`tasks.allow_personal`) mirrors the existing `personas.allow_personal`
+pattern — a single boolean in global config, surfaced in the admin
+settings panel.
+
+**Why not just a permission?** The `tasks.create` permission already
+gates the ability. `tasks.allow_personal` is a _policy_ control —
+admins might allow task creation for team tasks but prohibit personal
+tasks to prevent uncontrolled BYOK spending. Two orthogonal knobs.
+
+
+---
+
+## Design Decision: Task vs. Workflow Relationship
+
+Tasks and workflows are related but distinct:
+
+| | Task | Workflow |
+|---|------|---------|
+| Trigger | Cron schedule or `task_create` tool | Human click or task trigger |
+| Participants | Zero humans (service channel) | Humans + AI |
+| Complexity | Single prompt or workflow instantiation | Multi-stage, assignment queue |
+| Output | Channel, note, or webhook | Channel (interactive) |
+| Lifecycle | Recurring (cron) or one-shot | Single instance |
+
+A **prompt task** is simpler than a workflow — it's a scheduled
+completion. A **workflow task** is a task that instantiates a workflow.
+This keeps the task system lean (scheduling + budgets + output routing)
+and the workflow engine rich (stages + assignment + personas).
+
+The `task_type` column makes this explicit. Prompt tasks don't need
+workflows at all — they're a direct scheduler → completion path.
+Workflow tasks reuse the full workflow engine.
+
+
+---
+
+## Resolved Decisions
+
+1. **Task output retention.** Use channel-level retention from v0.26.0.
+ No per-task retention policy needed — the channel's `retention` JSONB
+ handles archive/delete semantics. Notes created by task output survive
+ channel removal (notes are channel-scoped but not cascade-deleted).
+ Storage quotas per user are a future concern (v0.29+ candidate) — not
+ blocking for initial task support.
+
+2. **Concurrent task execution.** Skip with a warning log. If
+ `task_runs` has a row with `status = 'running'` for the task, the
+ scheduler skips that tick. Log: `[scheduler] Skipping task %s — previous
+ run still active`. No queue — if a task consistently overruns its
+ schedule, the user needs to adjust the cron interval or budget.
+
+3. **Task templates.** Ship 3–5 optional starter templates in v0.27.4.
+ Presented as suggestions during task creation ("Start from a
+ template..." option alongside blank task). Not auto-created — user
+ must explicitly choose one. Templates are just pre-filled form values,
+ not persistent DB records.
+
+4. **Task notifications.** Opt-in per task. Two booleans on the `tasks`
+ table: `notify_on_complete` (default false), `notify_on_failure`
+ (default true). Uses existing notification infrastructure (v0.20.0
+ bell + WebSocket push).
diff --git a/docs/EXTENSION-SURFACES.md b/docs/EXTENSION-SURFACES.md
new file mode 100644
index 0000000..e7b1a48
--- /dev/null
+++ b/docs/EXTENSION-SURFACES.md
@@ -0,0 +1,453 @@
+# Extension Surfaces — Authoring Guide
+
+**Version:** v0.27.0+
+**Audience:** Developers building custom surfaces for Chat Switchboard
+**Prerequisite:** Admin access to install surfaces
+
+---
+
+## What Is an Extension Surface?
+
+An extension surface is a custom full-page UI that runs inside the
+Chat Switchboard shell. It gets its own URL (`/s/your-surface`), a
+link in the sidebar, and full access to the platform's authenticated
+API, theme system, and UI components.
+
+Examples of what you can build:
+
+- A monitoring dashboard that polls external APIs
+- A custom form builder for workflow intake
+- A kanban board backed by channel data
+- A cost tracking view using usage data
+- A project timeline visualization
+
+Extension surfaces are installed as `.surface` archives via the admin
+panel. No code changes to the platform are required.
+
+---
+
+## Quick Start
+
+### 1. Create the Archive
+
+A `.surface` file is a zip archive with this structure:
+
+```
+my-dashboard/
+├── manifest.json ← required: surface metadata
+├── js/
+│ └── main.js ← required: entry point script
+└── css/
+ └── main.css ← optional: styles
+```
+
+The top-level directory name must match the `id` in `manifest.json`.
+
+### 2. Write the Manifest
+
+```json
+{
+ "id": "my-dashboard",
+ "title": "My Dashboard",
+ "route": "/s/my-dashboard",
+ "auth": "authenticated",
+ "layout": "single",
+ "version": "1.0.0",
+ "description": "A custom dashboard surface."
+}
+```
+
+| Field | Required | Description |
+|---------------|----------|-------------|
+| `id` | yes | Unique identifier. Lowercase, alphanumeric + hyphens. Must match the archive's top-level directory name. |
+| `title` | yes | Human-readable name shown in the sidebar nav. |
+| `route` | no | URL path. Defaults to `/s/{id}` if omitted. Must start with `/s/`. |
+| `auth` | no | Auth requirement. Only `"authenticated"` is supported currently. Default: `"authenticated"`. |
+| `layout` | no | Layout preset. Only `"single"` is supported currently. Default: `"single"`. |
+| `version` | no | Semver string for your own tracking. Not enforced. |
+| `description` | no | Shown in the admin surfaces panel. |
+
+### 3. Write the Entry Point
+
+`js/main.js` runs after all platform scripts have loaded. Your code
+mounts into the `#extension-mount` container:
+
+```js
+(function() {
+ 'use strict';
+
+ var mount = document.getElementById('extension-mount');
+ if (!mount) return;
+
+ var manifest = window.__MANIFEST__ || {};
+ var user = window.__USER__ || {};
+
+ mount.innerHTML = 'Hello, ' + esc(user.display_name || user.username) + '!
';
+
+ function esc(s) {
+ var el = document.createElement('span');
+ el.textContent = s;
+ return el.innerHTML;
+ }
+})();
+```
+
+### 4. Package and Install
+
+```sh
+zip -r my-dashboard.surface my-dashboard/
+```
+
+Then in the admin panel: **Admin → Surfaces → Upload** and select the
+`.surface` file. The surface is immediately available — no restart
+required. A link appears in the sidebar.
+
+---
+
+## Platform Globals
+
+Your `main.js` runs in the full platform context. These globals are
+available when your script executes:
+
+### Window Injections
+
+| Global | Type | Description |
+|--------|------|-------------|
+| `window.__MANIFEST__` | Object | Your surface's manifest with route, layout, etc. Keys are **lowercase** (JSON serialization from Go). Access as `manifest.id`, `manifest.route`, `manifest.title`. |
+| `window.__USER__` | Object | Authenticated user: `{ username, display_name, role, id, email }`. |
+| `window.__BASE__` | String | URL base path (e.g. `"/dev"` or `""`). Prepend to all internal links. |
+| `window.__VERSION__` | String | Platform version string. |
+| `window.__SURFACE__` | String | Your surface ID. |
+
+### API Module
+
+`API` provides authenticated fetch wrappers. Auth headers and token
+refresh are handled automatically.
+
+```js
+// GET request — returns parsed JSON
+var data = await API._get('/api/v1/channels');
+
+// POST request — body is JSON-serialized automatically
+var result = await API._post('/api/v1/channels', {
+ name: 'my-channel',
+ type: 'channel'
+});
+
+// PUT request
+await API._put('/api/v1/channels/' + id, { name: 'renamed' });
+```
+
+All methods return a Promise that resolves to parsed JSON. On 401,
+tokens are refreshed automatically and the request is retried.
+
+**Important:** The methods are `_get`, `_post`, `_put` — prefixed with
+underscore. There is no `API.get()`.
+
+### UI Module
+
+`UI` provides platform UI primitives:
+
+```js
+// Toast notification — types: 'success', 'error', 'info', 'warning'
+UI.toast('Operation completed', 'success');
+UI.toast('Something went wrong', 'error');
+
+// Navigate to settings
+UI.openSettings('general');
+```
+
+### Theme Module
+
+`Theme` manages dark/light mode:
+
+```js
+// Current resolved theme: 'dark' or 'light'
+var mode = Theme.resolved();
+
+// Check via DOM attribute
+var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
+```
+
+Your CSS should use CSS custom properties (see below) rather than
+querying Theme directly. Properties update automatically on theme change.
+
+### Events Module
+
+`Events` is the platform event bus (WebSocket-backed):
+
+```js
+// Subscribe to events
+Events.on('chat.message.created', function(data) {
+ console.log('New message:', data);
+});
+
+// Emit local-only events
+Events.emit('my-surface.updated', { key: 'value' });
+```
+
+### ChatPane Component
+
+If your surface needs an embedded chat, you can mount a ChatPane:
+
+```js
+var container = document.createElement('div');
+container.style.height = '400px';
+mount.appendChild(container);
+
+ChatPane.create(container, { role: 'assist' });
+```
+
+---
+
+## CSS Custom Properties
+
+Your CSS should use the platform's CSS custom properties for consistent
+theming. These update automatically when the user switches themes.
+
+### Colors
+
+| Property | Dark Default | Light Default | Usage |
+|----------|-------------|---------------|-------|
+| `--bg` | `#0e0e10` | `#f7f7fa` | Page background |
+| `--bg-surface` | `#18181b` | `#ffffff` | Card/panel background |
+| `--bg-raised` | `#222227` | `#eff0f3` | Elevated element background |
+| `--bg-hover` | `#2a2a30` | `#e5e6eb` | Hover state background |
+| `--border` | `#2e2e35` | `#d5d6dc` | Default borders |
+| `--border-light` | `#3a3a42` | `#c2c3cb` | Subtle borders |
+| `--text` | `#e8e8ed` | `#1a1a2e` | Primary text |
+| `--text-2` | `#9898a8` | — | Secondary text |
+| `--text-3` | `#6b6b7b` | — | Tertiary/muted text |
+| `--accent` | `#6c9fff` | — | Accent color (links, highlights) |
+| `--accent-hover` | `#84b0ff` | — | Accent hover state |
+| `--accent-dim` | `rgba(108,159,255,0.12)` | — | Accent tinted background |
+| `--danger` | `#ef4444` | — | Error/destructive actions |
+| `--success` | `#22c55e` | — | Success indicators |
+| `--warning` | `#eab308` | — | Warning indicators |
+
+### Layout
+
+| Property | Default | Usage |
+|----------|---------|-------|
+| `--radius` | `8px` | Default border radius |
+| `--radius-lg` | `12px` | Large border radius |
+| `--font` | `'DM Sans', ...` | Primary font stack |
+| `--mono` | `'JetBrains Mono', ...` | Monospace font stack |
+| `--transition` | `180ms ease` | Default transition timing |
+
+### Example
+
+```css
+.my-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 16px;
+ color: var(--text);
+}
+
+.my-card-subtitle {
+ color: var(--text-2);
+ font-size: 13px;
+}
+
+.my-card:hover {
+ background: var(--bg-hover);
+}
+```
+
+**Do not use** `--bg-secondary`, `--text-secondary`, or
+`--text-tertiary` — these do not exist. Use `--bg-surface`, `--text-2`,
+and `--text-3` respectively.
+
+---
+
+## DOM Structure
+
+When your surface loads, the page DOM looks like this:
+
+```
+
+
+
+
+ ← platform nav
+
+ ← YOUR CONTENT GOES HERE
+
+
+
+
+
+
+
+
+ ... platform scripts ...
+ ← YOUR SCRIPT
+
+```
+
+The `#extension-mount` div uses `flex: 1` and `overflow: auto`, so it
+fills all available vertical space. You can set its content to anything.
+
+---
+
+## Admin API Reference
+
+Surfaces are managed via the admin API. All endpoints require admin
+authentication.
+
+### Install a Surface
+
+```
+POST /api/v1/admin/surfaces/install
+Content-Type: multipart/form-data
+
+file: <.surface or .zip file>
+```
+
+The archive must contain a `manifest.json` with `id` and `title`.
+Static assets (`js/`, `css/`, `assets/`) are extracted to the server's
+storage directory. The surface is registered in the database and
+enabled immediately.
+
+**Response:**
+```json
+{
+ "id": "my-dashboard",
+ "title": "My Dashboard",
+ "source": "extension",
+ "enabled": true
+}
+```
+
+### List All Surfaces
+
+```
+GET /api/v1/admin/surfaces
+```
+
+Returns all surfaces (core + extension) with their enabled state.
+
+### Enable / Disable
+
+```
+PUT /api/v1/admin/surfaces/:id/enable
+PUT /api/v1/admin/surfaces/:id/disable
+```
+
+Disabled surfaces redirect to the chat surface. The sidebar link is
+hidden. Core surfaces (`chat`, `admin`) cannot be disabled.
+
+### Uninstall
+
+```
+DELETE /api/v1/admin/surfaces/:id
+```
+
+Removes the surface registration and cleans up extracted static assets.
+Core surfaces cannot be deleted.
+
+### List Enabled (Non-Admin)
+
+```
+GET /api/v1/surfaces
+```
+
+Returns enabled surfaces with minimal info (id, title, route). Used by
+the sidebar to render nav links. Available to all authenticated users.
+
+---
+
+## Platform CSS Classes
+
+Your extension can use these CSS classes from the platform's
+`primitives.css` without importing anything:
+
+### Buttons
+
+```html
+
+
+
+
+
+```
+
+### Badges
+
+```html
+Default
+Success
+Error
+Warning
+```
+
+### Toast (via JS)
+
+```js
+UI.toast('Message here', 'success'); // green
+UI.toast('Message here', 'error'); // red
+UI.toast('Message here', 'info'); // blue
+UI.toast('Message here', 'warning'); // yellow
+```
+
+---
+
+## Tips
+
+**Always wrap in an IIFE.** Your script shares the global scope with
+platform scripts. Use `(function() { ... })();` to avoid collisions.
+
+**Use `esc()` for user-generated content.** The platform does not
+provide a global escaping function. Define your own:
+
+```js
+function esc(s) {
+ var el = document.createElement('span');
+ el.textContent = s;
+ return el.innerHTML;
+}
+```
+
+**Prepend `__BASE__` to internal links.** The platform may be deployed
+under a path prefix (e.g. `/dev`):
+
+```js
+var base = window.__BASE__ || '';
+link.href = base + '/settings/general';
+```
+
+**Check for globals before using them.** If your surface might be
+previewed outside the platform:
+
+```js
+if (typeof API !== 'undefined' && API._get) {
+ // safe to make API calls
+}
+if (typeof UI !== 'undefined' && UI.toast) {
+ UI.toast('Works!', 'success');
+}
+```
+
+**Size limit:** Archive upload is capped at 50 MB. Only `js/`, `css/`,
+and `assets/` directories are extracted from the archive.
+
+**Cache busting:** Static assets are served with `?v={platform_version}`
+query parameter. When the platform is upgraded, caches are invalidated
+automatically.
+
+---
+
+## Full Example
+
+See the included `hello-dashboard.surface` archive for a complete
+working example that demonstrates:
+
+- Manifest structure
+- Mounting into `#extension-mount`
+- Reading `__MANIFEST__` and `__USER__`
+- Using `UI.toast()` for notifications
+- Using `API._get()` for authenticated requests
+- Using CSS custom properties for theming
+- Proper `esc()` function for XSS safety
diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md
index 4ec5466..836258a 100644
--- a/docs/EXTENSIONS.md
+++ b/docs/EXTENSIONS.md
@@ -298,137 +298,43 @@ tools. The editor extension defines `read_file`, `write_file`,
---
-## 6. Surfaces (Modes)
+## 6. Extension Surfaces
-*Implemented in v0.21.3.*
+*Shipped in v0.27.0.*
-A "mode" is an extension that registers a **surface** — a UI region that
-replaces or augments the default chat area. The surface system preserves
-DOM state across mode switches (critical for CM6 editor instances).
+Extension surfaces are full-page custom UIs installed as `.surface`
+archives and served at `/s/:slug`. They run inside the platform shell
+with access to the authenticated API, theme system, and UI components.
-### 6.1 Surface Regions
+See **[EXTENSION-SURFACES.md](EXTENSION-SURFACES.md)** for the complete
+authoring guide including manifest format, platform API reference, CSS
+custom properties, and a worked example.
-The following `data-surface-region` attributes are present in `index.html`:
+### Summary
-```
-┌─────────────────────────────────────────────┐
-│ sidebar-top │ surface-header │
-│ │ (model-bar) │
-│ mode-selector │ │
-│ (auto-shown │ surface-main │
-│ when ≥2 │ (chatMessages) │
-│ surfaces) │ │
-│ │ │
-│ sidebar-content │ surface-footer │
-│ (search+chats) │ (input-area) │
-│ │ │
-│ sidebar-bottom │ │
-└─────────────────────────────────────────────┘
-```
+- **Archive format:** zip with `manifest.json` + `js/main.js` + optional `css/main.css`
+- **Install:** Admin panel → Surfaces → Upload (no restart required)
+- **Route:** `/s/{id}` — rendered via Go templates, DB lookup at request time
+- **Entry point:** `js/main.js` mounts into `#extension-mount`
+- **Platform access:** `API._get()`, `UI.toast()`, `Theme.resolved()`, `Events.on()`, `ChatPane.create()`
+- **Theming:** CSS custom properties from `variables.css` (`--bg`, `--bg-surface`, `--text`, `--text-2`, `--accent`, etc.)
+- **Admin API:** install, enable/disable, uninstall via `/api/v1/admin/surfaces/*`
-### 6.2 Surface Registration
+### Relationship to Other Extension Types
-**Manifest-based** (planned — extension loader integration):
+Extension surfaces are the **most capable** extension type. They create
+entirely new pages. Other extension types are lighter-weight:
-```json
-{
- "surfaces": [
- {
- "id": "editor",
- "label": "Editor",
- "icon": "code",
- "regions": ["surface-main", "surface-footer", "sidebar-content"],
- "default": false
- }
- ]
-}
-```
+| Type | Creates a page? | Uses |
+|------|----------------|------|
+| **Surface** (`/s/:slug`) | Yes — full page | Custom dashboards, tools, visualizations |
+| **Block renderer** (Hook 3) | No — transforms message content | Mermaid diagrams, KaTeX math, CSV tables |
+| **Tool** (Hook 5) | No — LLM-invocable function | Calculator, web search, file operations |
+| **Post-render** (Hook 3) | No — enhances rendered messages | Syntax highlighting, link previews |
-**Imperative** (implemented):
-
-```js
-Extensions.register({
- id: 'editor-mode',
- init(ctx) {
- ctx.surfaces.register('editor', {
- label: 'Editor',
- icon: 'code',
- regions: ['surface-main', 'surface-footer', 'sidebar-content'],
- activate() {
- ctx.ui.replace('surface-main', this.renderEditor());
- ctx.ui.replace('surface-footer', this.renderEditorInput());
- ctx.ui.replace('sidebar-content', this.renderFileTree());
- },
- deactivate() {
- ctx.ui.restore('surface-main');
- ctx.ui.restore('surface-footer');
- ctx.ui.restore('sidebar-content');
- }
- });
- }
-});
-```
-
-**Region management API** (on `ctx.ui`):
-
-- `ctx.ui.replace(regionId, element)` — saves current children to a
- `DocumentFragment` (detached, not destroyed), inserts new element.
-- `ctx.ui.restore(regionId)` — re-attaches saved children.
-
-This is critical for CM6 — editor instances survive mode switches because
-their DOM nodes are detached (preserving internal state) rather than removed.
-
-**Surface management API** (on `ctx.surfaces`):
-
-- `ctx.surfaces.register(id, opts)` — register a new surface
-- `ctx.surfaces.unregister(id)` — unregister (switches to chat if active)
-- `ctx.surfaces.activate(id)` — switch to a surface
-- `ctx.surfaces.getCurrent()` — returns active surface id
-
-### 6.3 Mode Selector
-
-When extensions register surfaces, a mode selector appears in the sidebar
-(`#modeSelectorWrap`) below the brand/new-chat area. Clicking a mode calls
-`activate()` on that surface and `deactivate()` on the current one.
-
-Events fired:
-```js
-Events.on('surface.activated', ({ surface, previous }) => { ... });
-Events.on('surface.deactivated', ({ surface }) => { ... });
-Events.on('surface.registered', ({ surface }) => { ... });
-Events.on('surface.unregistered', ({ surface }) => { ... });
-```
-
-### 6.4 Core Surface
-
-Chat mode is the default surface. It's not special architecturally — it's
-the surface that's active when no extension surface is selected. Pragmatically
-it stays in core because everything depends on it.
-
-### 6.5 Planned Modes
-
-**Editor Mode** (extension)
-- File tree in sidebar, code editor in main, AI chat in split/overlay.
-- Tools: `read_file`, `write_file`, `search_replace`, `git_status`,
- `git_commit` — browser tools backed by a git provider API.
-- This is ai-editor rebuilt properly: tool calls are server-side, logged,
- token-counted, auditable.
-
-**Article Mode** (extension)
-- Outline in sidebar, rich text editor in main, AI assistant in panel.
-- Tools: `fetch_url`, `summarize_section`, `check_citation`.
-- The conversation is hidden; only the document matters.
-
-**Cluster Manager Mode** (2-3 cooperating extensions)
-- `k8s-manager`: kubectl tools, pod/deployment views.
-- `ceph-manager`: ceph status, OSD management.
-- `node-manager`: SSH commands, system metrics.
-- These talk to each other via EventBus. When `k8s-manager` detects a
- node is NotReady, it publishes `cluster.node.unhealthy`. `ceph-manager`
- subscribes and checks OSD placement.
-- The LLM sees ALL tools from ALL active cluster extensions. "Drain node-3,
- ensure Ceph rebalances, then cordon it" → `kubectl_drain`, `ceph_osd_out`,
- `kubectl_cordon` — each routed to the appropriate extension.
+A single extension can combine multiple types — e.g. a surface for
+configuration plus a tool for LLM interaction plus a renderer for
+custom output formatting.
---
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 79aa408..0f1f93b 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -4,6 +4,8 @@
- [ARCHITECTURE.md](ARCHITECTURE.md) — Core services design, store layer, scope model
- [EXTENSIONS.md](EXTENSIONS.md) — Extension system spec (Browser/Starlark/Sidecar tiers,
manifests, browser tool bridge, surfaces/modes, model roles)
+- [EXTENSION-SURFACES.md](EXTENSION-SURFACES.md) — Extension surface authoring guide
+ (manifest format, platform API, CSS properties, install workflow)
- [CHANGELOG.md](../CHANGELOG.md) — Detailed release notes for all completed versions
**Versioning (pre-1.0):** `0..` — hotfixes use quad: `0.x.y.z`
@@ -152,9 +154,29 @@ v0.26.0 Workflow Engine ✅
human + AI collab, visitor
intake, assignment queue)
│
-v0.27.0 Tasks / Autonomous Agents
+v0.27.0 Debt Clearance
+ (extension surface routes,
+ workflow polish, editor debt)
+ │
+v0.27.1 Tasks Foundation
(service channels, scheduler,
- unattended execution)
+ task definitions, cron)
+ │
+v0.27.2 Task Execution
+ (budgets, admin controls,
+ run history, kill switch)
+ │
+v0.27.3 Task Chaining
+ (webhooks, task_create tool,
+ workflow-to-workflow)
+ │
+v0.27.4 Personal Tasks
+ (BYOK scheduling, user task UI,
+ starter templates)
+ │
+v0.28.0 Platform Polish
+ (virtual scroll, KB auto-inject,
+ Helm chart, provider model prefs)
```
---
@@ -427,28 +449,28 @@ Organized by domain. Pull into a version when the surrounding work
makes them a natural addition.
**Surfaces + Editor**
-- [ ] Extension loader: surfaces from manifest.json (was v0.21.3 — `/s/:slug` route deferred to v0.27.0)
-- [ ] Editor drag-drop file reorder (was v0.21.5)
+- [x] ~~Extension loader: surfaces from manifest.json~~ (v0.27.0 — `/s/:slug` route)
+- [x] ~~Editor drag-drop file reorder~~ (v0.27.0 — workspace debt)
- [ ] Article drag-to-reorder outline sections (was v0.21.6)
- [ ] Article-specific AI tools: suggest_outline, expand_section, check_citations (was v0.21.6)
- [x] ~~PDF export via pandoc~~ (shipped v0.22.4)
- [ ] Mobile: mode selector collapses to hamburger/bottom nav (was v0.21.3)
-- [ ] Pane state persistence per-user/per-project (pane positions saved, not yet persisted across sessions)
+- [x] ~~Pane state persistence per-user/per-project~~ (v0.27.0 — workspace debt)
**Workspace + Git**
-- [ ] Workspace settings UI: git config section in channel/project settings (was v0.21.4)
-- [ ] User settings: git credentials management UI (was v0.21.4)
-- [ ] `.gitignore` respect in workspace indexing (was v0.21.4)
+- [x] ~~Workspace settings UI: git config section in channel/project settings~~ (v0.27.0 — workspace debt)
+- [ ] User settings: git credentials management UI (was v0.21.4 — needs vault extension, v0.28.0+)
+- [x] ~~`.gitignore` respect in workspace indexing~~ (v0.27.0 — workspace debt)
- [ ] Integration tests: clone, commit, push/pull cycle — requires git binary in CI (was v0.21.4)
**Provider Health + Routing**
- [x] ~~`RecordOutcome` for web_search and url_fetch~~ (shipped v0.22.4 — tool health tracking)
- [x] ~~Rate limit tracking per provider config~~ (shipped v0.22.4 — `rate_limit_count` on `provider_health`)
- [x] ~~Auto-disable: mark provider inactive when `down` for N consecutive health windows~~ (shipped v0.22.4)
-- [ ] `capability_match` policy type ("cheapest model with tool_calling")
+- [ ] `capability_match` policy type ("cheapest model with tool_calling") — v0.28.0 candidate
- [ ] Latency-aware routing: track response time percentiles, prefer faster providers
- [ ] Cost-aware routing with budget ceiling per request
-- [ ] New provider types registrable via config file (OpenAI-compatible + custom schema)
+- [ ] New provider types registrable via config file (OpenAI-compatible + custom schema) — v0.28.0 candidate
- [ ] Provider profile editor: key-value config per provider type, preview of effective settings
- [ ] Fallback chain visualizer: drag-to-reorder provider priority per model family
@@ -456,7 +478,8 @@ makes them a natural addition.
- [x] ~~Session cleanup job~~ (shipped v0.26.0 — background goroutine, `SESSION_EXPIRY_DAYS`)
**Tool System**
-- [x] ~~Persona tool grant enforcement~~ (shipped v0.25.0 — second-pass allowlist in completion handler)
+- [x] ~~Persona tool grant enforcement~~ (shipped v0.25.0 — second-pass allowlist in `completion.go:928`)
+- [x] ~~Workflow version snapshot includes tool grants~~ (shipped v0.26.0 — `workflows.go:263`)
---
@@ -739,7 +762,7 @@ See [DESIGN-0.25.0.md](DESIGN-0.25.0.md) for full spec.
- [x] Core surfaces (chat, admin, settings, editor, notes) on manifest pattern
- [x] `window.__PAGE_DATA__` injection from declared `data_requires`
- [x] `surface_registry` table — admin can enable/disable surfaces (Migration 022)
-- [ ] `/s/:slug` route namespace for extension/dynamic surfaces — deferred
+- [ ] `/s/:slug` route namespace for extension/dynamic surfaces — v0.27.0
**Component Extraction** ✅
- [x] FileTree: extracted from `editor-mode.js`, standalone component with Go template partial + JS hydration
@@ -767,11 +790,11 @@ See [DESIGN-0.25.0.md](DESIGN-0.25.0.md) for full spec.
**CSS Decomposition** ✅
- [x] Monolithic `styles.css` → 13 files: variables, layout, primitives, modals, chat, panels, surfaces, splash, pane-container, chat-pane, user-menu, tool-grants, admin-surfaces
-**Persona Tool Grants UI** ✅ (partial — UI only, backend wire deferred)
+**Persona Tool Grants UI** ✅
- [x] Persona create/edit UI: "Tools" section — multi-select checklist grouped by category
- [x] `loadToolList()` fetches from `/api/v1/tools`, `loadToolGrants()` reads per-persona grants
-- [ ] Completion handler applies `GetToolGrants()` as second-pass allowlist — deferred to v0.27.0
-- [ ] Version snapshot includes persona tool grants at snapshot time — deferred to v0.27.0
+- [x] Completion handler applies `GetToolGrants()` as second-pass allowlist (`completion.go:928`)
+- [x] Version snapshot includes persona tool grants at snapshot time (`workflows.go:263`)
**Bug Fixes (v0.25.3–v0.25.4)**
- [x] Theme system mode (system preference → dark fallback)
@@ -792,16 +815,19 @@ See [DESIGN-0.25.0.md](DESIGN-0.25.0.md) for full spec.
**Shared Surface Routes** — partially shipped
- [x] `/admin/:section`, `/settings/:section` — full-page surface routes
-- [ ] `/s/editor/:wsId`, `/s/article/:wsId/:path` — deferred
-- [ ] `/p/:id` — shared project view — deferred
+- [ ] `/s/editor/:wsId`, `/s/article/:wsId/:path` — v0.27.0 (extension surface routes)
+- [ ] `/p/:id` — shared project view — TBD
**Shipped in v0.26.0:**
- [x] Context-Aware Tool System (`ToolContext`, `Require` predicates, `AvailableFor()`)
- [x] `ExecutionContext` extension (`WorkflowID`, `TeamID` fields)
-**Deferred to v0.27.0:**
-- [ ] Tool grant enforcement in completion handler
-- [ ] Extension surface route namespace (`/s/:slug`)
+**Shipped (verified in code audit):**
+- [x] Tool grant enforcement in completion handler (`completion.go:928`)
+- [x] Workflow version snapshot includes tool grants (`workflows.go:263`)
+
+**Moved to v0.27.0:**
+- [x] ~~Extension surface route namespace (`/s/:slug`)~~ → v0.27.0 Phase 1
**Migration:**
- [x] 022_surfaces.sql: `surface_registry` table (UUID PK, unique surface_id, is_enabled, is_core)
@@ -836,7 +862,7 @@ release notes.
- [x] Route registration test (`TestRouteRegistration`)
- [x] Workflow CRUD test (`TestWorkflowCRUD`, `TestWorkflowValidation`)
-**Deferred to v0.27.0:**
+**Moved to v0.27.0 Phase 2 (Workflow Polish):**
- [ ] Team-scoped workflow management UI (team admin settings surface)
- [ ] Drag-and-drop stage reorder in builder (backend supports it, UI is manual)
- [ ] `on_complete` workflow chaining (column exists nullable, not wired)
@@ -844,26 +870,129 @@ release notes.
- [ ] Stage persona auto-switch in chat UI
- [ ] Round-robin auto-assignment
- [ ] Assignment notifications via WebSocket
-- [ ] Extension surface routes (`/s/:slug` namespace)
-- [ ] Persona tool grant enforcement in completion handler
- [ ] Channel header stage indicator + advance/reject controls
+**Moved to v0.27.0 Phase 1:**
+- [ ] Extension surface routes (`/s/:slug` namespace)
+
+**Shipped (verified in code audit):**
+- [x] Persona tool grant enforcement in completion handler (`completion.go:928`)
+
---
-## v0.27.0 — Tasks / Autonomous Agents
+## v0.27.0 — Debt Clearance
-Unattended execution — workflows without a human in the loop.
-Depends on: workflow engine (v0.26.0).
+Extension surface routes, workflow engine polish, workspace/editor debt.
+Depends on: workflow engine (v0.26.0), dynamic surfaces (v0.25.0).
-_(Shifted from v0.23.0 — no content changes, dependency refs updated to v0.26.0)_
+See [DESIGN-0.27.0.md](DESIGN-0.27.0.md) for full spec.
-- [ ] Scheduler + task runner (cron-like triggers for workflow instantiation)
-- [ ] `task_create` tool (AI can spawn sub-workflows)
-- [ ] `type: 'service'` channels: workflow instances with no human participants
-- [ ] Execution budgets: max tokens, max tool calls, max wall-clock per stage
-- [ ] Admin controls for resource limits and kill switches
-- [ ] Completion webhooks (notify external systems when workflow completes)
-- [ ] Workflow chaining: one workflow's completion triggers another workflow's start
+**Phase 1: Extension Surface Routes (`/s/:slug`)**
+- [ ] `surface-extension` Go template (HTML + CSS + scripts blocks)
+- [ ] `base.html` conditional chain extended with `{{if .Manifest}}` fallthrough
+- [ ] `loadExtensionSurfaces()` in page engine (reads extension manifests from DB at startup)
+- [ ] nginx location block for `/surfaces/` static assets
+- [ ] Frontend nav renders extension surfaces from `ListEnabledSurfaces`
+- [ ] Admin surfaces section: upload/enable/disable/uninstall (API already exists)
+- [ ] Sample `.surface` archive (hello-world dashboard) for testing
+- [ ] CSP nonce propagation for extension scripts
+
+**Phase 2: Workflow Engine Polish (v0.26.0 debt)**
+- [ ] Channel header workflow stage indicator + advance/reject controls
+- [ ] Stage persona auto-switch in chat UI
+- [ ] Assignment notifications via WebSocket (`workflow.assigned`, `workflow.claimed`)
+- [ ] Round-robin auto-assignment (per-stage `transition_rules.auto_assign`)
+- [ ] `on_complete` workflow chaining (column exists, wire trigger logic)
+- [ ] Workflow retention enforcement (extend staleness sweep goroutine)
+- [ ] Drag-and-drop stage reorder in workflow builder UI
+- [ ] Team-scoped workflow management UI (team settings → Workflows section)
+
+**Phase 3: Workspace + Editor Debt**
+- [ ] `.gitignore` respect in workspace indexing (`workspace/indexer.go`)
+- [ ] Workspace settings UI: git config section in channel/project settings
+- [ ] Pane state persistence per-user/per-project (save/restore in `user_settings`)
+- [ ] Editor drag-drop file reorder (workspace file tree DnD)
+
+---
+
+## v0.27.1 — Tasks Foundation: Service Channels + Scheduler
+
+Core primitive for autonomous agents: a channel with no human
+participant, driven by a cron scheduler.
+Depends on: v0.27.0 (debt clearance).
+
+- [ ] `tasks` table + store interface + PG/SQLite implementations
+- [ ] `type: 'service'` channel type (no human participants)
+- [ ] `TaskScheduler` background goroutine (poll interval: 30s)
+- [ ] Cron expression parsing (`robfig/cron/v3`) + `next_run_at` computation
+- [ ] Prompt task execution (create service channel, send prompt, collect response)
+- [ ] Workflow task execution (instantiate workflow in service channel)
+- [ ] Provider resolution for task context (BYOK → team → global → routing policy)
+- [ ] Tasks sidebar section (read-only view of service channels)
+- [ ] Migration 026: `tasks` table, `service` channel type
+
+---
+
+## v0.27.2 — Task Execution: Budgets + Admin Controls
+
+Guardrails for unattended execution.
+Depends on: v0.27.1 (task scheduler).
+
+- [ ] Execution budget enforcement: `max_tokens`, `max_tool_calls`, `max_wall_clock`
+- [ ] `task_runs` table + store (run history, status, budget usage)
+- [ ] Budget breach → `budget_exceeded` status + owner notification
+- [ ] Concurrent run skip (if previous run still active, skip with warning)
+- [ ] Global config: `tasks.enabled`, `tasks.allow_personal`, `tasks.max_concurrent`
+- [ ] Default budget ceilings in global config (overridable per task)
+- [ ] `tasks.create` and `tasks.admin` permissions
+- [ ] Admin panel → Tasks section (CRUD, history, kill switch, budget config)
+
+---
+
+## v0.27.3 — Task Chaining: Webhooks + Workflow-to-Workflow
+
+External integration and multi-workflow orchestration.
+Depends on: v0.27.2 (task budgets).
+
+- [ ] Completion webhooks (POST to external URL on task/workflow completion)
+- [ ] Webhook retry (3 attempts, exponential backoff) + HMAC signature
+- [ ] `task_create` tool (AI spawns sub-tasks, `RequireWorkflow` predicate, depth limit)
+- [ ] `on_complete` chaining via task system (completed workflow → one-shot task → target workflow)
+- [ ] Webhook secret generation per task/workflow
+
+---
+
+## v0.27.4 — Personal Tasks: BYOK Scheduling + User Task UI
+
+User-facing task experience. Personal tasks run against BYOK providers.
+Depends on: v0.27.2 (admin controls, `tasks.allow_personal`).
+
+- [ ] Settings → Tasks section (CRUD, schedule builder, budget config)
+- [ ] Schedule builder: preset crons + custom 5-field cron + timezone selector
+- [ ] BYOK provider routing for personal tasks
+- [ ] `tasks.personal_require_byok` config key (prevent fallthrough to org providers)
+- [ ] Output modes: channel (default), note, webhook
+- [ ] Task notification booleans: `notify_on_complete` (default false), `notify_on_failure` (default true)
+- [ ] Tasks sidebar section with status indicators (✓/✗/⏳/▶)
+- [ ] "Run Now" button for manual trigger
+- [ ] 3–5 optional starter templates (news digest, stock screener, standup prep, etc.)
+
+---
+
+## v0.28.0 — Platform Polish
+
+TBD pull-forward: high-value items whose dependencies are met post-tasks.
+
+**Tier 1 — High value, dependencies met:**
+- [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels)
+- [ ] KB auto-injection: top-K chunk prepend, context budget aware, per-channel toggle
+- [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`)
+- [ ] Per-provider model preferences (`provider_config_id` dimension in `user_model_settings`)
+
+**Tier 2 — Medium value:**
+- [ ] Memory compaction: summarize old memories, confidence decay, prune low-confidence
+- [ ] `capability_match` routing policy ("cheapest model with tool_calling")
+- [ ] New provider types registrable via config file (OpenAI-compatible + custom schema)
---
@@ -895,7 +1024,7 @@ based on need.
**UX / Multi-Seat**
- "Group" scope badge on model selector / KB list: show access-source annotation so users see _why_ they have access (global, team, group grant). Requires backend to include `access_source` in list responses.
-- Per-provider model preferences: `user_model_settings` unique key is `(user_id, model_id)` — same model from different providers shares one visibility toggle. Needs `provider_config_id` dimension in DB constraint, store, API, and frontend `hiddenModels` keying. Frontend composite key (`configId:modelId`) already exists in `App.models[].id`.
+- ~~Per-provider model preferences~~ → scheduled v0.28.0. `user_model_settings` unique key is `(user_id, model_id)` — same model from different providers shares one visibility toggle. Needs `provider_config_id` dimension in DB constraint, store, API, and frontend `hiddenModels` keying. Frontend composite key (`configId:modelId`) already exists in `App.models[].id`.
- Git credentials store: vault-encrypted per-user git credentials (similar to BYOK pattern). Git provider abstraction (GitHub, GitLab, Gitea). Clone/pull/push handlers. Natural fit for extension sidecar tier since git operations are long-running.
- Admin settings team/user export: v0.25.4 ships admin-only settings export/import. User export blocked by vault-encrypted BYOK keys (can't round-trip). Team export needs merge-vs-replace semantics for member lists.
@@ -907,7 +1036,7 @@ based on need.
- Sub-projects / nested hierarchy: child inherits parent KBs, system prompt, persona (deferred until usage patterns clarify need vs tags/labels)
**Knowledge Bases — Future**
-- KB auto-injection: top-K chunk prepend to system prompt, context budget aware, per-channel toggle (latency budgeting required)
+- ~~KB auto-injection~~ → scheduled v0.28.0
- Hybrid search: combine vector similarity with full-text `tsvector`, re-rank
- Semantic chunking: embedding-based boundary detection for smarter splits
- HNSW index: better query performance than IVFFlat for large datasets
@@ -917,8 +1046,8 @@ based on need.
(currently uses direct `database.DB.ExecContext` in handler — works but bypasses store layer)
**Memory — Future**
-- Memory compaction: summarize old memories to save context tokens
-- Memory confidence decay: reduce confidence over time, prune low-confidence entries
+- ~~Memory compaction~~ → scheduled v0.28.0
+- ~~Memory confidence decay~~ → scheduled v0.28.0 (combined with compaction)
- Memory export/import: portable memory format across instances
- Cross-persona memory sharing (opt-in): e.g. "coding assistant" can read facts from "project manager"
- Memory analytics: dashboard showing what Personas are learning, memory growth trends
@@ -928,9 +1057,9 @@ based on need.
- ~~Provider health monitoring~~ → v0.22.0 + key rotation
- Multi-tenant SaaS mode
- Plugin/extension marketplace
-- Virtual scroll for long conversations
+- ~~Virtual scroll for long conversations~~ → scheduled v0.28.0
- ~~SQLite backend option (single-user / dev)~~ → v0.17.1
-- **Helm chart.** The k8s/ raw manifests with `${VAR}` substitution have served well but are friction for external adopters and make values management manual. A Helm chart wraps the same backend + frontend deployments with a `values.yaml` (replicas, image tags, ingress host, storage class, secret refs, resource limits, feature flags). Target: `helm install switchboard ./chart` for a fresh cluster, `helm upgrade` for rolling deploys. Subcharts for optional Postgres (for dev/test — prod uses external). Candidate for v0.28+ or a parallel track once the core feature set stabilizes.
+- ~~**Helm chart.**~~ → scheduled v0.28.0
~~**Pane Architecture (Workspace Container)**~~ → shipped in v0.25.0
diff --git a/docs/SURFACES.md b/docs/SURFACES.md
deleted file mode 100644
index c88a426..0000000
--- a/docs/SURFACES.md
+++ /dev/null
@@ -1,350 +0,0 @@
-# Surface Development Guide
-
-**Version:** 0.25.1
-**Audience:** Anyone with admin access to a Chat Switchboard instance
-
----
-
-## Overview
-
-A surface is a full-screen application mode in Chat Switchboard. The platform ships with core surfaces (Chat, Notes, Settings, Admin, Workflow) that cannot be uninstalled. Extension surfaces are uploaded as `.surface` archives via the Admin panel and can be enabled, disabled, and uninstalled at runtime.
-
-This guide covers everything needed to build, package, install, and manage surfaces using only a running instance — no access to the Go codebase required.
-
----
-
-## Quick Start
-
-Create a minimal surface in under 5 minutes:
-
-### 1. Create the files
-
-**manifest.json:**
-```json
-{
- "id": "hello",
- "version": "1.0.0",
- "title": "Hello World",
- "description": "Minimal surface example",
- "route": "/hello",
- "auth": "authenticated",
- "scripts": ["js/hello.js"],
- "styles": ["css/hello.css"],
- "source": "extension"
-}
-```
-
-**js/hello.js:**
-```javascript
-(function() {
- document.addEventListener('DOMContentLoaded', function() {
- if (window.__SURFACE__ !== 'hello') return;
-
- var root = document.getElementById('surfaceRoot');
- if (!root) return;
-
- root.innerHTML =
- '' +
- '
' +
- '
← Back' +
- '
Hello World' +
- '
' +
- '
' +
- '
' +
- '
👋
' +
- '
Hello from a Surface
' +
- '
Waiting for app init…
' +
- '
' +
- '
' +
- '
';
-
- document.addEventListener('sb:ready', function() {
- var el = document.getElementById('helloStatus');
- if (el) {
- el.textContent = 'Logged in as ' + (API.user?.username || 'unknown') +
- ' | ' + (App.models?.length || 0) + ' models loaded';
- }
- }, { once: true });
- });
-})();
-```
-
-**css/hello.css:**
-```css
-/* Empty for minimal example. Real surfaces use CSS classes with var(--*) tokens. */
-```
-
-### 2. Package
-
-```bash
-zip -r hello.surface manifest.json js/ css/
-```
-
-### 3. Install
-
-**Admin → System → Surfaces → + Install Surface**. Upload `hello.surface`.
-
-### 4. Visit
-
-Navigate to `https://your-instance/hello`.
-
----
-
-## How Surface Loading Works
-
-```
-Browser requests /hello
- → Router matches route from surface_registry
- → Auth middleware runs (based on manifest "auth" field)
- → base.html renders with:
- - Common CSS (variables, layout, components)
- - Common JS (api.js, events.js, ui-core.js, all component factories)
- - Your surface CSS and JS from manifest
- - app.js (boot script)
- → DOMContentLoaded fires:
- 1. Your surface JS runs — build DOM, create components
- 2. app.js init() runs — auth, settings, models, WebSocket
- 3. 'sb:ready' CustomEvent fires when init is complete
-```
-
-### Critical: Boot Timing
-
-Your JS runs BEFORE app init completes.
-
-| Available at DOMContentLoaded | Available at sb:ready |
-|---|---|
-| `window.__SURFACE__`, `window.__BASE__` | `App.models`, `App.settings` |
-| All component factories | `API.user`, `API.isAdmin` |
-| DOM ready | `Events` WebSocket connected |
-
-**Rule:** Anything needing models, user info, or settings goes in `sb:ready`:
-
-```javascript
-document.addEventListener('sb:ready', function() {
- // Safe to use App.models, API.user, App.settings
-}, { once: true });
-```
-
----
-
-## manifest.json Reference
-
-| Field | Required | Description |
-|---|---|---|
-| `id` | Yes | Unique identifier. Lowercase, no spaces. |
-| `title` | Yes | Display name in admin panel and nav. |
-| `version` | No | Semver for display. |
-| `description` | No | Short description for admin. |
-| `route` | No | URL pattern. Gin params: `/thing/:id`. Defaults to `/{id}`. |
-| `alt_routes` | No | Additional URL patterns. |
-| `auth` | No | `"authenticated"` (default), `"admin"`, `"session"`, `"public"`. |
-| `scripts` | No | JS files relative to archive root. |
-| `styles` | No | CSS files relative to archive root. |
-| `components` | No | Component IDs used (documentation only). |
-| `source` | No | Always `"extension"` for uploaded surfaces. |
-
----
-
-## .surface Archive Format
-
-```
-my-surface.surface (ZIP file)
-├── manifest.json ← Required
-├── js/
-│ └── my-surface.js ← Boot script(s)
-├── css/
-│ └── my-surface.css ← Styles
-└── assets/ ← Optional: images, fonts
-```
-
-Standard ZIP. `.surface` extension is conventional; `.zip` also accepted.
-
----
-
-## CSS Theme Tokens
-
-Use these instead of hardcoded colors:
-
-```css
-/* Backgrounds */
-var(--bg) var(--bg-secondary) var(--bg-tertiary) var(--bg-raised) var(--bg-hover)
-
-/* Text */
-var(--text) var(--text-2) var(--text-3)
-
-/* Accent */
-var(--accent) var(--accent-dim) var(--accent-hover)
-
-/* Borders */
-var(--border) var(--border-light)
-
-/* Semantic */
-var(--success) var(--warning) var(--danger) var(--purple)
-var(--success-dim) var(--warning-dim) var(--danger-dim) var(--purple-dim)
-
-/* Type & Layout */
-var(--mono) var(--msg-font) var(--radius) var(--radius-lg) var(--transition)
-```
-
----
-
-## Reusable Components
-
-All loaded by `base.html` on every surface. No imports needed.
-
-### UserMenu
-
-```javascript
-// Build DOM into a container
-container.innerHTML =
- '';
-
-// Wire after auth (sb:ready)
-var menu = UserMenu.create({ id: '' });
-menu.setUser(API.user);
-menu.bind({
- onSettings: function() { location.href = (window.__BASE__ || '') + '/settings'; },
- onAdmin: function() { location.href = (window.__BASE__ || '') + '/admin'; },
- onSignout: function() { if (typeof handleLogout === 'function') handleLogout(); },
-});
-menu.showAdmin(API.isAdmin);
-```
-
-### ChatPane
-
-```javascript
-// Build DOM
-slot.innerHTML =
- '';
-
-// Create instance
-var pane = ChatPane.create({
- id: 'x',
- messagesEl: document.getElementById('xChatMessages'),
- inputEl: document.getElementById('xChatInput'),
- sendBtnEl: document.getElementById('xSendBtn'),
- standalone: true,
-});
-pane.showWelcome();
-```
-
-### PaneContainer
-
-```javascript
-PaneContainer.registerPreset('my-layout', {
- type: 'split', direction: 'horizontal',
- sizes: [250, null, 350],
- children: [
- { type: 'leaf', id: 'nav', size: 250, minSize: 150 },
- { type: 'leaf', id: 'main' }, // flex pane (no size)
- { type: 'tabbed', id: 'assist', size: 350, minSize: 200,
- tabs: [{ id: 'chat', label: 'Chat' }, { id: 'tools', label: 'Tools' }]
- },
- ],
-});
-
-var layout = PaneContainer.mount(document.getElementById('body'), 'my-layout');
-var mainEl = layout._panes.get('main').el;
-var chatTab = layout._panes.get('assist').getTabPanel('chat');
-```
-
-**Rules:** One child per split must omit `size` (flex pane). Drag handles cap at 60%. Sizes persist in localStorage.
-
----
-
-## Making API Calls
-
-```javascript
-// CRUD
-var data = await API._get('/api/v1/channels');
-var resp = await API._post('/api/v1/channels', { title: 'New', type: 'direct' });
-await API._put('/api/v1/channels/' + id, { title: 'Updated' });
-await API._del('/api/v1/channels/' + id);
-
-// Streaming (SSE)
-var resp = await API.streamCompletion(channelId, content, model, abortSignal);
-var reader = resp.body.getReader();
-```
-
-Auth tokens and base path are handled automatically.
-
----
-
-## Admin: Surface Management
-
-**Location:** Admin → System → Surfaces
-
-| Action | Description |
-|---|---|
-| **+ Install Surface** | Upload `.surface` or `.zip` archive |
-| **Toggle** | Enable/disable. Disabled surfaces redirect to Chat. |
-| **Uninstall** | Remove extension surface (files + DB entry). |
-
-**Protected:** Chat and Admin toggles are locked — cannot be disabled.
-
-### API
-
-```bash
-# List
-curl -H "$AUTH" "$BASE/api/v1/admin/surfaces"
-
-# Install
-curl -H "$AUTH" -F "file=@hello.surface" "$BASE/api/v1/admin/surfaces/install"
-
-# Disable / Enable
-curl -X PUT -H "$AUTH" "$BASE/api/v1/admin/surfaces/editor/disable"
-curl -X PUT -H "$AUTH" "$BASE/api/v1/admin/surfaces/editor/enable"
-
-# Uninstall
-curl -X DELETE -H "$AUTH" "$BASE/api/v1/admin/surfaces/hello"
-
-# Public: list enabled (for nav)
-curl -H "$AUTH" "$BASE/api/v1/surfaces"
-```
-
----
-
-## Checklist
-
-- [ ] `manifest.json` has `id` and `title`
-- [ ] Boot script checks `window.__SURFACE__` guard
-- [ ] DOM built in JS (no Go template dependency)
-- [ ] Deferred init via `sb:ready`
-- [ ] Topbar: `position: relative; z-index: 20`
-- [ ] All colors use `var(--*)` tokens
-- [ ] Back link: `window.__BASE__ + '/'`
-- [ ] Graceful with 0 models
-- [ ] Archive: only `manifest.json`, `js/`, `css/`, `assets/`
-
----
-
-## Troubleshooting
-
-| Symptom | Cause | Fix |
-|---|---|---|
-| Surface doesn't appear after install | Routes registered at startup | Restart server or toggle disable/enable |
-| "Cannot overwrite core surface" | ID conflicts with chat/notes/etc. | Choose different ID |
-| `API is undefined` | Code runs before app init | Move to `sb:ready` listener |
-| No styles applied | CSS path mismatch | Check manifest `styles` matches archive structure |
-| Flyout hidden behind content | Missing stacking context | Add `z-index: 20` to topbar |
-| Drag handle snaps | v0.25.0 known issue | Sub-pixel rounding; partially mitigated |
diff --git a/hello-dashboard.surface b/hello-dashboard.surface
new file mode 100644
index 0000000..c19cbb4
Binary files /dev/null and b/hello-dashboard.surface differ
diff --git a/nginx.conf b/nginx.conf
index 45e4e1c..cd1ed78 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -73,6 +73,23 @@ server {
location /editor { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location /notes { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location /settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
+ location /w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
+
+ # v0.27.0: Extension surface page routes → backend (Go templates)
+ location /s/ {
+ proxy_pass $backend;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ # v0.27.0: Extension surface static assets (JS, CSS, images)
+ location /surfaces/ {
+ alias /data/surfaces/;
+ expires 1h;
+ add_header Cache-Control "public";
+ }
# Fallback: redirect unknown paths to root
location / {
diff --git a/server/main.go b/server/main.go
index 67282ae..867d1e0 100644
--- a/server/main.go
+++ b/server/main.go
@@ -5,7 +5,9 @@ import (
"encoding/json"
"fmt"
"log"
+ "net/http"
"os"
+ "path/filepath"
"strings"
"time"
@@ -1049,6 +1051,25 @@ func main() {
// Server-rendered pages via Go templates. Runs alongside the SPA.
// The chat surface is a bridge: Go renders the shell, existing JS
// builds the DOM inside it — identical behavior to index.html.
+
+ // v0.27.0: Extension surface static assets (JS, CSS, images).
+ // Served without auth — same rationale as extension assets (script tags can't send headers).
+ // In split deployment, nginx serves these from /data/surfaces/ directly.
+ if cfg.StoragePath != "" {
+ surfacesStaticDir := cfg.StoragePath + "/surfaces"
+ base.GET("/surfaces/:id/*path", func(c *gin.Context) {
+ id := c.Param("id")
+ filePath := c.Param("path")
+ // Security: prevent path traversal
+ clean := filepath.Clean(filepath.Join(surfacesStaticDir, id, filePath))
+ if !strings.HasPrefix(clean, filepath.Clean(surfacesStaticDir)) {
+ c.Status(http.StatusForbidden)
+ return
+ }
+ c.File(clean)
+ })
+ }
+
pages.SetVersion(Version)
pageEngine := pages.New(cfg, stores)
@@ -1105,7 +1126,7 @@ func main() {
}
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
log.Printf(" Health: provider tracking active (flush=%s, prune=%s)", health.FlushInterval, health.PruneAge)
- log.Printf(" Pages: template engine active (surfaces: chat, editor, notes, settings, admin)")
+ log.Printf(" Pages: template engine active (%d surfaces registered)", len(pageEngine.Surfaces()))
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
diff --git a/server/pages/pages.go b/server/pages/pages.go
index 36aa922..b67e19a 100644
--- a/server/pages/pages.go
+++ b/server/pages/pages.go
@@ -91,6 +91,9 @@ type PageData struct {
// v0.25.0: Enabled surface IDs for conditional nav rendering.
EnabledSurfaces []string `json:"-"`
+ // v0.27.0: Extension surfaces for sidebar nav rendering.
+ ExtensionSurfaces []ExtensionNavItem `json:"-"`
+
// v0.22.7: Login/splash page fields
InstanceName string // branding: instance display name
LogoURL string // branding: custom logo URL
@@ -110,6 +113,52 @@ func (pd PageData) SurfaceEnabled(id string) bool {
return false
}
+// ExtensionNavItem holds the minimal info needed to render an extension
+// surface link in the sidebar nav.
+type ExtensionNavItem struct {
+ ID string
+ Title string
+ Route string
+}
+
+// extensionNavItems returns enabled extension surfaces for sidebar rendering.
+// Queries the DB at render time so newly installed surfaces appear immediately.
+func (e *Engine) extensionNavItems() []ExtensionNavItem {
+ if e.stores.Surfaces == nil {
+ return nil
+ }
+ ctx := context.Background()
+ surfaces, err := e.stores.Surfaces.List(ctx)
+ if err != nil {
+ log.Printf("[pages] Failed to load extension surfaces for nav: %v", err)
+ return nil
+ }
+
+ var items []ExtensionNavItem
+ for _, sr := range surfaces {
+ if sr.Source == "core" || !sr.Enabled {
+ continue
+ }
+ // Editor is Source="extension" but hardcoded as core — skip from dynamic nav
+ if sr.ID == "editor" {
+ continue
+ }
+ route := ""
+ if sr.Manifest != nil {
+ route, _ = sr.Manifest["route"].(string)
+ }
+ if route == "" {
+ route = "/s/" + sr.ID
+ }
+ items = append(items, ExtensionNavItem{
+ ID: sr.ID,
+ Title: sr.Title,
+ Route: route,
+ })
+ }
+ return items
+}
+
// UserContext is the authenticated user's info available to templates.
type UserContext struct {
ID string `json:"id"`
@@ -297,12 +346,74 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
}
e.Render(c, "base.html", PageData{
- Surface: surfaceID,
- Section: section,
- User: user,
- Data: data,
- Manifest: e.GetSurface(surfaceID),
- EnabledSurfaces: e.EnabledSurfaceIDs(),
+ Surface: surfaceID,
+ Section: section,
+ User: user,
+ Data: data,
+ Manifest: e.GetSurface(surfaceID),
+ EnabledSurfaces: e.EnabledSurfaceIDs(),
+ ExtensionSurfaces: e.extensionNavItems(),
+ })
+ }
+}
+
+// RenderExtensionSurface handles /s/:slug — dynamic DB lookup for extension surfaces.
+// No restart required after installing a surface via admin API.
+func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ slug := c.Param("slug")
+ if slug == "" {
+ c.String(http.StatusNotFound, "Surface not found")
+ return
+ }
+
+ if e.stores.Surfaces == nil {
+ c.String(http.StatusNotFound, "Surface registry not available")
+ return
+ }
+
+ // Look up by ID (slug == surface ID)
+ sr, err := e.stores.Surfaces.Get(c.Request.Context(), slug)
+ if err != nil || sr == nil {
+ c.String(http.StatusNotFound, "Surface not found: "+slug)
+ return
+ }
+ if !sr.Enabled {
+ c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/")
+ return
+ }
+ if sr.Source == "core" {
+ // Core surfaces have their own routes — don't serve them here
+ c.String(http.StatusNotFound, "Surface not found: "+slug)
+ return
+ }
+
+ user := e.getUserContext(c)
+
+ // Build manifest from DB record
+ route, _ := sr.Manifest["route"].(string)
+ if route == "" {
+ route = "/s/" + sr.ID
+ }
+ layout, _ := sr.Manifest["layout"].(string)
+ if layout == "" {
+ layout = "single"
+ }
+ manifest := &SurfaceManifest{
+ ID: sr.ID,
+ Route: route,
+ Title: sr.Title,
+ Template: "surface-extension",
+ Layout: layout,
+ Source: "extension",
+ }
+
+ e.Render(c, "base.html", PageData{
+ Surface: sr.ID,
+ User: user,
+ Manifest: manifest,
+ EnabledSurfaces: e.EnabledSurfaceIDs(),
+ ExtensionSurfaces: e.extensionNavItems(),
})
}
}
@@ -341,6 +452,10 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
handler := e.RenderSurface(s.ID)
registerRoutes(group, s, handler)
}
+
+ // v0.27.0: Extension surface catch-all — DB lookup at request time.
+ // No restart needed after installing new surfaces via admin API.
+ group.GET("/s/:slug", e.RenderExtensionSurface())
}
// Admin surfaces — auth + admin role middleware
diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html
index 0ca265b..598b42a 100644
--- a/server/pages/templates/base.html
+++ b/server/pages/templates/base.html
@@ -22,8 +22,13 @@
+
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
{{if eq .Surface "editor"}}{{template "css-editor" .}}{{end}}
+ {{/* v0.27.0: Extension surface CSS — loaded from /surfaces/{id}/css/main.css */}}
+ {{if and .Manifest (eq .Manifest.Source "extension")}}
+
+ {{end}}