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 16:06:12 +00:00

65 KiB

ICD-API — Chat Switchboard Backend API Contract

Version: 0.23.0
Updated: 2026-03-04
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 17b. Files & Storage
  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).


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. Channels support multiple participants — users, personas, and sessions — making them the foundation for collaborative and multi-model workflows.

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",
  "title": "My Chat",
  "type": "direct|group|workflow",
  "description": "",
  "model": "claude-sonnet-4-20250514",
  "provider_config_id": "uuid|null",
  "system_prompt": "",
  "project_id": "uuid|null",
  "folder": "string|null",
  "tags": ["tag1", "tag2"],
  "participant_count": 1,
  "created_at": "...",
  "updated_at": "..."
}

Channel types:

Type Description
direct Single-user chat (default, backward compatible)
group Multi-user/multi-model collaborative channel
workflow Staged channel driven by workflow definitions

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, the authenticated user is automatically added as a participant with role: "owner". 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",
      "participant_id": "uuid|null",
      "participant_type": "user|persona|session|null",
      "thinking": "...|null",
      "tool_calls": [...],
      "tool_results": [...],
      "attachments": [...],
      "has_siblings": true,
      "sibling_index": 0,
      "sibling_count": 2,
      "created_at": "..."
    }
  ]
}

participant_id and participant_type identify who authored the message. For user messages, this is the authenticated user. For assistant messages, this is the persona (if set) or null for raw model responses. Existing messages without participants return null.

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.

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

Participant scoping: The requesting user must be a participant in the channel. Messages are attributed to the authenticated user (or session). In group channels, all participants see all messages in real time via WebSocket.

Persona resolution: The primary completion path. If persona_id is set (explicitly or via @mention resolution from message content), the handler loads the persona's system prompt, model, provider config, and KB bindings. Per-message model/provider_config_id override the persona's defaults. In multi-persona channels, @mention in the message content is parsed to resolve the target persona from the channel's participant list.

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 (Raw Access)

The primary way to add AI to a channel is by adding a persona as a participant (§3.7). Adding a persona automatically populates the channel model roster with the persona's underlying model.

For the rare case where a user needs raw model access without a persona wrapper, the channel_models table provides direct model roster management. This is the 1% escape hatch — most users should never need it.

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,
  "persona_id": "uuid|null",
  "created_at": "..."
}

persona_id: set when this roster entry was auto-created by adding a persona participant. Null for manually-added raw models.

@mention resolution: When a user types @ in a channel, the autocomplete shows persona participants first (by display name), then raw model roster entries. The target of a @mention is always resolved to a specific provider_config_id + model pair — never an ambiguous bare model name.

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 Files

All binary content associated with channels — user uploads, tool- generated artifacts, system files — lives in the unified files table backed by the ObjectStore (see §X). The old attachments table and routes are removed.

Upload (user):

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

Field: file. Sets origin: "user_upload". Returns the file object.

Create (tool output):

POST /files
{
  "channel_id": "uuid",
  "message_id": "uuid",
  "filename": "generated_image.png",
  "content_type": "image/png",
  "display_hint": "inline",
  "metadata": { "tool_name": "image_generation" }
}

Body: multipart file field or base64 data field. Sets origin: "tool_output". Returns file object.

List by channel:

GET /channels/:id/files?origin=user_upload

Returns { "files": [...] }. Filterable by origin, content_type.

List by message:

GET /messages/:id/files  → { "files": [...] }

How the frontend discovers generated artifacts to render inline.

List by user (file manager):

GET /files?page=1&per_page=50  → { "files": [...], "total": N }

All files owned by the authenticated user, paginated.

Get metadata:

GET /files/:id
{
  "id": "uuid",
  "channel_id": "uuid",
  "message_id": "uuid|null",
  "user_id": "uuid",
  "origin": "user_upload|tool_output|system",
  "filename": "report.pdf",
  "content_type": "application/pdf",
  "size_bytes": 1048576,
  "display_hint": "inline|download|thumbnail",
  "extracted_text": "...|null",
  "metadata": {},
  "created_at": "...",
  "updated_at": "..."
}

Download:

GET /files/:id/download

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

Thumbnail:

GET /files/:id/thumbnail

Delete:

DELETE /files/:id

Deletes metadata + blob. Channel deletion cascades via FK + DeletePrefix.

3.7 Channel Participants

The participant system is how users and AI interact in a channel. Personas are the primary way to add AI — adding a persona as a participant brings its full identity (name, avatar, system prompt, model, provider config, KB bindings) into the channel. This is the 99% path. Raw model access without a persona is available via the model roster (§3.4) for power users.

Every channel has at least one participant (the owner). direct channels have exactly one user participant and optionally one or more persona participants. group and workflow channels support multiple participants of mixed types.

List participants:

GET /channels/:id/participants

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

{
  "id": "uuid",
  "channel_id": "uuid",
  "participant_type": "user|persona|session",
  "participant_id": "uuid",
  "role": "owner|member|observer",
  "display_name": "Jane Doe",
  "avatar_url": "...|null",
  "joined_at": "..."
}

Participant types:

Type Description
user Authenticated user. participant_id = users.id
persona AI persona added to channel. participant_id = personas.id. Brings model, system prompt, KB bindings
session Anonymous/session participant (workflow intake). participant_id = session token

Participant roles:

Role Capabilities
owner Full control: add/remove participants, delete channel, all member capabilities
member Send messages, trigger completions, view history
observer Read-only: view messages, no send

Add participant:

POST /channels/:id/participants
{
  "participant_type": "user|persona",
  "participant_id": "uuid",
  "role": "member"
}

Requires owner role in the channel. Adding a persona participant:

  • Makes the persona available as an @mention target
  • Adds the persona's model to the channel model roster automatically
  • Scopes the persona's KB bindings into the channel's KB resolution chain
  • The persona's avatar and display name appear in the participant list

Update participant role:

PATCH /channels/:id/participants/:participantId
{ "role": "observer" }

Remove participant:

DELETE /channels/:id/participants/:participantId

Cannot remove the last owner. Removing a persona participant also removes its auto-created model roster entry.

Completion routing: When @PersonaName appears in message content, the completion handler resolves to that persona's model, provider config, and system prompt. When no @mention is present in a multi-persona channel, the channel's default persona (first added) handles the response. The persona_id field in the completion request (§3.3) can also be set explicitly to override @mention resolution.

Backward compatibility: Existing direct channels that predate the participant system are auto-migrated on first access — a single user participant with role: "owner" is created from the channel's legacy user_id column.

3.8 Presence

Real-time presence for channel participants. Delivered via WebSocket to all subscribers of the channel room.

Who's here:

GET /channels/:id/presence

Returns { "participants": [{ "participant_id", "display_name", "status", "last_seen" }] }.

status: online, idle, offline.

Typing indicators are sent via WebSocket (see §18.4). The typing event payload includes participant_id and participant_type so the UI can distinguish between human and AI typing.


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.

Model inheritance: A Persona inherits all properties of its underlying model. This includes context window size, max output tokens, supported capabilities (thinking, tools, vision, streaming), input/output pricing, and provider status. When the frontend displays a Persona, it resolves the underlying model's capabilities and shows the same pills/badges (e.g. thinking, tools, 200k context) that the raw model would show. The Persona adds identity (name, avatar, system prompt, KB bindings) on top of the model's capabilities — it never masks or reduces them.

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,
  "is_active": true,
  "avatar_url": "/api/v1/personas/uuid/avatar?v=123",
  "inherited": {
    "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"
  },
  "created_at": "...",
  "updated_at": "..."
}

The inherited block is populated by the backend from the resolved model capabilities at response time — it is not stored on the persona. If the underlying model's capabilities change (e.g. provider upgrades context window), the persona inherits the new values automatically. The frontend uses inherited to render capability pills and context size badges identical to the raw model.

4.1 Personal Personas

Gated by allow_user_personas policy.

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

4.2 Team Personas

GET    /teams/:teamId/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": [...] }
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

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",
  "is_persona": false,
  "persona_id": "uuid|null",
  "persona_scope": "global|team|personal|null",
  "persona_avatar": "url|null",
  "persona_team_name": "string|null"
}

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

When is_persona is true, the capability fields (context_window, max_output_tokens, supports_*, pricing, provider_status) are inherited from the persona's underlying model. The frontend renders the same capability pills and badges for a persona as for its raw model — the persona adds identity, not capability restrictions.

11.2 User Model Preferences

Users can hide models they don't want to see and set per-model defaults. Preferences are keyed on the composite identity provider_config_id:model_id — the same model from different providers can have independent visibility.

GET  /models/preferences            → { "preferences": [PreferenceEntry objects] }
PUT  /models/preferences            ← { "model_id": "...", "provider_config_id": "uuid", "hidden": true }
POST /models/preferences/bulk       ← { "entries": [{ "model_id": "...", "provider_config_id": "uuid", "hidden": true }] }

PreferenceEntry:

{
  "model_id": "grok-4.1-fast",
  "provider_config_id": "uuid",
  "hidden": true,
  "preferred_temperature": 0.7,
  "preferred_max_tokens": 4096,
  "sort_order": 0
}

Identity rule: provider_config_id is required on write. The same bare model_id from two different provider configs (e.g. global Venice vs personal BYOK Venice) are independent preference entries. The frontend composite ID format is {provider_config_id}:{model_id}.

Persona preferences: Personas are not in this table. A persona's visibility is controlled by the grant system (§15.3) and the is_active flag, not by model preferences.


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).


17b. Files & Storage

All binary content in Chat Switchboard flows through a single ObjectStore abstraction backed by PVC (filesystem) or S3. All file metadata lives in one files table. The old attachments table is dropped.

17b.1 Storage Backend

Operation Description
Put(key, reader, size, contentType) Store a blob
Get(key) → reader, size, contentType Retrieve a blob
Delete(key) Remove a blob
DeletePrefix(prefix) Bulk remove (channel deletion)
Exists(key) Check without reading
Healthy() Backend health check
Stats() Aggregate metrics

Backends: pvc (STORAGE_PATH=/data/storage) or s3 (S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY).

17b.2 Key Namespacing

Prefix Pattern What
attachments/ attachments/{channel_id}/{file_id}_{filename} User uploads + AI outputs (legacy prefix retained for existing blobs)
kb/ kb/{kb_id}/{doc_id}_{filename} Knowledge base documents
exports/ exports/{user_id}/{export_id}_{filename} Chat/data exports
workspace/ workspace/{workspace_id}/{path} Workspace files

17b.3 File Object

CREATE TABLE files (
    id              UUID PRIMARY KEY,
    channel_id      UUID REFERENCES channels(id) ON DELETE CASCADE,
    message_id      UUID REFERENCES messages(id) ON DELETE SET NULL,
    user_id         UUID REFERENCES users(id) ON DELETE SET NULL,
    project_id      UUID REFERENCES projects(id) ON DELETE SET NULL,
    origin          TEXT NOT NULL DEFAULT 'user_upload'
                    CHECK (origin IN ('user_upload','tool_output','system')),
    filename        TEXT NOT NULL,
    content_type    TEXT NOT NULL DEFAULT 'application/octet-stream',
    size_bytes      BIGINT NOT NULL DEFAULT 0,
    storage_key     TEXT NOT NULL,
    display_hint    TEXT NOT NULL DEFAULT 'download'
                    CHECK (display_hint IN ('inline','download','thumbnail')),
    extracted_text  TEXT,
    metadata        JSONB DEFAULT '{}',
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

Origin:

Value Who creates Examples
user_upload User via upload UI PDF, image, doc attached to message
tool_output Tool execution during completion DALL-E image, video gen, code artifact
system Platform Export, avatar stored as file

Display hint:

Value Frontend behavior
inline Render in message stream (images, video, audio, HTML)
download Filename + size + download button
thumbnail Thumbnail preview, click to expand

17b.4 Tool Output Flow

  1. Tool executes (image gen, video render, etc.)
  2. Tool writes blob via ObjectStore.Put
  3. Tool creates file record: POST /files with origin: "tool_output", message_id = current assistant message
  4. Tool result includes file_id reference
  5. WebSocket emits file.created event:
{
  "event": "file.created",
  "channel_id": "uuid",
  "message_id": "uuid",
  "file": { ...file object... }
}
  1. Frontend renders inline based on display_hint + content_type

The event fires during streaming so the user sees the artifact before the completion finishes.

17b.5 Content Type → Display Hint Defaults

Content Type Default Hint Rendering
image/* inline <img> in message, lightbox on click
video/* inline <video> player
audio/* inline <audio> player
text/html inline Sandboxed iframe
application/pdf thumbnail Thumb + PDF viewer on click
text/*, application/json inline Syntax-highlighted code block
Everything else download Filename + download button

Tools can override the default hint explicitly.

17b.6 File Manager (Future Surface)

A user-scoped file browser backed by GET /files?page=1&per_page=50. Displays all files owned by the user across all channels, grouped by channel or date. Supports download and delete. Deleting a file that is referenced by a message replaces the inline render with a "file deleted" placeholder.

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 Participant started typing (payload includes participant_id, participant_type)
typing.stop ↔ both Typing ended
participant.joined → client Participant added to channel
participant.left → client Participant removed from channel
participant.updated → client Participant role changed
presence.update → client Participant online/idle/offline status change
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
file.created → client File uploaded or generated (§17b.4)
file.deleted → client File removed

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


19. Appendix: Enums

Channel Types

direct (single-user), group (multi-user/multi-model), workflow (staged)

Participant Types

user, persona, session

Participant Roles

owner, member, observer

Presence Statuses

online, idle, offline

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.