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/channels.md
gobha 3a4afea7f2 Changeset 0.37.18 (#230)
Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
2026-03-24 19:55:14 +00:00

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 @mention target
  • Adds the persona's model to the channel model roster automatically
  • Scopes the persona's KB bindings into the channel's KB resolution chain
  • The persona's avatar and display name appear in the participant list

Update participant role:

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

Remove participant:

DELETE /channels/:id/participants/:participantId

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

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

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

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.

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.