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

766 lines
21 KiB
Markdown

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