# 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. ### Channel CRUD **List channels** — paginated, sorted by last activity. ``` GET /channels?page=1&per_page=50 ``` Optional query filters: `type`, `types` (comma-separated), `folder`, `folder_id`, `search`, `project_id` (UUID or `"none"`), `archived`. Returns pagination envelope. Each channel: ```json { "id": "uuid", "user_id": "uuid", "title": "My Chat", "type": "direct|dm|group|channel|workflow|service", "ai_mode": "auto|mention_only|off", "topic": "string|null", "description": "", "model": "claude-sonnet-4-20250514", "provider_config_id": "uuid|null", "system_prompt": "", "is_archived": false, "is_pinned": false, "folder": "string|null", "folder_id": "uuid|null", "project_id": "uuid|null", "workspace_id": "uuid|null", "tags": ["tag1", "tag2"], "settings": {}, "message_count": 0, "unread_count": 0, "created_at": "...", "updated_at": "..." } ``` **Channel types:** | Type | Description | |------|-------------| | `direct` | Single-user chat (default, backward compatible) | | `dm` | Human-to-human direct message, AI silent unless @mentioned | | `group` | Multi-user/multi-model collaborative channel | | `channel` | Named persistent space, configurable ai_mode | | `workflow` | Staged channel driven by workflow definitions | | `service` | Autonomous task execution, no human participant | **Create channel:** ``` POST /channels ``` ```json { "title": "New Chat", "type": "direct", "description": "", "model": "claude-sonnet-4-20250514", "provider_config_id": "uuid", "system_prompt": "", "folder": null, "folder_id": null, "tags": [], "participants": ["user-uuid"] } ``` 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. `participants` is only used for `dm` type — array of exactly one other user UUID. The handler enforces DM dedup: if a DM already exists between the two users, the existing channel is returned (HTTP 200, not 201). **Get channel:** ``` GET /channels/:id ``` Returns single channel object. Accessible by the channel owner **or** any user who is a participant in the channel (via `channel_participants`). **Update channel:** ``` PUT /channels/:id ``` Accepts partial updates — only fields present in the body are changed. Same field set as create, plus `is_archived`, `is_pinned`, `settings` (JSONB merge), `workspace_id`, `ai_mode`, `topic`, `folder_id`. **Auth:** Owner only (keyed on `user_id` column). **Delete channel:** ``` DELETE /channels/:id ``` **Retention policy (v0.37.14):** When `retention_ttl_days > 0`, all channels are archived on delete and purged after the TTL. The only exception is channels using a Personal (BYOK) provider — those are always hard-deleted immediately. | Provider scope | TTL > 0 | TTL = 0 | |---------------|---------|---------| | `personal` (BYOK) | Hard delete | Hard delete | | `global` | Archive + purge after TTL | Hard delete | | `team` | Archive + purge after TTL | Hard delete | | NULL (no provider) | Archive + purge after TTL | Hard delete | When retention applies, the channel is archived (`is_archived = true`) and stamped with `purge_after`. A background scanner purges channels past their `purge_after` hourly. Archived channels are hidden from the user's sidebar. Non-owners who call DELETE are removed as participants ("leave channel") instead of deleting. **Response (immediate delete):** `{"message": "channel deleted"}` **Response (retention):** `{"message": "channel archived for retention", "purge_after": "..."}` **Response (leave):** `{"message": "left channel"}` Hard-delete cascades: messages, files, channel_models, channel_kbs, channel_participants. Storage cleanup runs asynchronously. **Auth:** Owner for delete/archive; any participant for leave. ### 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 { "active_leaf_id": "uuid" } ``` Sets which leaf message is active. The server walks the tree from this leaf to the root and returns the full path. 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). ### 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`. Content and reasoning deltas use OpenAI-compatible envelope format. Tool events use named SSE events. ``` data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":null}],"model":"claude-sonnet-4-20250514"} data: {"choices":[{"delta":{"content":" world"},"finish_reason":null}],"model":"claude-sonnet-4-20250514"} data: {"choices":[{"delta":{"reasoning_content":"Let me think..."},"finish_reason":null}],"model":"claude-sonnet-4-20250514"} event: tool_use data: [{"id":"call_1","name":"web_search","input":{"query":"..."}}] event: tool_result data: [{"id":"call_1","content":"Search results..."}] data: {"choices":[{"delta":{"content":"Based on the search..."},"finish_reason":null}],"model":"claude-sonnet-4-20250514"} data: {"choices":[{"delta":{},"finish_reason":"stop"}],"model":"claude-sonnet-4-20250514"} data: [DONE] ``` | Event | `event:` field | Payload | |-------|---------------|---------| | Content delta | _(unnamed)_ | OpenAI envelope: `choices[0].delta.content` | | Reasoning delta | _(unnamed)_ | OpenAI envelope: `choices[0].delta.reasoning_content` | | Tool invocation | `tool_use` | `[{ "id", "name", "input" }]` | | Tool result | `tool_result` | `[{ "id", "content" }]` | | Stream complete | _(unnamed)_ | OpenAI envelope: `choices[0].finish_reason` = `"stop"` or `"budget_exceeded"` | | End sentinel | _(unnamed)_ | `[DONE]` (literal string) | | Error mid-stream | _(unnamed)_ | `{ "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) X-Switchboard-Fallback: depth (when fallback chain was triggered) ``` **List available tools:** ``` GET /tools ``` Returns `{ "tools": [...] }` where each tool has `name`, `description`, `parameters` (JSON Schema), and `category`. ### 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. ### 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" }`. ### 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):** When `workspace_write` or `workspace_patch` tools succeed during a completion, the handler auto-records a file reference with `origin: "tool_output"` linked to the assistant message (v0.37.18). No public endpoint — created internally by the completion handler via the store layer. **List by channel:** ``` GET /channels/:id/files?origin=user_upload ``` Returns `{ "files": [...] }`. Filterable by `origin`, `content_type`. **List by message:** ``` GET /messages/:id/files ``` Returns `{ "files": [...] }`. How the frontend discovers generated artifacts to render inline. **List by user (file manager):** ``` GET /files?page=1&per_page=50 ``` Returns `{ "files": [...], "total": N, "page": N, "per_page": 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** _(planned — v0.28.0+):_ ``` GET /files/:id/thumbnail ``` **Delete:** ``` DELETE /files/:id ``` Deletes metadata + blob. Channel deletion cascades via FK + `DeletePrefix`. ### 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. ### Presence Heartbeat-based online status. Not channel-scoped — tracks global user presence. **Heartbeat:** ``` POST /presence/heartbeat ``` Client sends every 30 seconds. Server upserts `user_presence` row. No request body needed. Returns `{ "ok": true }`. **Auth:** Authenticated. **Bulk query:** ``` GET /presence?users=uuid1,uuid2,uuid3 ``` Returns online/offline status for listed user UUIDs. Threshold: online if last heartbeat < 90 seconds ago. ```json { "presence": { "uuid1": "online", "uuid2": "offline" } } ``` Values are flat status strings. Capped at 100 user IDs per request. Subsequent updates delivered via WebSocket `presence.changed` events. **Typing indicators** are sent via `POST /channels/:id/typing` (authenticated, broadcasts `typing.user` WebSocket event to other user participants) and via WebSocket (see websocket.md). The typing event payload includes `channel_id`, `user_id`, and `display_name`. ### Chat Folders User-scoped grouping for chats. Folders have no semantic weight — they are named drawers. The schema supports nesting via `parent_id` (DnD nesting not yet implemented in the UI). ``` GET /folders → { "folders": [...] } POST /folders ← { "name": "Research", "sort_order": 0 } PUT /folders/:id ← { "name": "Renamed", "sort_order": 1 } DELETE /folders/:id → chats become unfiled ``` **Folder object:** ```json { "id": "uuid", "name": "Research", "parent_id": "uuid|null", "sort_order": 0, "created_at": "...", "updated_at": "..." } ``` Moving a chat into/out of a folder is done via `PUT /channels/:id` with `{ "folder_id": "uuid" }` or `{ "folder_id": "" }` to unbind. The channel list also supports `?folder_id=uuid` as a query filter. **Auth:** Authenticated (user-scoped). ### Channel Configuration Additional channel fields (set via `PUT /channels/:id`): | Field | Type | Default | Description | |-------|------|---------|-------------| | `ai_mode` | string | `auto` | `auto`, `mention_only`, or `off` | | `topic` | string | null | Short description shown in header | Fields managed internally (not settable via channel update): | Field | Type | Default | Description | |-------|------|---------|-------------| | `allow_anonymous` | boolean | false | Session participants can join. Set by workflow instance handlers on channel creation. | | `kb_auto_inject` | boolean | false | Top-K KB chunks auto-prepend to context _(v0.28.0 — schema exists, not yet wired)_. | `ai_mode` behavioral matrix: | Channel type | Default ai_mode | Notes | |-------------|----------------|-------| | `direct` | `auto` | Existing behavior | | `dm` | `mention_only` | AI silent unless @mentioned | | `group` | `auto` | Configurable per channel | | `channel` | `auto` | Configurable per channel | ### Mark Read ``` POST /channels/:id/mark-read ``` Updates the user's `last_read_at` cursor. Used for unread count calculation. **Auth:** Authenticated. ### User Search ``` GET /users/search?q=alice ``` Returns `{ "users": [{ "id", "username", "display_name", "handle" }] }`. Matches on username, display_name, and handle (case-insensitive substring). Used for DM creation and participant picker. Excludes the calling user. Max 20 results. **Auth:** Authenticated. ---