# Chat Switchboard — Core Services Architecture **Version:** 0.1 draft **Status:** Design **Companion to:** EXTENSIONS.md (extension system spec) --- ## Overview The extension system (EXTENSIONS.md) defines _how_ new capabilities plug in. This document defines _what the core provides_ — the backend services that exist before any extension is loaded. These are the organs; the extension system is the nervous system. Every service listed here is a **primitive** that multiple features consume. Embeddings serve Knowledge Bases, Notes, and future RAG. Tasks consume completions, tools, and web search. Channels extend the message persistence layer. Nothing here is a leaf feature — everything is foundational. --- ## Terminology These terms have specific meanings throughout this document, EXTENSIONS.md, ROADMAP.md, and the codebase. Using them consistently prevents overloading. ### Core Primitives **Channel** — The universal conversation primitive. Every interaction is a channel: 1:1 chat, group discussion, editor session, task execution. A channel has human members, configured models, and a message stream. What was previously called a "chat" is a channel with `type: 'direct'`. **Message** — A single entry in a channel's stream. Has a participant type (`user`, `model`, `system`) and a participant ID. Replaces the previous `role: 'user'|'assistant'` model to support multiple humans and multiple models unambiguously. **Provider** — An external LLM API service (OpenAI, Anthropic, Venice, OpenRouter, Ollama). Configured via `api_configs` with endpoint, API key, and provider-specific settings. **Model** — A specific LLM available through a provider (e.g., `claude-sonnet-4-20250514` via Anthropic). Has capabilities (tool calling, vision, thinking) and limits (max output tokens, context window). **Extension** — A plugin that adds capabilities. Three tiers: Browser (client JS), Starlark (server sandbox), Sidecar (container). See EXTENSIONS.md. **Surface** — A UI mode registered by an extension. Chat mode is the default surface. Editor mode, Article mode, and Cluster Manager mode are extension-provided surfaces. See EXTENSIONS.md §6. **Tool** — A function the LLM can call. Built-in tools (web_search, note_create, kb_search) ship with core. Extension tools are registered by extensions at any tier. The LLM doesn't know where a tool executes. ### People & Access **User** — An authenticated human account. Has a username, email, and one or more Roles. Belongs to zero or more Teams. **Role** — What a user is allowed to do. Admin-controlled RBAC. Governs _permissions_: which models to use, which KBs to read/write, whether to create tasks, token spending limits, admin delegation. Roles are _vertical_ — about privilege level. A user can have multiple roles (permissions are additive). Examples: - `admin` — full system access, provider management, user management - `developer` — all models, KB read/write, task creation - `viewer` — read-only access to shared channels and KBs - Custom roles defined by admin **Team** — Who a user works with. Organizational scoping. Governs _shared context_: which channels are visible, which KBs are shared, which projects are collaborative. Teams are _horizontal_ — about visibility and collaboration scope. A user can belong to multiple teams. Examples: - `infrastructure` — sees infra channels, cluster KBs, ops projects - `frontend` — sees frontend channels, design KBs, UI projects - `switchboard` — the project team, cross-cutting **The distinction:** A Role says "you're allowed to use GPT-4o." A Team says "you share these channels and this knowledge base with these people." Two developers on different teams have the same permissions but different visibility. ``` Jeff ├── Roles: [admin, developer] ← what he CAN do └── Teams: [switchboard, infrastructure] ← who he works WITH Alice ├── Roles: [developer] ← same model access, no admin └── Teams: [switchboard, frontend] ← different project scope ``` | Resource | Role controls | Team controls | |-----------|-----------------------------|--------------------------------| | Models | Which models you can use | — | | Channels | — | Which channels you see | | KBs | Read/write capability | Which KBs are shared with you | | Projects | — | Which projects you're part of | | Tasks | Whether you can create them | Which task outputs you see | | Admin | Admin panel, delegation | — | | Budgets | Per-role token limits | — | ### Content & Organization **Project** — A workspace that carries configuration: system prompt, default model, attached KBs, team access. Contains channels. Non-nestable. A project is _context_ — it defines how work happens. **Folder** — A hierarchical organizer for channels. Nestable. Pure organization with no configuration. A folder is _structure_ — it defines where things live. **Note** — A user-created document with full-text search, folder organization, and LLM tool integration. Embedded for RAG retrieval. **Knowledge Base (KB)** — A named collection of documents that are chunked, embedded, and queryable via `kb_search`. Attachable to channels and projects. **Task** — A scheduled prompt that runs autonomously. Combines a cron schedule, a model, tools, and an output target. Executes in a `type: 'service'` channel with zero human members. ### Infrastructure **EventBus** — Pub/sub message system. Carries events between browser, server, and WebSocket. Extensions subscribe to events. Channels are bus rooms. **Capability** — A model's feature set: tool calling, vision, thinking, reasoning, max output tokens, context window. Resolved from provider API data, known model table, and heuristic detection. **Compaction** — Automatic context summarization. When a channel's history exceeds the model's context window, a utility LLM condenses older messages. The full history stays in the database. **Message Tree** — The actual structure of conversation history. Messages form a tree via `parent_id`, not a flat list. A linear conversation is a tree with no branches. Edits, regenerations, and forks create branches. See §8. **Branch** — A path through the message tree from root to a leaf. Created by edit-and-resubmit, regeneration, or explicit fork. All branches persist. The UI shows one branch at a time. **Active Path** — The branch currently being viewed and used for context assembly. Tracked per-user per-channel via `channel_cursors`. The completion handler sends only the active path to the LLM. **Cursor** — A per-user pointer to the leaf message of their active branch in a channel. Stored in `channel_cursors`. ### Deployment & Auth **Auth Mode** — How the backend resolves user identity. Three strategies, selected by `AUTH_MODE` environment variable: `builtin` (app-owned JWT), `mtls` (proxy-injected cert headers), `oidc` (Keycloak/external IdP). The internal user model is the same regardless of auth mode. **Identity Header** — In mTLS mode, the reverse proxy validates the client certificate and injects identity via HTTP headers (e.g., `X-SSL-Client-S-DN`, `X-Forwarded-Client-Cert`). The backend trusts these headers and extracts user identity from them. **OIDC (OpenID Connect)** — Standard protocol for delegated authentication. Keycloak, Okta, Azure AD, etc. The backend is a relying party — it validates tokens against the IdP's JWKS endpoint and extracts claims (email, groups, roles). **Environment Banner** — Thin header/footer bar indicating the environment designation. Content area is `100vh - banner_top - banner_bottom`. When no banners are configured, the full viewport is available. Banner text and color are admin-configurable. --- ## Layered Architecture ``` ┌─────────────────────────────────────────────┐ │ Extensions (Browser / Starlark / Sidecar) │ │ Editor, Article, Cluster, custom tools │ ├─────────────────────────────────────────────┤ │ Built-in Tools │ │ web_search, url_fetch, notes, kb_search, │ │ task_create │ ├─────────────────────────────────────────────┤ │ Core Services │ │ Tasks, Channels, Notes, Knowledge Bases, │ │ Embeddings, Folders/Projects │ ├─────────────────────────────────────────────┤ │ Core Infrastructure │ │ Auth, Provider Router, Completion Handler, │ │ EventBus, Extension Loader, Persistence │ ├─────────────────────────────────────────────┤ │ Storage │ │ PostgreSQL + pgvector │ └─────────────────────────────────────────────┘ ``` --- ## 1. Summarize / Compaction **What:** Utility LLM service that condenses long conversations to preserve context within model context windows. **Why core:** Every mode needs it. Chat, editor, article — any conversation that exceeds the context window needs automatic compaction. Tasks that run overnight accumulate context that must be compressed. **Design:** - Backend service, not a user feature. Like garbage collection — it just happens. - Admin configures the compaction model (cheap/fast: Haiku, Flash, Gemini Flash, or a local model via Ollama). - Auto-triggers when conversation token count exceeds a configurable threshold (e.g., 80% of model's max_context). - Produces a compacted summary that replaces older messages in the context window while preserving the full history in the database. - Users can optionally trigger manual compaction. - Admin can delegate compaction controls to users (opt-in via admin setting). **Data model:** ```sql -- Compaction results stored per-chat ALTER TABLE chats ADD COLUMN compaction_summary TEXT; ALTER TABLE chats ADD COLUMN compacted_at TIMESTAMP; ALTER TABLE chats ADD COLUMN compaction_token_count INTEGER; ``` **Admin settings:** - `compaction_model` — which model handles compaction - `compaction_threshold` — % of context window that triggers auto-compact - `compaction_user_enabled` — whether users can trigger manual compaction --- ## 2. Channels (Everything Is a Channel) **What:** The universal conversation primitive. Every interaction — 1:1 chat, group discussion, editor session, task execution — is a channel with participants and a message stream. **Why core:** A "chat" is a channel with one human and one model. A "group chat" is a channel with multiple humans and models. An "editor session" is a channel where edit events are messages. A "task run" is a channel with zero humans and one model. Building these as separate primitives means building context assembly, message persistence, tool routing, and compaction multiple times. Building them as one primitive means building it once. **The Master Rule:** > If a channel has exactly 1 human and 1 model: behaves as chat — > no @mention required, every user message triggers a completion. > Once additional participants are added (human or model): @mention > required for LLM to respond. This is one `if` in the completion handler. The user never configures it. Create a chat → it works like a chat. Add a participant → it works like a channel. Remove them → back to chat behavior. The transition is invisible. ```go func shouldAutoComplete(channel Channel) bool { return len(channel.Models) == 1 && len(channel.HumanMembers) == 1 } ``` **Design:** - **Channel types:** - `direct` — 1:1, what "chat" is today. Created by "New Chat." - `group` — multi-participant. Created explicitly or by adding participants to a direct channel. - `service` — zero humans. Task runner, scheduled jobs, automations. - **Participants are humans. Models are configured resources.** Humans are members with roles and permissions. Models are configured per-channel with system prompts, display names, KB access, and tool permissions. A model is not a "member" — it's a resource that members can invoke. - **@mention routing:** - `@user` → notification (highlight, badge, push). - `@model-display-name` → completion trigger. Backend collects channel history, prepends the model's channel-specific system prompt, fires completion. Response posts as a message from that model. - `@editor` and `@reviewer` can both be Claude Sonnet 4 with different system prompts. The display name is the identity, not the model. - **Context assembly is channel-scoped.** The completion handler loads messages from the channel, applies the resolved model's system prompt and KB context, and fires. Same handler for 1:1 and group. The only difference is the auto-complete check. **Backward compatibility:** The current `/api/v1/chats/*` endpoints become aliases for channel operations filtered to `type = 'direct' AND created_by = $user`. Existing frontend code works unchanged. New channel-aware UI layers on top. **Data model:** ```sql -- Replaces the current "chats" table CREATE TABLE channels ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title VARCHAR(500), type VARCHAR(20) DEFAULT 'direct', -- direct, group, service created_by UUID REFERENCES users(id), system_prompt TEXT, default_model VARCHAR(255), settings JSONB DEFAULT '{}', folder_id UUID REFERENCES folders(id), project_id UUID REFERENCES projects(id), compaction_summary TEXT, compacted_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); -- Human participants CREATE TABLE channel_members ( channel_id UUID REFERENCES channels(id) ON DELETE CASCADE, user_id UUID REFERENCES users(id) ON DELETE CASCADE, role VARCHAR(20) DEFAULT 'member', -- owner, admin, member joined_at TIMESTAMP DEFAULT NOW(), PRIMARY KEY (channel_id, user_id) ); -- Model configurations per channel CREATE TABLE channel_models ( channel_id UUID REFERENCES channels(id) ON DELETE CASCADE, model_id VARCHAR(255) NOT NULL, api_config_id UUID REFERENCES api_configs(id), display_name VARCHAR(100), -- "@editor", "@reviewer" system_prompt TEXT, -- overrides channel default kb_ids UUID[] DEFAULT '{}', -- KBs attached to this model tools TEXT[] DEFAULT '{}', -- enabled tools for this model attention VARCHAR(20) DEFAULT 'mention', -- mention, passive, auto settings JSONB DEFAULT '{}', -- temperature, max_tokens, etc. PRIMARY KEY (channel_id, model_id, COALESCE(display_name, '')) ); -- Messages: tree structure with participant attribution CREATE TABLE messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), channel_id UUID REFERENCES channels(id) ON DELETE CASCADE, parent_id UUID REFERENCES messages(id), -- tree structure (null = root) participant_type VARCHAR(20) NOT NULL, -- 'user', 'model', 'system' participant_id TEXT NOT NULL, -- user UUID or model config ref content TEXT, metadata JSONB DEFAULT '{}', -- tool calls, token counts, etc. deleted_at TIMESTAMP, -- soft delete for branch pruning created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_messages_channel ON messages(channel_id, created_at); CREATE INDEX idx_messages_parent ON messages(parent_id); CREATE INDEX idx_channel_members ON channel_members(user_id); ``` Each channel tracks which branch the user is currently viewing: ```sql -- Per-user active branch tracking CREATE TABLE channel_cursors ( channel_id UUID REFERENCES channels(id) ON DELETE CASCADE, user_id UUID REFERENCES users(id) ON DELETE CASCADE, active_leaf_id UUID REFERENCES messages(id), updated_at TIMESTAMP DEFAULT NOW(), PRIMARY KEY (channel_id, user_id) ); ``` See §8 (Conversation Forking) for the full tree model and operations. ``` **Migration from current schema:** ```sql -- chats → channels ALTER TABLE chats RENAME TO channels; ALTER TABLE channels ADD COLUMN type VARCHAR(20) DEFAULT 'direct'; ALTER TABLE channels RENAME COLUMN user_id TO created_by; -- Every existing chat gets a channel_members row INSERT INTO channel_members (channel_id, user_id, role) SELECT id, created_by, 'owner' FROM channels; -- Every existing chat gets a channel_models row from its model field INSERT INTO channel_models (channel_id, model_id, attention) SELECT id, model, 'auto' FROM channels WHERE model IS NOT NULL; -- Messages: add participant columns and tree structure, backfill from role ALTER TABLE messages RENAME COLUMN chat_id TO channel_id; ALTER TABLE messages ADD COLUMN parent_id UUID REFERENCES messages(id); ALTER TABLE messages ADD COLUMN participant_type VARCHAR(20); ALTER TABLE messages ADD COLUMN participant_id TEXT; ALTER TABLE messages ADD COLUMN deleted_at TIMESTAMP; UPDATE messages SET participant_type = CASE WHEN role = 'user' THEN 'user' WHEN role = 'assistant' THEN 'model' ELSE 'system' END, participant_id = CASE WHEN role = 'user' THEN (SELECT created_by::text FROM channels WHERE id = messages.channel_id) ELSE COALESCE(model, 'unknown') END; -- Backfill parent_id: chain messages linearly by timestamp WITH ordered AS ( SELECT id, channel_id, LAG(id) OVER (PARTITION BY channel_id ORDER BY created_at) AS prev_id FROM messages ) UPDATE messages SET parent_id = ordered.prev_id FROM ordered WHERE messages.id = ordered.id; CREATE INDEX idx_messages_parent ON messages(parent_id); ``` --- ## 3. Notes **What:** User-created documents with full-text search, folder organization, and LLM tool integration. **Why core:** Every mode needs persistent, searchable, user-scoped storage. The editor extension stores files. The article extension stores drafts. The chat mode stores insights. Notes are the universal "user's persistent data" primitive. **Design:** - CRUD with folder hierarchy and tags. - Full-text search via PostgreSQL `tsvector` (zero additional infra). - Markdown content with optional structured metadata (JSONB). - Bidirectional links to chats ("this note was created from chat X"). - Embedded for RAG retrieval (uses the embedding infrastructure from §5). **LLM Tools (built-in):** - `note_create` — create a note with title, content, folder, tags - `note_search` — full-text search across user's notes - `note_update` — update note content (append, replace, or patch) - `note_list` — list notes by folder or tag These are built-in tools available to any model in any mode. The LLM decides when to use them. "Save this as a note" in chat, "Update my project notes" in editor mode, "Add this source to my research" in article mode — all the same tools. **Data model:** ```sql CREATE TABLE notes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(500) NOT NULL, content TEXT, folder_path TEXT DEFAULT '/', -- /projects/switchboard/ tags TEXT[] DEFAULT '{}', metadata JSONB DEFAULT '{}', source_chat_id UUID REFERENCES chats(id), search_vector TSVECTOR, embedding_id UUID, -- links to embeddings table created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_notes_user ON notes(user_id); CREATE INDEX idx_notes_search ON notes USING GIN(search_vector); CREATE INDEX idx_notes_folder ON notes(user_id, folder_path); CREATE INDEX idx_notes_tags ON notes USING GIN(tags); -- Auto-update search vector CREATE TRIGGER notes_search_update BEFORE INSERT OR UPDATE ON notes FOR EACH ROW EXECUTE FUNCTION tsvector_update_trigger(search_vector, 'pg_catalog.english', title, content); ``` --- ## 4. Knowledge Bases **What:** Named collections of documents that get embedded and become queryable by the LLM via RAG. **Why core:** The embedding and retrieval infrastructure isn't just for KBs — notes get embedded, chat history can be embedded, future features will need vector search. KBs are the first _consumer_ of the embedding infrastructure but not the only one. **Design:** - A KB is a named collection with an owner (user or shared). - Documents are uploaded (PDF, DOCX, TXT, MD, HTML), chunked, and embedded. - Admin configures the embedding model globally (OpenAI text-embedding-3-small, a local model, etc.). - Chunking strategy is configurable (fixed-size, recursive, semantic boundaries). - KBs can be attached to chats, channels, or projects — when attached, `kb_search` automatically includes that KB's context. **LLM Tool (built-in):** - `kb_search` — semantic search across one or more knowledge bases. Returns top-k chunks with source attribution. **Data model:** ```sql CREATE TABLE knowledge_bases ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, description TEXT, owner_id UUID REFERENCES users(id), is_shared BOOLEAN DEFAULT false, chunk_strategy VARCHAR(50) DEFAULT 'recursive', chunk_size INTEGER DEFAULT 512, chunk_overlap INTEGER DEFAULT 50, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE kb_documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), kb_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE, filename VARCHAR(500), content_type VARCHAR(100), size_bytes BIGINT, status VARCHAR(20) DEFAULT 'pending', -- pending, processing, ready, error chunk_count INTEGER DEFAULT 0, uploaded_at TIMESTAMP DEFAULT NOW() ); -- See §5 for the embeddings table (shared with Notes) ``` --- ## 5. Embeddings Infrastructure **What:** The shared embedding pipeline that KBs, Notes, and future features all use. **Why core:** Embedding is a horizontal capability. Restricting it to just KBs would mean reimplementing it for notes, chat history search, and anything else that needs semantic retrieval. **Design:** - `embed(text) → vector` — calls the admin-configured embedding model. - `store(vector, source_type, source_id, metadata)` — stores in pgvector. - `search(query_vector, filters) → ranked chunks` — similarity search with source-type filtering. - Admin configures: embedding model, vector dimensions, distance metric. - Async pipeline: embeddings are generated in the background via a worker queue (not blocking the request). **Data model:** ```sql -- Requires: CREATE EXTENSION vector; CREATE TABLE embeddings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), source_type VARCHAR(50) NOT NULL, -- 'kb_chunk', 'note', 'chat_summary' source_id UUID NOT NULL, -- references the source record chunk_index INTEGER DEFAULT 0, -- position within source content TEXT NOT NULL, -- the text that was embedded embedding vector(1536), -- dimension matches model metadata JSONB DEFAULT '{}', -- source filename, page, etc. created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_embeddings_source ON embeddings(source_type, source_id); CREATE INDEX idx_embeddings_vector ON embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); ``` **Admin settings:** - `embedding_model` — which model to use (default: text-embedding-3-small) - `embedding_dimensions` — vector size (default: 1536) - `embedding_api_config_id` — which provider config to use for embedding calls --- ## 6. Folders and Projects **What:** Organizational containers for conversations and related resources. **Why core:** Every mode needs organization. Without it, users drown in flat chat lists. Two types serve different needs: - **Folders** — hierarchical, nestable. Pure organization. A folder contains chats (and other folders). Like filesystem directories. - **Projects** — flat (non-nestable), but carry configuration. A project has a system prompt, default model, attached KBs, and member access. A project is a _workspace_ that happens to contain conversations. **Design:** - Folders are lightweight: just a name, parent_id, and user_id. - Projects carry context: system_prompt, model, KBs, team members. - Chats belong to at most one folder OR one project (not both). - Projects can have shared access (team feature, future). **Data model:** ```sql CREATE TABLE folders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE CASCADE, parent_id UUID REFERENCES folders(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, sort_order INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE projects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), owner_id UUID REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, description TEXT, system_prompt TEXT, default_model VARCHAR(255), settings JSONB DEFAULT '{}', created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE project_knowledge_bases ( project_id UUID REFERENCES projects(id) ON DELETE CASCADE, kb_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE, PRIMARY KEY (project_id, kb_id) ); -- Chats gain optional organization ALTER TABLE chats ADD COLUMN folder_id UUID REFERENCES folders(id); ALTER TABLE chats ADD COLUMN project_id UUID REFERENCES projects(id); ``` --- ## 7. Tasks **What:** Scheduled prompts that run autonomously — a prompt + a schedule + a model + tools + a destination. **Why core:** This is what transforms Switchboard from a chat app into an autonomous agent platform. Tasks consume every other core service: completions, tools (web search, KB search, notes), channels (posting results), and embeddings (updating knowledge). **Design:** - A task is: cron schedule + prompt + model + tool set + output target. - Output targets: note (create/update), channel (post message), webhook (HTTP POST), chat (append to existing conversation). - Tasks run in the backend via a scheduler goroutine. - Each run calls the completion handler with the task's prompt, model, and enabled tools — identical to a user sending a message, but automated. - Task history: every run logged with input, output, tokens used, duration, status. - Tasks can be paused, resumed, edited, deleted. - Admin controls: max concurrent tasks, per-user task limits, allowed models for tasks. **LLM Tool (built-in):** - `task_create` — the LLM can schedule follow-ups: "I'll check on this tomorrow" becomes a real task. Parameters: prompt, schedule, model, output target. **Examples:** - "Check these 5 news sources for AI policy changes every morning, write a summary, post to #ai-news channel." - "Review k8s cluster health every hour, alert to #ops if anomalous." - "Refresh the 'competitor analysis' knowledge base weekly from these URLs." - "Every Friday, summarize this week's chat conversations into project notes." **Data model:** ```sql CREATE TABLE tasks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, description TEXT, prompt TEXT NOT NULL, model VARCHAR(255) NOT NULL, schedule VARCHAR(100) NOT NULL, -- cron expression tools TEXT[] DEFAULT '{}', -- enabled tool names output_type VARCHAR(50) NOT NULL, -- note, channel, webhook, chat output_target TEXT NOT NULL, -- note_id, channel_id, URL, chat_id settings JSONB DEFAULT '{}', -- max_tokens, temperature, etc. is_active BOOLEAN DEFAULT true, last_run_at TIMESTAMP, next_run_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE task_runs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), task_id UUID REFERENCES tasks(id) ON DELETE CASCADE, started_at TIMESTAMP DEFAULT NOW(), completed_at TIMESTAMP, status VARCHAR(20) DEFAULT 'running', -- running, success, error input_tokens INTEGER, output_tokens INTEGER, result TEXT, error TEXT, duration_ms INTEGER ); CREATE INDEX idx_tasks_user ON tasks(user_id, is_active); CREATE INDEX idx_tasks_schedule ON tasks(next_run_at) WHERE is_active = true; CREATE INDEX idx_task_runs ON task_runs(task_id, started_at DESC); ``` --- ## 8. Conversation Forking **What:** Message history is a tree, not a list. Every edit-and-resubmit, every regeneration, every "let me try a different direction" creates a branch. All branches persist. The UI shows one branch at a time. **Why core:** Every LLM interaction involves exploration. Users backtrack, retry, rephrase. The current model (linear message list) forces a choice: keep the old response or destroy it. That's data loss. The tree model preserves everything and makes exploration a first-class operation. **The model:** ``` msg1 (user: "explain TCP") └── msg2 (model: "TCP is a connection-oriented...") ├── msg3a (user: "go deeper on handshake") ← branch A │ └── msg4a (model: "the three-way handshake...") └── msg3b (user: "actually explain UDP instead") ← branch B (edit) └── msg4b (model: "UDP is a connectionless...") └── msg5b (user: "compare their headers") ├── msg6b-v1 (model: "TCP headers have...") ← regen 1 └── msg6b-v2 (model: "the key difference...") ← regen 2 ``` A linear conversation is just a tree with no branches — every node has exactly one child. No special cases. **Schema:** (defined in §2 Channels) ```sql -- Already in the messages table: parent_id UUID REFERENCES messages(id) -- null = root message deleted_at TIMESTAMP -- soft delete -- Per-user cursor (which branch they're viewing): channel_cursors (channel_id, user_id, active_leaf_id) ``` **Operations:** **Edit & resubmit** — User edits message N. The frontend creates a new message with the same `parent_id` as message N (a sibling). Fires a completion from the new message. The old branch remains in the tree. The cursor moves to the new branch's leaf. **Regenerate** — Creates a new model message with the same `parent_id` as the current model response (sibling). Both responses persist. The user picks which to continue from. **Fork** — Explicitly branch from any point. "I want to try a different direction from message #5." Creates a new child of message #5. Both branches coexist. This is just "edit & resubmit" but at an arbitrary point in the tree. **Delete a chain** — Soft delete: `SET deleted_at = NOW()` on a message and all its descendants. The tree structure stays intact for undo. The UI hides soft-deleted messages. Hard prune is a background job that removes orphaned subtrees after a retention period. **Switch branch** — User navigates to a different branch at a fork point. The frontend updates `channel_cursors.active_leaf_id` and redraws the path from root to the new leaf. **Context assembly:** The completion handler sends the **active path**, not all messages in the channel. The path is the chain from root to current leaf, following `parent_id` pointers: ```go func getActivePath(channelID, leafID string) []Message { var path []Message current := leafID for current != "" { msg := loadMessage(current) if msg.DeletedAt != nil { break // stop at soft-deleted boundary } path = append([]Message{msg}, path...) current = msg.ParentID } return path } ``` Branch A's messages never pollute Branch B's context. If you branched because the conversation went wrong, the wrong turn doesn't follow you. **Compaction** is per-path, not per-channel. Each active branch can be compacted independently. Unused branches keep their full history. **Frontend requirements:** - **Branch indicator** — When a message has siblings (same parent_id), show navigation: `← 2/3 →`. Displayed at the fork point, not on every message. - **Tree minimap** (optional, power user) — A collapsible outline showing the full branch structure. Click a branch to switch to it. Shows branch depth, message count per branch, and which branches have model responses. - **Active path highlight** — The current branch is visually distinguished. Sibling branches are accessible but dimmed/collapsed. **What to implement when:** Phase 1: Add `parent_id` to messages table. Backfill existing messages with linear parent chains. All new messages created with proper `parent_id`. Frontend still renders linearly — no branch UI yet. This is the "do it now while it's cheap" step. Phase 2: Edit-and-resubmit creates siblings instead of replacing. Regenerate creates sibling model responses. Branch indicator `← 1/2 →` in the UI. `channel_cursors` table for tracking active branch. Phase 3 (future): Full tree minimap. Explicit fork button. Branch comparison view (diff two branches). Branch merging (take the best parts of two branches into a new path). --- ## 9. Built-in Tools These are the tools that ship with core because multiple modes and services depend on them. They are always available when the model supports tool calling. | Tool | Tier | Description | |---|---|---| | `web_search` | Sidecar | Search provider abstraction (SearXNG, Brave, DuckDuckGo). Returns ranked results with snippets. | | `url_fetch` | Server | Retrieve and extract content from a URL. Used by web_search follow-up, article mode, KB ingestion, tasks. | | `note_create` | Server | Create a note with title, content, folder, tags. | | `note_search` | Server | Full-text + semantic search across user's notes. | | `note_update` | Server | Update note content (append, replace, or structured patch). | | `kb_search` | Server | Semantic search across attached knowledge bases. Returns top-k chunks with source attribution. | | `task_create` | Server | Schedule a new task. The LLM can create its own follow-ups. | **Extension-provided tools** (not core, but expected early extensions): - `read_file`, `write_file`, `search_replace` (Editor mode) - `kubectl_get`, `ceph_status`, `node_ssh` (Cluster manager) - `git_commit`, `git_diff`, `git_push` (Editor + Git) - `fetch_source`, `check_citation` (Article mode) --- ## 10. Implementation Sequence Priority is based on: dependency depth (what blocks other things), solo-user value (Jeff is the first user), and complexity. ### Phase 1: Channel Foundation + Organization **chats → channels migration + message tree + Folders + Projects + banners** - Rename `chats` → `channels`, add `type`, `channel_members`, `channel_models`, `participant_type`/`participant_id` on messages. - Add `parent_id` to messages. Backfill existing messages with linear parent chains. All new messages created with proper `parent_id`. This is the "do it now while it's cheap" step — the tree structure exists in the schema even before the branch UI is built. - Add `channel_cursors` table for per-user active branch tracking. - Backward-compatible: `/api/v1/chats/*` aliases still work. - 1:1 behavior unchanged (auto-complete rule). - Folders + Projects for organization. - Environment banners: CSS custom properties, banner settings in admin, `/api/v1/settings/banners` endpoint, `initBanners()` in frontend. Small, zero-risk, and required before any enterprise deployment. - This is the schema foundation everything else builds on. Do it now while there's one user and a handful of chats. ### Phase 2: Notes + Built-in Tools ✅ **Notes CRUD + note_* tools + tool execution framework** - Enables the LLM to be genuinely useful beyond ephemeral chat. - Requires: tool execution in completion handler (the plumbing for _all_ tools). - This phase builds the tool calling infrastructure that everything else uses. - **Conversation forking UI (done, 0.7.2):** Edit-and-resubmit creates siblings instead of replacing. Regenerate creates sibling model responses. Branch indicator `← 1/2 →` at fork points. Context assembly uses active path, not full channel history. ### Phase 3: Web Search + URL Fetch **web_search + url_fetch tools** - Sidecar or direct HTTP from backend. - Paired with Notes: "research X and save findings to my notes." - Paired with Tasks (future): automated research. ### Phase 4: @mention Routing + Multi-participant **@mention parsing + multi-model channels** - The channel schema exists from Phase 1. This phase adds the routing logic: scan messages for @mentions, resolve against `channel_models`, fire completions. - "Add a model" UI: configure a second model in any channel. - Enables: editor mode (multiple model roles), second opinions, cross-model conversations. ### Phase 5: Embeddings + Knowledge Bases **Embedding pipeline + pgvector + KB CRUD + kb_search tool** - Depends on: tool execution framework (Phase 2). - Notes get embedded too (once pipeline exists). - Admin configures embedding model. - KBs attach to channels via `channel_models.kb_ids`. ### Phase 6: Compaction **Auto-compaction service** - Depends on: utility LLM calling (same as task runner). - Channel-scoped: compacts any channel that exceeds context threshold. - Can be built alongside or after KB (similar backend pattern: background job that calls an LLM). ### Phase 7: Tasks **Scheduler + task runner + task_create tool** - Creates `type: 'service'` channels with no human members. - Depends on: completion handler, tool execution, notes, web search. - The capstone: everything below it combined into autonomous agents. - Admin controls for resource limits. ### Phase 8: Auth Strategy + Roles/Teams + Permissions **Enterprise auth modes + RBAC beyond admin/user** - Auth middleware strategy pattern: `AUTH_MODE` env var selects `builtin`, `mtls`, or `oidc`. All three resolve to the same internal user model. - `auth_source` + `external_id` columns on users table. - mTLS: header trust, auto-provision from cert DN. - OIDC: Keycloak/Okta token validation, claim extraction, role mapping. - WebSocket auth per mode. - Roles: permission grants (model access, KB write, task create, admin delegation, token budgets). - Teams: organizational scoping (channel visibility, project membership, shared KBs). - Multi-user collaboration features on top of the channel foundation. --- ## 11. Admin Control Surface Each core service adds admin settings. The admin settings table (`global_settings`) is already in place. New settings per service: ``` compaction_model — model for auto-compaction compaction_threshold — context % trigger (default: 80) compaction_user_enabled — let users trigger manual compaction embedding_model — model for embeddings embedding_api_config_id — which provider config for embeddings embedding_dimensions — vector size (default: 1536) task_max_concurrent — max simultaneous task runs task_per_user_limit — max tasks per user task_allowed_models — which models can be used in tasks channel_max_members — max users per channel channel_ai_models — which models can be @mentioned websearch_provider — searxng, brave, duckduckgo websearch_endpoint — SearXNG instance URL websearch_api_key — Brave/DDG API key auth_mode — builtin, mtls, oidc oidc_issuer_url — Keycloak realm URL oidc_client_id — OIDC client ID mtls_identity_header — header containing client DN mtls_auto_provision — create users on first cert auth banner_top_text — top banner text (empty = hidden) banner_top_color — background color (#007A33, #0033A0, etc.) banner_top_text_color — text color (#FFFFFF) banner_bottom_text — bottom banner text (empty = hidden) banner_bottom_color — background color banner_bottom_text_color — text color ``` --- ## 12. Authentication Architecture **Design goal:** The application consumes identity — it does not own the auth flow in every deployment. The auth mode is selected by the `AUTH_MODE` environment variable. The backend's internal model is the same regardless: a user ID, email, display name, and set of roles. ### Mode: `builtin` (default) What exists today. The app owns the full auth lifecycle. - Login form → `POST /api/v1/auth/login` → issues JWT access + refresh tokens. - Access token: short-lived (15 min), stateless, validated per-request. - Refresh token: long-lived (7 days), stored in DB, rotated on use. - Password hashing: bcrypt. - User management: admin creates users via admin panel. No changes needed. This is the personal deployment mode. ### Mode: `mtls` The reverse proxy (Istio sidecar, HAProxy, nginx) terminates mTLS, validates the client certificate against a trusted CA chain, and injects identity headers. The backend trusts these headers unconditionally — the proxy is the trust boundary, not the app. **Flow:** ``` Client (CAC/PIV cert) → Proxy (mTLS termination) → Backend │ ├── X-SSL-Client-S-DN: CN=jeff.smith.1234567890,OU=... ├── X-SSL-Client-Verify: SUCCESS └── X-SSL-Client-Cert: (optional, full cert PEM) ``` **Backend behavior:** 1. Auth middleware reads the identity header (`AUTH_MTLS_HEADER`, default: `X-SSL-Client-S-DN`). 2. Parses the DN to extract CN (common name) or a configurable field. 3. Looks up user by `external_id` (the DN or CN). 4. If not found and `AUTH_MTLS_AUTO_PROVISION=true`: creates the user with `auth_source: 'mtls'`, DN as `external_id`, CN as display name. 5. If not found and auto-provision disabled: 403. 6. Sets `user_id` in request context. All downstream handlers are unchanged. **No JWTs issued.** Every request carries the cert. The proxy validates it every time. The backend has no session state to manage. No login page, no refresh tokens, no logout. **Config:** ``` AUTH_MODE=mtls AUTH_MTLS_HEADER=X-SSL-Client-S-DN AUTH_MTLS_VERIFY_HEADER=X-SSL-Client-Verify AUTH_MTLS_AUTO_PROVISION=true AUTH_MTLS_DEFAULT_ROLE=developer ``` **Security:** The identity headers MUST only be trusted if the request came through the proxy. The backend should reject direct connections that bypass the proxy — either via network policy (pod only accepts traffic from proxy sidecar) or by requiring the verify header. ### Mode: `oidc` Keycloak, Okta, Azure AD, or any OIDC-compliant IdP. The user authenticates with the IdP. The app is a relying party. **Flow (Authorization Code):** ``` Browser → /auth/login → redirect to IdP IdP → authenticates user → redirect back with code Browser → /auth/callback?code=... → Backend exchanges code for tokens Backend → validates ID token via JWKS → extracts claims → issues session ``` **Flow (Bearer Token / API):** ``` Client → Authorization: Bearer Backend → validates token against IdP JWKS endpoint Backend → extracts claims (sub, email, groups, roles) ``` **Backend behavior:** 1. Auth middleware reads the `Authorization: Bearer` header. 2. Validates the token signature against the IdP's JWKS endpoint (cached, refreshed periodically). 3. Extracts claims: `sub` (subject), `email`, `preferred_username`, `realm_access.roles` (Keycloak-specific), or configurable claim paths. 4. Looks up user by `external_id` (the `sub` claim). 5. If not found and auto-provision enabled: creates user with `auth_source: 'oidc'`, IdP roles mapped to local roles. 6. Sets `user_id` in request context. **Role mapping:** The IdP's roles/groups can be mapped to local Roles via config. For example: ``` AUTH_OIDC_ROLE_MAP=idp-admin:admin,idp-developer:developer,idp-viewer:viewer ``` Unmapped IdP roles get the default role. **Config:** ``` AUTH_MODE=oidc AUTH_OIDC_ISSUER=https://keycloak.example.com/realms/switchboard AUTH_OIDC_CLIENT_ID=chat-switchboard AUTH_OIDC_CLIENT_SECRET= AUTH_OIDC_REDIRECT_URI=https://switchboard.example.com/auth/callback AUTH_OIDC_SCOPES=openid,profile,email AUTH_OIDC_AUTO_PROVISION=true AUTH_OIDC_DEFAULT_ROLE=viewer AUTH_OIDC_ROLE_MAP=admin:admin,developer:developer AUTH_OIDC_ROLE_CLAIM=realm_access.roles ``` ### Auth Middleware Strategy Pattern ```go func AuthMiddleware(cfg config.Config) gin.HandlerFunc { switch cfg.AuthMode { case "mtls": return mtlsAuth(cfg) case "oidc": return oidcAuth(cfg) default: return builtinJWTAuth(cfg) } } ``` Each strategy resolves to the same result: `c.Set("user_id", userID)`. Everything downstream — handlers, completion, channels, permissions — is auth-mode agnostic. ### User Table Changes ```sql ALTER TABLE users ADD COLUMN auth_source VARCHAR(20) DEFAULT 'builtin'; -- 'builtin', 'mtls', 'oidc' ALTER TABLE users ADD COLUMN external_id TEXT; -- DN for mTLS, 'sub' claim for OIDC ALTER TABLE users ADD COLUMN external_metadata JSONB DEFAULT '{}'; -- full cert DN fields, IdP claims, etc. CREATE UNIQUE INDEX idx_users_external ON users(auth_source, external_id) WHERE external_id IS NOT NULL; ``` Users can exist with `auth_source: 'builtin'` and `external_id: NULL` (current state). Migration is additive — existing users unaffected. ### WebSocket Auth Per Mode - **builtin:** current `?token=` query param approach. - **mtls:** proxy already validated the cert for the WebSocket upgrade request. Backend reads the same identity headers. - **oidc:** `?token=` query param, validated against JWKS (same as HTTP bearer validation). The WebSocket hub doesn't care which mode resolved the identity — it gets `user_id` from the auth middleware like every other handler. --- ## 13. Environment Banners & Layout **What:** Configurable header and footer banners that indicate the environment designation. When present, the content area shrinks to accommodate them. When absent, full viewport. **Why this matters:** In certain deployment environments, every page of every application must display an environment designation banner. This is often a policy requirement. The banners must be consistent, always visible (no scroll), and not interfere with the application's layout. Getting this wrong blocks deployment. ### Design Banners are thin, fixed-position bars. The content area is everything between them. The layout uses CSS custom properties so the math is always correct: ```css :root { --banner-top-h: 0px; --banner-bottom-h: 0px; } /* When banners exist, JS sets the actual heights */ .banner { position: fixed; left: 0; right: 0; height: 28px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; z-index: 9999; user-select: none; } .banner-top { top: 0; } .banner-bottom { bottom: 0; } /* Content area respects banners */ .app-container { position: fixed; top: var(--banner-top-h); bottom: var(--banner-bottom-h); left: 0; right: 0; overflow: hidden; } ``` **No banners configured:** `--banner-top-h` and `--banner-bottom-h` stay at `0px`. The `.app-container` fills the full viewport. The banner DOM elements don't exist. Zero visual or layout impact. **Banners configured:** Backend serves banner config via `GET /api/v1/settings/banners` (public, no auth). The frontend injects the banner elements, sets the CSS variables, and the content area shrinks automatically. ### Standard Banner Presets Admin configures text, background color, and text color. Organizations typically define their own color standards for environment banners. The app doesn't know or care about banner semantics — it just renders the text in the color the admin configured. ### Banner API ```json GET /api/v1/settings/banners { "top": { "text": "ENVIRONMENT LABEL", "background": "#007A33", "color": "#FFFFFF" }, "bottom": { "text": "ENVIRONMENT LABEL", "background": "#007A33", "color": "#FFFFFF" } } ``` Empty response or `null` top/bottom = no banner. ### Frontend Init ```javascript async function initBanners() { try { const resp = await fetch('/api/v1/settings/banners'); const data = await resp.json(); if (data.top) injectBanner('top', data.top); if (data.bottom) injectBanner('bottom', data.bottom); } catch (e) { // No banners — silent. Don't block app load. } } function injectBanner(position, config) { const el = document.createElement('div'); el.className = `banner banner-${position}`; el.textContent = config.text; el.style.backgroundColor = config.background; el.style.color = config.color; document.body.prepend(el); const h = el.offsetHeight + 'px'; document.documentElement.style.setProperty( `--banner-${position}-h`, h ); } ``` Banners load before the app initializes. The CSS variable update causes a single layout reflow — the content area adjusts instantly. ### Admin Settings ``` banner_top_text — empty = no banner banner_top_color — background color banner_top_text_color — text color banner_bottom_text — empty = no banner banner_bottom_color — background color banner_bottom_text_color — text color ``` Setting these to empty strings removes the banners. No restart required — next page load picks up the new config. The banner endpoint is public (no auth required) so it loads before login in OIDC/mTLS flows where the login redirect hasn't happened yet. ### Layout Impact on All Surfaces Every surface/mode must use `.app-container` as its root. This ensures banners work automatically across chat mode, editor mode, article mode, or any future extension surface. Extensions don't need to know about banners — they render inside the container, the container respects the CSS variables. ``` ┌──────────────────────────────────────┐ │ ██ ENVIRONMENT LABEL ███████████████ │ ← banner-top (28px) ├──────────────────────────────────────┤ │ │ │ .app-container │ │ (all surfaces render here) │ │ │ │ │ ├──────────────────────────────────────┤ │ ██ ENVIRONMENT LABEL ███████████████ │ ← banner-bottom (28px) └──────────────────────────────────────┘ ```