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/docs/ICD-API.md
2026-03-04 10:44:42 +00:00

52 KiB

ICD-API — Chat Switchboard Backend API Contract

Version: 0.22.7
Updated: 2026-03-03
Audience: Frontend developers, integrators, API consumers

Organization principle: Each section is one domain — the complete story. Every endpoint that touches Knowledge Bases is in §5, every endpoint that touches Personas is in §4, etc. Admin and user endpoints live together in their domain section. Cross-cutting platform admin (user management, global settings, audit) lives in §16.


Table of Contents

  1. Conventions
  2. Auth
  3. Channels & Conversations
  4. Personas
  5. Knowledge Bases
  6. Notes
  7. Workspaces & Git
  8. Projects
  9. Memory
  10. Providers & Routing
  11. Models & Preferences
  12. Notifications
  13. Extensions
  14. User Profile & Settings
  15. Teams & Access Control
  16. Platform Administration
  17. Export & Utility
  18. WebSocket Protocol
  19. Appendix: Enums

1. Conventions

Base URL

{scheme}://{host}{BASE_PATH}/api/v1

BASE_PATH is empty by default. In path-routed K8s deployments (e.g. /staging/), all routes shift accordingly. The frontend reads BASE_PATH from a <meta> tag injected by the Go template engine.

Authentication

JWT bearer tokens. Every request to a protected or admin route requires:

Authorization: Bearer {access_token}

Access token: HS256 JWT, 15-minute TTL. Claims: user_id, email, role, exp, iat, jti.

Refresh token: Opaque, DB-stored, 7-day TTL. Used to obtain new access tokens without re-login.

WebSocket fallback: ?token={access_token} query parameter when the Authorization header isn't available.

Cookie sync: On every token save/refresh, the frontend writes sb_token as a cookie. Go template page routes read this cookie via AuthOrRedirect middleware for server-rendered surfaces.

Authorization Tiers

Tier Middleware Who
Public none Anyone (health, login, register, public settings)
Authenticated Auth() Any logged-in user
Team Admin RequireTeamAdmin() Admin of the specific team
Platform Admin RequireAdmin() Users with role = "admin"

Error Envelope

All errors return:

{ "error": "human-readable message" }

Standard HTTP status codes: 400 (bad request), 401 (not authenticated), 403 (not authorized), 404 (not found), 409 (conflict), 500 (server error), 502 (upstream provider failure).

Pagination Envelope

Endpoints that paginate return:

{
  "data": [...],
  "page": 1,
  "per_page": 50,
  "total": 142,
  "total_pages": 3
}

Query params: ?page=1&per_page=50. Not all list endpoints paginate — some return { "data": [...] } or a bare array in a named key.

ID Format

All resource IDs are UUIDv4 strings, generated application-side via store.NewID() (Go uuid.New()). This ensures compatibility across both Postgres and SQLite backends.

Timestamps

ISO 8601 with timezone: "2025-06-15T14:30:00Z". Stored as TIMESTAMPTZ (Postgres) or TEXT (SQLite).

Naming Note

The domain concept is Persona (identity + system prompt + model + KB bindings + grant scoping). API routes currently use /personas for historical reasons. A pre-1.0 rename to /personas is planned. This document uses "Persona" for the concept and shows the current route paths as-is.


2. Auth

Four endpoints. No token required for any of them.

Register

POST /auth/register
{
  "username": "jdoe",
  "password": "...",
  "email": "jdoe@example.com",        // optional
  "display_name": "Jane Doe"           // optional
}

Gated by allow_registration policy. Returns same shape as Login.

Login

POST /auth/login
{ "username": "jdoe", "password": "..." }

Response:

{
  "access_token": "eyJ...",
  "refresh_token": "opaque-string",
  "user": {
    "id": "uuid",
    "username": "jdoe",
    "email": "jdoe@example.com",
    "display_name": "Jane Doe",
    "role": "user",
    "avatar_url": "/api/v1/profile/avatar?v=1234",
    "created_at": "...",
    "last_login": "..."
  }
}

Refresh

POST /auth/refresh
{ "refresh_token": "opaque-string" }

Returns new access_token and refresh_token. Old refresh token is invalidated (rotation).

Logout

POST /auth/logout
{ "refresh_token": "opaque-string" }

Revokes the refresh token. Returns { "message": "logged out" }.


3. Channels & Conversations

The channel is the universal conversation container. Messages, tool activity, attachments, and KB bindings all hang off a channel. Today channels are single-user direct chats; the architecture anticipates multi-participant channels for v0.23.0.

3.1 Channel CRUD

List channels — paginated, sorted by last activity.

GET /channels?page=1&per_page=50

Returns pagination envelope. Each channel:

{
  "id": "uuid",
  "user_id": "uuid",
  "title": "My Chat",
  "type": "direct",
  "description": "",
  "model": "claude-sonnet-4-20250514",
  "provider_config_id": "uuid|null",
  "system_prompt": "",
  "project_id": "uuid|null",
  "folder": "string|null",
  "tags": ["tag1", "tag2"],
  "created_at": "...",
  "updated_at": "..."
}

Create channel:

POST /channels
{
  "title": "New Chat",
  "type": "direct",
  "description": "",
  "model": "claude-sonnet-4-20250514",
  "provider_config_id": "uuid",
  "system_prompt": "",
  "folder": null,
  "tags": []
}

On create, if model and provider_config_id are provided, the channel model roster (channel_models) is auto-populated with an initial entry.

Get channel:

GET /channels/:id

Returns single channel object.

Update channel:

PUT /channels/:id

Accepts partial updates — only fields present in the body are changed. Same field set as create.

Delete channel:

DELETE /channels/:id

Cascades: deletes messages, attachments, channel_models, channel_kbs.

3.2 Message Tree

Switchboard uses a tree model for messages, not a flat list. Each message has a parent_id (NULL for root). Editing creates a sibling branch; regeneration creates an alternative sibling. The cursor tracks which child is "active" at each branch point.

Get active path — the primary endpoint for loading chat history:

GET /channels/:id/path

Returns the messages along the active branch from root to leaf, following the cursor at each fork. This is what the UI renders.

{
  "messages": [
    {
      "id": "uuid",
      "channel_id": "uuid",
      "parent_id": "uuid|null",
      "role": "user|assistant|system|tool",
      "content": "...",
      "model": "claude-sonnet-4-20250514|null",
      "provider_config_id": "uuid|null",
      "thinking": "...|null",
      "tool_calls": [...],
      "tool_results": [...],
      "attachments": [...],
      "has_siblings": true,
      "sibling_index": 0,
      "sibling_count": 2,
      "created_at": "..."
    }
  ]
}

List all messages — includes all branches, flat:

GET /channels/:id/messages

Legacy/debug endpoint. Returns every message in the channel regardless of branch. Not used by the standard frontend.

Create message:

POST /channels/:id/messages
{
  "role": "user",
  "content": "Hello",
  "parent_id": "uuid|null",
  "attachments": ["attachment-uuid-1"]
}

Edit message — creates a sibling at the same level:

POST /channels/:id/messages/:msgId/edit
{ "content": "Revised question" }

Creates a new message with the same parent_id as the original. Automatically updates the cursor to point to the new sibling.

Regenerate — creates an alternative assistant response:

POST /channels/:id/messages/:msgId/regenerate
{
  "model": "claude-sonnet-4-20250514",
  "provider_config_id": "uuid"
}

Creates a new empty assistant message as a sibling of msgId, then streams the completion into it. The cursor is updated.

Update cursor — switch to a different branch:

PUT /channels/:id/cursor
{
  "message_id": "uuid",
  "child_id": "uuid"
}

Sets which child is active at the message_id branch point. Subsequent GET /channels/:id/path will follow the new branch.

List siblings — all children of a message's parent:

GET /channels/:id/messages/:msgId/siblings

Returns { "siblings": [...] } with abbreviated message objects (id, role, content preview, created_at).

3.3 Completions & Streaming

The single most complex endpoint. Sends a message to an LLM provider and streams the response via Server-Sent Events.

POST /chat/completions

Request:

{
  "channel_id": "uuid",
  "message": "User's message text",
  "model": "claude-sonnet-4-20250514",
  "provider_config_id": "uuid",
  "persona_id": "uuid|null",
  "parent_id": "uuid|null",
  "tools_enabled": true,
  "tool_ids": ["web_search", "calculator"],
  "attachments": ["attachment-uuid"],
  "extra_body": {}
}

channel_id is required. chat_id is accepted as a deprecated alias (maps to channel_id; frontend no longer sends it — removal planned).

extra_body is a freeform JSON object merged into the provider request (provider-specific parameters like temperature, reasoning, etc.).

Persona resolution: If persona_id is set, the handler loads the persona's system prompt, model, provider_config_id, and KB bindings. Per-message model/provider_config_id override the persona's defaults.

Routing resolution: After config resolution, the routing policy evaluator (evaluateRouting()) may redirect to a different provider based on active policies (see §10.4). The winning provider config's credentials are loaded and used for the actual API call.

SSE Stream Format:

The response is Content-Type: text/event-stream. Events:

data: {"content":"Hello"}

data: {"content":" world"}

data: {"reasoning":"Let me think..."}

event: tool_use
data: {"id":"call_1","name":"web_search","input":{"query":"..."}}

event: tool_result
data: {"id":"call_1","content":"Search results..."}

data: {"content":"Based on the search..."}

event: finish
data: {"message_id":"uuid","model":"claude-sonnet-4-20250514","usage":{"input_tokens":150,"output_tokens":42}}

data: [DONE]
Event event: field Payload
Content delta (unnamed) { "content": "text" }
Reasoning delta (unnamed) { "reasoning": "text" }
Tool invocation tool_use { "id", "name", "input" }
Tool result tool_result { "id", "content" }
Stream complete finish { "message_id", "model", "usage" }
End sentinel (unnamed) [DONE] (literal string)
Error mid-stream error { "error": "message" }

Tool cycle: Tool use and tool result events can repeat up to max_tool_iterations (configured server-side). The handler executes tools, feeds results back to the model, and continues streaming.

Response headers:

X-Switchboard-Provider: providerID/configID
X-Switchboard-Route: policy-name    (when routing policy is active)

List available tools:

GET /tools

Returns { "tools": [...] } where each tool has name, description, parameters (JSON Schema), and category.

3.4 Multi-Model Roster

Channels can have multiple AI models. The channel_models table tracks the roster. @mention in message content routes to specific models.

GET    /channels/:id/models              → { "models": [...] }
POST   /channels/:id/models              ← { "model": "...", "provider_config_id": "...", "display_name": "..." }
PATCH  /channels/:id/models/:modelId     ← { "display_name": "..." }
DELETE /channels/:id/models/:modelId

Each roster entry:

{
  "id": "uuid",
  "channel_id": "uuid",
  "model": "claude-sonnet-4-20250514",
  "provider_config_id": "uuid",
  "display_name": "Claude",
  "is_default": true,
  "created_at": "..."
}

3.5 Channel Utilities

Summarize channel (compaction):

POST /channels/:id/summarize

Triggers conversation summarization via the utility model role. Returns { "message": "summarized", "summary_id": "uuid" }.

Generate title:

POST /channels/:id/generate-title

Uses the utility model to auto-generate a title from the first exchange. Returns { "title": "Generated Title" }.

3.6 Attachments

File attachments are channel-scoped. Upload stores the file in blob storage; a background extraction pipeline processes documents (PDF, DOCX, XLSX, etc.) into searchable text.

Upload:

POST /channels/:id/attachments
Content-Type: multipart/form-data

Field: file. Returns the attachment object.

List by channel:

GET /channels/:id/attachments

Returns { "attachments": [...] }.

Get metadata:

GET /attachments/:id
{
  "id": "uuid",
  "channel_id": "uuid",
  "user_id": "uuid",
  "filename": "report.pdf",
  "content_type": "application/pdf",
  "size": 1048576,
  "storage_key": "attachments/chan-id/att-id_report.pdf",
  "extracted_text_key": "...|null",
  "metadata": {},
  "created_at": "..."
}

Download:

GET /attachments/:id/download

Returns the file with appropriate Content-Type and Content-Disposition headers.

Delete:

DELETE /attachments/:id

4. Personas

A Persona is the unit of access control and AI identity: system prompt + model + provider config + KB bindings + grant scoping. Users interact with Personas, not raw provider configs. Admins curate which Personas are available; team admins create team-scoped Personas.

Three scopes, three route namespaces, one domain:

Scope Routes Who manages
Personal GET/POST/PUT/DELETE /personas The user
Team GET/POST/PUT/DELETE /teams/:teamId/personas Team admin
Global (admin) GET/POST/PUT/DELETE /admin/personas Platform admin

All three return the same Persona object shape:

{
  "id": "uuid",
  "name": "Code Reviewer",
  "description": "Reviews code for bugs and style",
  "system_prompt": "You are a senior code reviewer...",
  "model": "claude-sonnet-4-20250514",
  "provider_config_id": "uuid|null",
  "scope": "personal|team|global",
  "owner_id": "uuid|null",
  "is_default": false,
  "avatar_url": "/api/v1/personas/uuid/avatar?v=123",
  "created_at": "...",
  "updated_at": "..."
}

4.1 Personal Personas

Gated by allow_user_personas policy.

GET    /personas                     → { "personas": [...], "personas": [...] }
POST   /personas                     ← { "name", "description", "system_prompt", "model", ... }
PUT    /personas/:id                 ← partial update
DELETE /personas/:id

Note: List response currently sends both "personas" and "personas" keys (identical arrays) for backward compatibility. The "personas" key is what the frontend reads. Dual keys will be collapsed pre-1.0.

4.2 Team Personas

GET    /teams/:teamId/personas       → { "personas": [...], "personas": [...] }
POST   /teams/:teamId/personas       ← same shape
PUT    /teams/:teamId/personas/:id
DELETE /teams/:teamId/personas/:id

4.3 Admin (Global) Personas

GET    /admin/personas                → { "personas": [...], "personas": [...] }
POST   /admin/personas                ← same shape
PUT    /admin/personas/:id
DELETE /admin/personas/:id

4.4 Persona KB Bindings

A Persona can have Knowledge Bases bound to it. When a channel uses that Persona, the bound KBs are automatically scoped into kb_search tool calls. Users never see or manage the underlying KBs directly — they talk to the Persona.

Get bindings:

GET /personas/:id/knowledge-bases                → { "data": [KB objects] }
GET /teams/:teamId/personas/:id/knowledge-bases  → { "data": [...] }
GET /admin/personas/:id/knowledge-bases          → { "data": [...] }

Set bindings (replace all):

PUT /personas/:id/knowledge-bases
PUT /teams/:teamId/personas/:id/knowledge-bases
PUT /admin/personas/:id/knowledge-bases
{
  "kb_ids": ["kb-uuid-1", "kb-uuid-2"],
  "auto_search": {
    "kb-uuid-1": true,
    "kb-uuid-2": false
  }
}

auto_search: when true, the KB is always searched (prepended to context). When false, it's available via the kb_search tool but not auto-injected.

4.5 Persona Avatars

POST   /personas/:id/avatar          ← multipart/form-data (field: "avatar")
DELETE /personas/:id/avatar
POST   /admin/personas/:id/avatar
DELETE /admin/personas/:id/avatar

4.6 Resource Grants

See §15.3 for the grant system. Personas use grants to control who can see and use them beyond their base scope.


5. Knowledge Bases

The Knowledge Base (KB) is the RAG system: upload documents → chunk → embed (pgvector / app-level cosine for SQLite) → search via kb_search tool during completions.

5.1 KB CRUD

GET    /knowledge-bases             → { "data": [KB objects] }
POST   /knowledge-bases             ← { "name", "description", "scope", "team_id" }
GET    /knowledge-bases/:id         → KB object
PUT    /knowledge-bases/:id         ← partial update
DELETE /knowledge-bases/:id

KB object:

{
  "id": "uuid",
  "name": "Product Docs",
  "description": "Internal product documentation",
  "scope": "personal|team|global",
  "owner_id": "uuid|null",
  "team_id": "uuid|null",
  "discoverable": true,
  "embedding_model": "text-embedding-3-small",
  "chunk_size": 512,
  "chunk_overlap": 50,
  "document_count": 12,
  "created_at": "...",
  "updated_at": "..."
}

5.2 Documents

Upload:

POST /knowledge-bases/:id/documents
Content-Type: multipart/form-data

Field: file. Supported: PDF, DOCX, TXT, MD, HTML, CSV, XLSX, PPTX. Returns the document object with status: "pending".

List:

GET /knowledge-bases/:id/documents

Returns { "data": [document objects] }.

Status (poll during processing):

GET /knowledge-bases/:id/documents/:docId/status

Status progression: pending → chunking → embedding → ready | error.

Delete:

DELETE /knowledge-bases/:id/documents/:docId
POST /knowledge-bases/:id/search
{
  "query": "How do I configure SSO?",
  "limit": 5
}

Returns { "results": [{ "content", "score", "document_id", "metadata" }] }.

5.4 Rebuild

Re-chunks and re-embeds all documents:

POST /knowledge-bases/:id/rebuild

5.5 Channel KB Bindings

A channel can have KBs explicitly bound to it (in addition to any KBs coming from the active Persona or parent Project).

Get:

GET /channels/:id/knowledge-bases

Returns { "data": [ChannelKB objects] }:

{
  "kb_id": "uuid",
  "kb_name": "Product Docs",
  "enabled": true,
  "document_count": 12
}

Set (replace all):

PUT /channels/:id/knowledge-bases
{ "kb_ids": ["kb-uuid-1", "kb-uuid-2"] }

5.6 Discoverable KBs

The discoverable flag controls whether a KB appears in the user's KB picker. Non-discoverable KBs are hidden from direct access but still searchable through Persona bindings (enterprise KB mode).

List discoverable (for KB picker UI):

GET /knowledge-bases-discoverable

Returns { "data": [KB objects] } — only KBs the user can see (personal + team + discoverable global).

Toggle discoverability (admin-enforced in handler):

PUT /knowledge-bases/:id/discoverable
{ "discoverable": false }

5.7 KB Resolution Chain

When a completion fires, KBs are resolved in priority order:

Persona KBs → Project KBs → Channel KBs → Personal KBs

The BuildKBHint function assembles all applicable KBs into the system prompt, and the kb_search tool searches across all of them.

5.8 Persona KB Bindings

See §4.4. Persona-KB binding is the primary enterprise mechanism for controlled KB access.


6. Notes

Obsidian-style knowledge graph with [[wikilinks]], backlinks, graph visualization, and folder organization. Notes are user-scoped (personal notebook). Future: channel-scoped notes for workflow artifacts.

6.1 CRUD

GET    /notes                       → paginated list of noteListItem
POST   /notes                       ← { "title", "content", "folder" }
GET    /notes/:id                   → full note object
PUT    /notes/:id                   ← { "title", "content", "folder" }
DELETE /notes/:id

Note object:

{
  "id": "uuid",
  "user_id": "uuid",
  "title": "Meeting Notes",
  "content": "# Meeting Notes\n\nDiscussed [[Project Alpha]]...",
  "folder": "work",
  "source_message_id": "uuid|null",
  "created_at": "...",
  "updated_at": "..."
}

source_message_id links a note back to the chat message that created it (provenance for AI-generated notes).

noteListItem is the same but with content truncated or omitted.

Full-text search:

GET /notes/search?q=project+alpha

Uses plainto_tsquery (Postgres) or LIKE fallback (SQLite). Returns { "data": [searchResult objects] } with match highlights.

Title search (lightweight, for wikilink autocomplete):

GET /notes/search-titles?q=proj

Returns { "data": [{ "id", "title" }] }.

6.3 Graph

Full graph topology:

GET /notes/graph
{
  "nodes": [{ "id": "uuid", "title": "Note Title" }],
  "edges": [{ "source": "uuid", "target": "uuid" }],
  "unresolved": [{ "source": "uuid", "target_title": "Missing Note" }]
}

Edges are directed: source contains [[target_title]]. Unresolved edges have a target_title but no matching note (dangling wikilink). When a note with that title is created, the edge auto-resolves.

Backlinks (who links to this note):

GET /notes/:id/backlinks

Returns { "data": [note objects] } — all notes containing [[this note's title]].

6.4 Folders

GET /notes/folders

Returns { "folders": ["work", "personal", "archive"] } — distinct folder values across all user notes.

6.5 Bulk Operations

POST /notes/bulk-delete
{ "ids": ["uuid-1", "uuid-2"] }

7. Workspaces & Git

The Workspace is a virtual filesystem for the editor surface. Each workspace has a root directory, file operations, optional Git integration, and full-text indexing for code search.

7.1 Workspace CRUD

GET    /workspaces                  → { "data": [...] }
POST   /workspaces                  ← { "name", "owner_type", "owner_id" }
GET    /workspaces/:id              → workspace object
PATCH  /workspaces/:id              ← partial update
DELETE /workspaces/:id

Workspace object:

{
  "id": "uuid",
  "name": "my-project",
  "owner_type": "user|project",
  "owner_id": "uuid",
  "status": "active|archived",
  "storage_bytes": 1048576,
  "file_count": 42,
  "created_at": "...",
  "updated_at": "..."
}

7.2 File Operations

All paths are relative to the workspace root.

GET    /workspaces/:id/files?path=/           → { "files": [entries] }
GET    /workspaces/:id/files/read?path=/a.md  → { "content": "...", "size": 123 }
PUT    /workspaces/:id/files/write            ← { "path": "/a.md", "content": "..." }
DELETE /workspaces/:id/files/delete            ← { "path": "/a.md" }
POST   /workspaces/:id/files/mkdir             ← { "path": "/src" }

File entry:

{
  "name": "main.go",
  "path": "/src/main.go",
  "is_dir": false,
  "size": 4096,
  "modified": "..."
}

7.3 Archive

Download (tar.gz of entire workspace):

GET /workspaces/:id/archive/download

Upload (restore from tar.gz):

POST /workspaces/:id/archive/upload
Content-Type: multipart/form-data

7.4 Reconcile, Stats, Index

Reconcile (sync filesystem state with DB metadata):

POST /workspaces/:id/reconcile

Stats:

GET /workspaces/:id/stats

Returns { "file_count", "storage_bytes", "indexed_files", ... }.

Index status:

GET /workspaces/:id/index-status

Returns indexing progress for full-text search.

7.5 Git Integration

All Git operations are workspace-scoped.

POST /workspaces/:id/git/clone     ← { "url", "branch", "credential_id" }
POST /workspaces/:id/git/pull
POST /workspaces/:id/git/push
GET  /workspaces/:id/git/status    → { "files": [{ "path", "status" }] }
GET  /workspaces/:id/git/diff      → { "diff": "..." }
POST /workspaces/:id/git/commit    ← { "message", "files": [...] }
GET  /workspaces/:id/git/log       → { "commits": [...] }
GET  /workspaces/:id/git/branches  → { "branches": [...], "current": "main" }
POST /workspaces/:id/git/checkout  ← { "branch": "feature-x" }

7.6 Git Credentials

User-owned, encrypted credential storage for Git operations. Encrypted with the user's UEK (same as BYOK keys — admins cannot recover).

POST   /git-credentials             ← { "name", "auth_type", "token"|"username"+"password"|"ssh_key" }
GET    /git-credentials             → { "data": [GitCredentialSummary] }
DELETE /git-credentials/:id

auth_type: https_pat, https_basic, or ssh_key.

Summary response (never exposes encrypted data):

{
  "id": "uuid",
  "name": "GitHub PAT",
  "auth_type": "https_pat",
  "created_at": "..."
}

8. Projects

Projects are organizational containers that group channels, knowledge bases, notes, and files. They follow the scope model (personal, team, global).

8.1 Project CRUD

GET    /projects                    → { "data": [...] }
POST   /projects                    ← { "name", "description", "scope", "team_id", "persona_id", "system_prompt" }
GET    /projects/:id                → project object
PUT    /projects/:id                ← partial update
DELETE /projects/:id

Project object:

{
  "id": "uuid",
  "name": "Q3 Research",
  "description": "...",
  "scope": "personal|team|global",
  "owner_id": "uuid",
  "team_id": "uuid|null",
  "persona_id": "uuid|null",
  "system_prompt": "...|null",
  "is_archived": false,
  "channel_count": 5,
  "created_at": "...",
  "updated_at": "..."
}

8.2 Channel Association

GET    /projects/:id/channels                   → { "data": [channel objects] }
POST   /projects/:id/channels                   ← { "channel_id": "uuid" }
DELETE /projects/:id/channels/:channelId
PUT    /projects/:id/channels/reorder            ← { "channel_ids": ["uuid1", "uuid2"] }

A channel can only belong to one project. POST performs an atomic move if the channel is already in a different project.

8.3 KB Association

GET    /projects/:id/knowledge-bases             → { "data": [KB objects] }
POST   /projects/:id/knowledge-bases             ← { "kb_id": "uuid" }
DELETE /projects/:id/knowledge-bases/:kbId

KBs bound to a project are automatically available to every channel in that project (see §5.7 resolution chain).

8.4 Note Association

GET    /projects/:id/notes                       → { "data": [note objects] }
POST   /projects/:id/notes                       ← { "note_id": "uuid" }
DELETE /projects/:id/notes/:noteId

8.5 Project Files

GET  /projects/:id/files                         → { "files": [...] }
POST /projects/:id/files                         ← multipart/form-data

Project-level file uploads (distinct from workspace files and channel attachments).

8.6 Admin Project Management

GET    /admin/projects                           → { "data": [...] }
DELETE /admin/projects/:id

Cross-instance visibility for platform admins. BYOK personal projects remain private (admin can delete but not read content).


9. Memory

Long-term memory extracted from conversations. The system identifies facts, preferences, and context about the user and stores them for injection into future conversations.

9.1 User Memory

GET    /memories                    → { "data": [memory objects] }
GET    /memories/count              → { "count": 42 }
PUT    /memories/:id                ← { "content": "updated text" }
DELETE /memories/:id
POST   /memories/:id/approve        → approve a pending memory
POST   /memories/:id/reject         → reject a pending memory

Memory object:

{
  "id": "uuid",
  "user_id": "uuid",
  "persona_id": "uuid|null",
  "scope": "user|persona",
  "content": "User prefers Go for backend work",
  "source_channel_id": "uuid",
  "status": "pending|approved|rejected",
  "confidence": 0.85,
  "created_at": "...",
  "updated_at": "..."
}

scope: user memories apply across all conversations. persona memories are specific to interactions with that Persona.

9.2 Admin Memory Review

GET  /admin/memories/pending        → { "data": [memory objects with user info] }
POST /admin/memories/bulk-approve   ← { "ids": ["uuid1", "uuid2"] }

Platform admin can review and bulk-approve pending memories across all users.


10. Providers & Routing

The multi-provider system. One or more LLM providers are configured, each with their own API keys, endpoints, and model catalogs. The routing layer decides which provider handles each request.

10.1 User BYOK Provider Configs

Gated by allow_user_byok policy. Personal API keys are encrypted with the user's UEK (per-user encryption key, Argon2id-derived). Platform admins cannot recover personal keys.

GET    /api-configs                 → { "configs": [safeConfig objects] }
POST   /api-configs                 ← { "name", "provider", "endpoint", "api_key", ... }
GET    /api-configs/:id             → safeConfig
PUT    /api-configs/:id             ← partial update (api_key optional)
DELETE /api-configs/:id

safeConfig — API keys are never returned:

{
  "id": "uuid",
  "name": "My OpenAI",
  "provider": "openai",
  "endpoint": "https://api.openai.com/v1",
  "model_default": "gpt-4o",
  "scope": "personal",
  "owner_id": "uuid",
  "is_active": true,
  "has_key": true,
  "config": {},
  "headers": {},
  "settings": {},
  "created_at": "..."
}

List models for a user config:

GET /api-configs/:id/models

Returns { "models": [catalog entries] }.

Fetch/sync models from provider API:

POST /api-configs/:id/models/fetch

Calls the provider's model list API, upserts into the local catalog.

10.2 Admin Global Provider Configs

GET    /admin/configs               → { "configs": [configWithKey objects] }
POST   /admin/configs               ← { "name", "provider", "endpoint", "api_key", "config", "headers", "settings", "is_private" }
PUT    /admin/configs/:id           ← partial update
DELETE /admin/configs/:id

configWithKey is the same as safeConfig but comes from ListGlobal — still redacts API keys, just adds the has_key flag.

Admin configs use the ENCRYPTION_KEY env var (not per-user UEK). is_private: when true, the config is available for admin-created Personas but not directly selectable by users.

10.3 Model Catalog (Admin)

The catalog is populated by fetching from provider APIs and stores model metadata (capabilities, context window, pricing).

GET    /admin/models                → { "models": [catalog entries] }
PUT    /admin/models/:id            ← { "visibility", "display_name", ... }
PUT    /admin/models/bulk           ← { "provider_config_id", "visibility" }
DELETE /admin/models/:id
POST   /admin/models/fetch          ← { "provider_config_id": "uuid|empty" }

Fetch with empty provider_config_id syncs ALL active global providers. Returns:

{
  "message": "models synced",
  "added": 5,
  "updated": 12,
  "total": 47
}

Or for multi-provider fetch: { "added", "updated", "total", "errors": [...] }.

Bulk visibility sets all models for a provider (or all models globally if no provider_config_id) to the specified visibility (enabled, disabled, team).

10.4 Provider Health

Real-time health tracking per provider config. The health accumulator records success/failure/latency on every completion, flushes to DB every 60 seconds.

Get all:

GET /admin/providers/health

Returns { "data": [ProviderHealth objects] }:

{
  "provider_config_id": "uuid",
  "provider_config_name": "OpenAI Production",
  "status": "healthy|degraded|down",
  "error_rate": 0.02,
  "avg_latency_ms": 450,
  "timeout_rate": 0.01,
  "rate_limit_count": 3,
  "last_check": "...",
  "last_error": "...|null"
}

Get single:

GET /admin/providers/:id/health

Auto-disable: After PROVIDER_AUTO_DISABLE_THRESHOLD consecutive "down" windows (default: 3), the provider is automatically deactivated.

10.5 Capability Overrides

Admin can override any model capability detected by the catalog or heuristic layer.

GET    /admin/capability-overrides           → { "data": [...] }
GET    /admin/models/:id/capabilities        → capabilities for one model
PUT    /admin/models/:id/capabilities        ← { "field": "value" }
DELETE /admin/models/:id/capabilities/:overrideId

Override fields: supports_vision, supports_tools, supports_thinking, context_window, max_output_tokens, etc.

10.6 Routing Policies

Policy-based request routing. Evaluated after model/config resolution, before provider dispatch.

Admin CRUD:

GET    /admin/routing/policies       → { "data": [...] }
GET    /admin/routing/policies/:id   → policy object
POST   /admin/routing/policies       ← { "name", "scope", "team_id", "priority", "policy_type", "config", "is_active" }
PUT    /admin/routing/policies/:id
DELETE /admin/routing/policies/:id

Policy object:

{
  "id": "uuid",
  "name": "Prefer Anthropic",
  "scope": "global|team",
  "team_id": "uuid|null",
  "priority": 10,
  "policy_type": "provider_prefer|team_route|cost_limit|model_alias",
  "config": {},
  "is_active": true
}
Policy type Config Behavior
provider_prefer { "providers": ["cfg-1", "cfg-2"] } Ordered fallback list
team_route { "providers": ["cfg-1"] } Restrict team to specific providers
cost_limit { "max_cost_per_request": 0.50 } Heuristic cost cap
model_alias { "alias": "fast", "target_model": "...", "target_config": "..." } Alias → provider+model rewrite

Dry-run test:

POST /admin/routing/test
{
  "model": "claude-sonnet-4-20250514",
  "user_id": "uuid",
  "team_id": "uuid|null"
}

Returns the ranked candidate list with health status for each.

10.7 Provider Types

Registry of supported provider types with metadata.

GET /admin/provider-types

Returns { "types": [...] } with name, display name, default endpoint, profile schema, and supported features per type.


11. Models & Preferences

What models are available to the current user and how they control visibility.

11.1 Enabled Models

The primary endpoint for populating model selectors:

GET /models/enabled

Note: GET /models exists as an alias. The alias is unused by the frontend and will be removed pre-1.0.

Returns { "models": [UserModel objects] }:

{
  "id": "composite-id",
  "model_id": "claude-sonnet-4-20250514",
  "provider_config_id": "uuid",
  "provider_config_name": "Anthropic",
  "provider": "anthropic",
  "display_name": "Claude Sonnet 4",
  "model_type": "chat|embedding|image",
  "context_window": 200000,
  "max_output_tokens": 8192,
  "supports_vision": true,
  "supports_tools": true,
  "supports_thinking": true,
  "supports_streaming": true,
  "input_price_per_m": 3.00,
  "output_price_per_m": 15.00,
  "provider_status": "healthy|degraded|down|null",
  "scope": "global|team|personal",
  "source": "catalog|heuristic"
}

The capabilities on each model are resolved through the three-tier chain: catalog DB → heuristic inference → admin overrides (see §10.5).

11.2 User Model Preferences

Users can hide models they don't want to see.

GET  /models/preferences            → { "preferences": { "model-id": { "hidden": true } } }
PUT  /models/preferences            ← { "model_id": "...", "hidden": true }
POST /models/preferences/bulk       ← { "preferences": { "id1": { "hidden": true }, "id2": { ... } } }

12. Notifications

Real-time notification infrastructure with WebSocket delivery and optional email transport.

12.1 CRUD

GET    /notifications                → paginated list
GET    /notifications/unread-count   → { "count": 5 }
PATCH  /notifications/:id/read       → mark one as read
POST   /notifications/mark-all-read  → mark all as read
DELETE /notifications/:id

Notification object:

{
  "id": "uuid",
  "user_id": "uuid",
  "type": "role.fallback|memory.extracted|system.announcement|...",
  "title": "Role Fallback Triggered",
  "body": "Primary model unavailable, using fallback",
  "metadata": {},
  "read": false,
  "created_at": "..."
}

12.2 Preferences

Users control per-type delivery:

GET    /notifications/preferences            → { "preferences": [...] }
PUT    /notifications/preferences/:type      ← { "in_app": true, "email": false }
DELETE /notifications/preferences/:type       → reset to default

Resolution: specific type pref → user wildcard * pref → system default (in_app=true, email=false).


13. Extensions

Plugin system with three tiers: Browser JS (client-side), Starlark sandbox (server-side, future), Sidecar containers (server-side, future). Currently only Browser tier is implemented.

13.1 User Extensions

GET  /extensions                    → { "extensions": [...] }
POST /extensions/:id/settings       ← { "enabled": true, "config": {...} }
GET  /extensions/:id/manifest       → full manifest.json
GET  /extensions/tools              → { "tools": [tool schema objects] }

13.2 Admin Extension Management

GET    /admin/extensions             → { "extensions": [...] }
POST   /admin/extensions             ← { manifest + script content }
PUT    /admin/extensions/:id         ← updated manifest/script
DELETE /admin/extensions/:id

13.3 Asset Serving

GET /extensions/:id/assets/*path

Public (no auth required). Serves static assets (icons, CSS, JS) from extension bundles.


14. User Profile & Settings

14.1 Profile

GET /profile                        → profileResponse
PUT /profile                        ← { "display_name", "email" }
{
  "id": "uuid",
  "username": "jdoe",
  "email": "jdoe@example.com",
  "display_name": "Jane Doe",
  "role": "user|admin",
  "avatar_url": "...",
  "created_at": "...",
  "last_login": "..."
}

14.2 Avatar

POST   /profile/avatar              ← multipart/form-data (field: "avatar")
DELETE /profile/avatar

14.3 Password

POST /profile/password
{
  "current_password": "...",
  "new_password": "..."
}

On password change, the UEK is re-wrapped with the new password (BYOK keys remain accessible without re-encryption).

14.4 App Settings

GET /settings                       → { "settings": {...} }
PUT /settings                       ← { "theme", "editor_keybindings", ... }

User-level preferences (theme, keybindings, default model, etc.).


15. Teams & Access Control

15.1 My Teams

GET /teams/mine                     → { "teams": [...] }

Returns teams the current user is a member of.

15.2 Team Administration

Team-admin-scoped routes (require RequireTeamAdmin middleware):

Team CRUD (platform admin):

GET    /admin/teams                  → { "teams": [...] }
POST   /admin/teams
GET    /admin/teams/:id
PUT    /admin/teams/:id
DELETE /admin/teams/:id

Members:

GET    /admin/teams/:id/members      → { "members": [...] }
POST   /admin/teams/:id/members      ← { "user_id", "role" }
PUT    /admin/teams/:id/members/:memberId  ← { "role" }
DELETE /admin/teams/:id/members/:memberId

Team-scoped routes (team admin, not platform admin):

GET    /teams/:teamId/members
POST   /teams/:teamId/members        ← { "user_id", "role" }
PUT    /teams/:teamId/members/:memberId
DELETE /teams/:teamId/members/:memberId

Team Providers:

GET    /teams/:teamId/providers      → { "configs": [...] }
POST   /teams/:teamId/providers      ← { "name", "provider", "endpoint", "api_key", ... }
PUT    /teams/:teamId/providers/:id
DELETE /teams/:teamId/providers/:id
GET    /teams/:teamId/providers/:id/models  → { "models": [...] }

Team Models:

GET /teams/:teamId/models           → { "models": [...] }

Available models for this team (global + team-scoped).

Team Personas: See §4.2.

Team Roles:

GET    /teams/:teamId/roles          → { "roles": [...] }
PUT    /teams/:teamId/roles/:role    ← { "permissions": {...} }
DELETE /teams/:teamId/roles/:role

Team Audit:

GET /teams/:teamId/audit             → paginated audit log
GET /teams/:teamId/audit/actions     → { "actions": [...] } (distinct action types)

Team Usage:

GET /teams/:teamId/usage             → { "totals": {...}, "results": [...] }

15.3 Groups & Resource Grants

Groups are ACL containers that decouple access from team membership. A resource grant controls who can see a Persona or KB beyond its base scope.

My Groups:

GET /groups/mine                     → { "groups": [...] }

Admin Group CRUD:

GET    /admin/groups                 → { "groups": [...] }
POST   /admin/groups                 ← { "name", "scope", "team_id" }
GET    /admin/groups/:id
PUT    /admin/groups/:id
DELETE /admin/groups/:id

Group Members:

GET    /admin/groups/:id/members     → { "members": [...] }
POST   /admin/groups/:id/members     ← { "user_id" }
DELETE /admin/groups/:id/members/:userId

Resource Grants:

GET /admin/grants/:type/:id          → { "grant": {...} }
PUT /admin/grants/:type/:id          ← { "grant_type": "team_only|global|groups", "group_ids": [...] }
DELETE /admin/grants/:type/:id

:type is persona or kb. :id is the resource ID.

Grant types:

grant_type Visibility
team_only Only the owning team
global All authenticated users
groups Members of specified groups

16. Platform Administration

Cross-cutting admin operations that don't belong to a specific domain.

16.1 User Management

GET    /admin/users                  → { "users": [...] }
POST   /admin/users                  ← { "username", "password", "email", "role" }
PUT    /admin/users/:id/role         ← { "role": "admin|user" }
PUT    /admin/users/:id/active       ← { "active": false }
DELETE /admin/users/:id
POST   /admin/users/:id/reset-password  ← { "new_password": "..." }
POST   /admin/users/:id/vault/reset     → destroys user's UEK (BYOK keys lost)

16.2 Global Settings & Policies

Settings are key-value pairs in global_settings. Policies are boolean flags that gate features.

GET /admin/settings                  → all settings
GET /admin/settings/:key             → single setting
PUT /admin/settings/:key             ← { "value": ... }

The handler auto-detects: if the value is a string and the key is a known policy name, it writes to the policies table. Otherwise it writes to global_config as JSON.

Public settings (non-admin, for FE bootstrapping):

GET /settings/public
{
  "banner": { "enabled": true, "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff", "position": "both" },
  "branding": { "instance_name": "Switchboard", "logo_url": "...", "tagline": "..." },
  "has_admin_prompt": true,
  "storage_configured": true,
  "paste_to_file_chars": 2000,
  "policies": {
    "allow_registration": "true",
    "allow_user_byok": "true",
    "allow_user_personas": "true"
  }
}

16.3 Banner Configuration

The environment banner is stored as a single global_config entry under the key "banner". It's a simple object:

{
  "enabled": true,
  "text": "DEVELOPMENT",
  "position": "both|top|bottom",
  "bg": "#007a33",
  "fg": "#ffffff"
}

Set via PUT /admin/settings/banner with { "value": { ... } }. The admin UI provides a color picker, position selector, and preset dropdown. The Go template base layout reads the banner from PageData and renders top/bottom strips with CSS custom properties.

16.4 Platform Stats

GET /admin/stats
{
  "users": 42,
  "channels": 156,
  "messages": 12847
}

16.5 Storage & Vault

Storage status:

GET /admin/storage/status            → { "backend", "healthy", "file_count", "total_bytes", ... }
GET /admin/storage/orphans           → { "count": 3 }
POST /admin/storage/cleanup          → removes orphaned blobs
GET /admin/storage/extraction        → extraction queue status

Vault:

GET /admin/vault/status              → { "encryption_key_set", "user_vaults_count", ... }

16.6 Audit Log

GET /admin/audit?page=1&per_page=50&action=user.create&actor_id=uuid
GET /admin/audit/actions             → { "actions": ["user.create", "policy.update", ...] }

Audit entry:

{
  "id": "uuid",
  "actor_id": "uuid",
  "action": "user.create",
  "resource_type": "user",
  "resource_id": "uuid",
  "metadata": {},
  "created_at": "..."
}

16.7 Usage & Pricing

GET /admin/usage                     → { "totals", "results" }
GET /admin/usage/teams/:id           → team-specific usage
GET /admin/usage/users/:id           → user-specific usage
GET /admin/pricing                   → { "data": [...] }
PUT /admin/pricing                   ← { "provider", "model", "input_per_m", "output_per_m" }
DELETE /admin/pricing/:provider/:model

Personal usage (non-admin):

GET /usage                           → { "totals", "results" }

Scoped to the user's BYOK provider usage only.

16.8 Roles (Platform)

GET    /admin/roles                  → { "roles": [...] }
GET    /admin/roles/:role
PUT    /admin/roles/:role            ← { "permissions": {...} }
POST   /admin/roles/:role/test       ← test role permissions

16.9 Email Test

POST /admin/notifications/test-email
{ "to": "admin@example.com" }

Sends a test email using the configured SMTP settings.


17. Export & Utility

17.1 Health Check

GET /health                          → { "status": "ok" }

Public, no auth. Also available at /api/v1/health.

17.2 Export

Convert markdown to PDF or DOCX via pandoc:

POST /export
{
  "content": "# My Document\n\nBody text...",
  "format": "pdf|docx",
  "filename": "my-document"
}

Returns the binary file with appropriate Content-Type. Requires pandoc in the container (available in the unified and backend Docker images).


18. WebSocket Protocol

Real-time bidirectional event bus. Single connection per client.

18.1 Connection

ws://{host}{BASE_PATH}/ws?token={access_token}

Lifecycle:

  • Server sends ping every 54 seconds
  • Client must respond with pong within 60 seconds or disconnection
  • Max message size: 4 KB
  • Write deadline: 10 seconds

18.2 Event Envelope

All messages are JSON:

{
  "type": "event.type",
  "payload": { ... }
}

18.3 Room Subscription

Clients subscribe to rooms for scoped event delivery:

{ "type": "subscribe", "payload": { "room": "channel:uuid" } }
{ "type": "unsubscribe", "payload": { "room": "channel:uuid" } }

18.4 Event Routing Table

Prefix Direction Description
channel.created → client New channel created
channel.updated → client Channel metadata changed
channel.deleted → client Channel removed
message.created → client New message in subscribed channel
message.updated → client Message content changed
message.deleted → client Message removed
cursor.updated → client Branch cursor moved
typing.start ↔ both User/AI started typing
typing.stop ↔ both Typing ended
model.changed → client Channel model roster changed
persona.updated → client Persona metadata changed
kb.updated → client Knowledge base changed
kb.document.status → client Document processing status update
note.created → client Note created
note.updated → client Note content changed
note.deleted → client Note removed
notification.new → client New notification
notification.read → client Notification marked read
memory.extracted → client New memory extracted
memory.status → client Memory approved/rejected
role.fallback → client Role fallback triggered
workspace.updated → client Workspace state changed
project.updated → client Project metadata changed
extension.updated → client Extension config changed
settings.updated → client Global settings changed
user.updated → client User profile changed

Direction: → client = server-to-client only, ← client = client-to-server only, ↔ both = bidirectional.


19. Appendix: Enums

Channel Types

direct (current default), group (v0.23.0), workflow (v0.25.0)

Message Roles

user, assistant, system, tool

Scopes

personal, team, global

Visibility (model catalog)

enabled, disabled, team

User Roles

admin, user

Team Member Roles

admin, member

Provider Types

openai, anthropic, openrouter, venice (extensible via registry)

Model Types

chat, embedding, image

Memory Scopes

user, persona

Memory Statuses

pending, approved, rejected

Workspace Owner Types

user, project

Workspace Statuses

active, archived

Index Statuses

pending, indexing, ready, error

KB Document Statuses

pending, chunking, embedding, ready, error

Provider Health Statuses

healthy, degraded, down

Routing Policy Types

provider_prefer, team_route, cost_limit, model_alias

Extension Tiers

browser (implemented), starlark (future), sidecar (future)

Grant Types

team_only, global, groups

Resource Grant Scopes

persona, kb

Notification Types

role.fallback, memory.extracted, system.announcement, plus extensible via notification service.

Git Auth Types

https_pat, https_basic, ssh_key

Policies

Key Default Description
allow_registration "true" Allow new user self-registration
allow_user_byok "true" Allow users to add personal API keys
allow_user_personas "true" Allow users to create personal Personas
allow_team_providers "true" Allow team admins to configure team providers
kb_direct_access "true" Show KB picker in channel (false = strict enterprise mode)
require_email "false" Require email on registration
default_system_prompt "" Injected into all conversations (admin override)

Page Routes (Non-API)

Server-rendered Go template surfaces. Not REST endpoints — these return HTML pages.

Route Surface Description
/login Login Standalone login/register page
/ Chat Main chat interface
/chat/:chatID Chat Chat with specific channel loaded
/editor Editor Workspace file editor
/editor/:wsId Editor Editor with specific workspace
/notes Notes Notes interface
/notes/:noteId Notes Notes with specific note loaded
/admin Admin Platform administration
/admin/:section Admin Admin with specific section
/settings Settings User settings
/settings/:section Settings Settings with specific section

All page routes (except /login) require authentication via AuthOrRedirect middleware (reads sb_token cookie). Admin routes additionally require RequireAdminPage().

Dynamic surface routing for admin-installed extension surfaces is not yet implemented. The current five surfaces are hardcoded in Go route registration. Future: extension manifests declare surface routes, the page engine dynamically registers them with appropriate data loaders. See EXTENSIONS.md for the design direction.