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

2622 lines
65 KiB
Markdown

# 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](#1-conventions)
2. [Auth](#2-auth)
3. [Channels & Conversations](#3-channels--conversations)
4. [Personas](#4-personas)
5. [Knowledge Bases](#5-knowledge-bases)
6. [Notes](#6-notes)
7. [Workspaces & Git](#7-workspaces--git)
8. [Projects](#8-projects)
9. [Memory](#9-memory)
10. [Providers & Routing](#10-providers--routing)
11. [Models & Preferences](#11-models--preferences)
12. [Notifications](#12-notifications)
13. [Extensions](#13-extensions)
14. [User Profile & Settings](#14-user-profile--settings)
15. [Teams & Access Control](#15-teams--access-control)
16. [Platform Administration](#16-platform-administration)
17. [Export & Utility](#17-export--utility)
17b. [Files & Storage](#17b-files--storage)
18. [WebSocket Protocol](#18-websocket-protocol)
19. [Appendix: Enums](#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:
```json
{ "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:
```json
{
"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
```
```json
{
"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
```
```json
{ "username": "jdoe", "password": "..." }
```
**Response:**
```json
{
"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
```
```json
{ "refresh_token": "opaque-string" }
```
Returns new `access_token` and `refresh_token`. Old refresh token is
invalidated (rotation).
### Logout
```
POST /auth/logout
```
```json
{ "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:
```json
{
"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
```
```json
{
"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.
```json
{
"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
```
```json
{
"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
```
```json
{ "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
```
```json
{
"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
```
```json
{
"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:**
```json
{
"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:
```json
{
"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
```
```json
{
"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
```
```json
{
"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": [...] }`:
```json
{
"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
```
```json
{
"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
```
```json
{ "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:
```json
{
"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
```
```json
{
"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:
```json
{
"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
```
### 5.3 Search
```
POST /knowledge-bases/:id/search
```
```json
{
"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] }`:
```json
{
"kb_id": "uuid",
"kb_name": "Product Docs",
"enabled": true,
"document_count": 12
}
```
**Set** (replace all):
```
PUT /channels/:id/knowledge-bases
```
```json
{ "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
```
```json
{ "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:
```json
{
"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.
### 6.2 Search
**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
```
```json
{
"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
```
```json
{ "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:
```json
{
"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:
```json
{
"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):
```json
{
"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:
```json
{
"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:
```json
{
"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:
```json
{
"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:
```json
{
"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] }`:
```json
{
"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:
```json
{
"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
```
```json
{
"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] }`:
```json
{
"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:
```json
{
"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:
```json
{
"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" }
```
```json
{
"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
```
```json
{
"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
```
```json
{
"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:
```json
{
"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
```
```json
{
"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:
```json
{
"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
```
```json
{ "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
```
```json
{
"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
```sql
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:
```json
{
"event": "file.created",
"channel_id": "uuid",
"message_id": "uuid",
"file": { ...file object... }
}
```
6. 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:
```json
{
"type": "event.type",
"payload": { ... }
}
```
### 18.3 Room Subscription
Clients subscribe to rooms for scoped event delivery:
```json
{ "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.