This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/ARCHITECTURE.md
2026-02-20 22:24:47 +00:00

1350 lines
51 KiB
Markdown

# 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 (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`, 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 (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_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 <access_token>
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=<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=<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. 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)
└──────────────────────────────────────┘
```