51 KiB
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 managementdeveloper— all models, KB read/write, task creationviewer— 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 projectsfrontend— sees frontend channels, design KBs, UI projectsswitchboard— 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).
Classification 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:
-- 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 compactioncompaction_threshold— % of context window that triggers auto-compactcompaction_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.
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.@editorand@reviewercan 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:
-- 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:
-- 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, tagsnote_search— full-text search across user's notesnote_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:
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_searchautomatically 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:
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:
-- 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:
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:
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)
-- 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:
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 (v0.6.x): 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 (v0.7.x+): 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 (v0.6.x)
chats → channels migration + message tree + Folders + Projects + banners
- Rename
chats→channels, addtype,channel_members,channel_models,participant_type/participant_idon messages. - Add
parent_idto messages. Backfill existing messages with linear parent chains. All new messages created with properparent_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_cursorstable 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.
- Classification banners: CSS custom properties, banner settings
in admin,
/api/v1/settings/bannersendpoint,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 (v0.7.x)
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 (v0.7.x): 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 (v0.7.x)
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 (v0.7.x)
@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 (v0.8.x)
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 (v0.8.x)
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 (v0.9.x)
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 (v0.10.x)
Enterprise auth modes + RBAC beyond admin/user
- Auth middleware strategy pattern:
AUTH_MODEenv var selectsbuiltin,mtls, oroidc. All three resolve to the same internal user model. auth_source+external_idcolumns 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:
- Auth middleware reads the identity header (
AUTH_MTLS_HEADER, default:X-SSL-Client-S-DN). - Parses the DN to extract CN (common name) or a configurable field.
- Looks up user by
external_id(the DN or CN). - If not found and
AUTH_MTLS_AUTO_PROVISION=true: creates the user withauth_source: 'mtls', DN asexternal_id, CN as display name. - If not found and auto-provision disabled: 403.
- Sets
user_idin 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 <access_token>
Backend → validates token against IdP JWKS endpoint
Backend → extracts claims (sub, email, groups, roles)
Backend behavior:
- Auth middleware reads the
Authorization: Bearerheader. - Validates the token signature against the IdP's JWKS endpoint (cached, refreshed periodically).
- Extracts claims:
sub(subject),email,preferred_username,realm_access.roles(Keycloak-specific), or configurable claim paths. - Looks up user by
external_id(thesubclaim). - If not found and auto-provision enabled: creates user with
auth_source: 'oidc', IdP roles mapped to local roles. - Sets
user_idin 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=<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
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
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=<jwt>query param approach. - mtls: proxy already validated the cert for the WebSocket upgrade request. Backend reads the same identity headers.
- oidc:
?token=<access_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. Classification 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:
: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
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
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)
└──────────────────────────────────────┘