Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
21 KiB
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:
{
"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
{
"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.
{
"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
{
"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:
{
"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:
{
"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
{
"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": [...] }:
{
"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
@mentiontarget - 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.
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.
{
"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:
{
"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.