# Channel Protocol Architecture **Status:** Draft **Authors:** Mosaic Core Team **Last Updated:** 2026-03-22 **Covers:** M7-001 (IChannelAdapter interface) and M7-002 (ChannelMessage protocol) --- ## Overview The channel protocol defines a unified abstraction layer between Mosaic's core messaging infrastructure and the external communication channels it supports (Matrix, Discord, Telegram, TUI, WebUI, and future channels). The protocol consists of two main contracts: 1. `IChannelAdapter` — the interface each channel driver must implement. 2. `ChannelMessage` — the canonical message format that flows through the system. All channel-specific translation logic lives inside the adapter implementation. The rest of Mosaic works exclusively with `ChannelMessage` objects. --- ## M7-001: IChannelAdapter Interface ```typescript interface IChannelAdapter { /** * Stable, lowercase identifier for this channel (e.g. "matrix", "discord"). * Used as a namespace key in registry lookups and log metadata. */ readonly name: string; /** * Establish a connection to the external channel backend. * Called once at application startup. Must be idempotent (safe to call * when already connected). */ connect(): Promise; /** * Gracefully disconnect from the channel backend. * Must flush in-flight sends and release resources before resolving. */ disconnect(): Promise; /** * Return the current health of the adapter connection. * Used by the admin health endpoint and alerting. * * - "connected" — fully operational * - "degraded" — partial connectivity (e.g. read-only, rate-limited) * - "disconnected" — no connection to channel backend */ health(): Promise<{ status: 'connected' | 'degraded' | 'disconnected' }>; /** * Register an inbound message handler. * The adapter calls `handler` for every message received from the channel. * Multiple calls replace the previous handler (last-write-wins). * The handler is async; the adapter must not deliver new messages until * the previous handler promise resolves (back-pressure). */ onMessage(handler: (msg: ChannelMessage) => Promise): void; /** * Send a ChannelMessage to the given channel/room/conversation. * `channelId` is the channel-native identifier (e.g. Matrix room ID, * Discord channel snowflake, Telegram chat ID). */ sendMessage(channelId: string, msg: ChannelMessage): Promise; /** * Map a channel-native user identifier to the Mosaic internal userId. * Returns null when no matching Mosaic account exists for the given * channelUserId (anonymous or unlinked user). */ mapIdentity(channelUserId: string): Promise; } ``` ### Adapter Registration Adapters are registered with the `ChannelRegistry` service at startup. The registry calls `connect()` on each adapter and monitors `health()` on a configurable interval (default: 30 s). ``` ChannelRegistry └── register(adapter: IChannelAdapter): void └── getAdapter(name: string): IChannelAdapter | null └── listAdapters(): IChannelAdapter[] └── healthAll(): Promise> ``` --- ## M7-002: ChannelMessage Protocol ### Canonical Message Format ```typescript interface ChannelMessage { /** * Globally unique message ID. * Format: UUID v4. Generated by the adapter when receiving, or by Mosaic * when sending. Channel-native IDs are stored in metadata.channelMessageId. */ id: string; /** * Channel-native room/conversation/channel identifier. * The adapter populates this from the inbound message. * For outbound messages, the caller supplies the target channel. */ channelId: string; /** * Channel-native identifier of the message sender. * For Mosaic-originated messages this is the Mosaic userId or agentId. */ senderId: string; /** Sender classification. */ senderType: 'user' | 'agent' | 'system'; /** * Textual content of the message. * For non-text content types (image, file) this may be an empty string * or an alt-text description; the actual payload is in `attachments`. */ content: string; /** * Hint for how `content` should be interpreted and rendered. * - "text" — plain text, no special rendering * - "markdown" — CommonMark markdown * - "code" — code block (use metadata.language for the language tag) * - "image" — binary image; content is empty, see attachments * - "file" — binary file; content is empty, see attachments */ contentType: 'text' | 'markdown' | 'code' | 'image' | 'file'; /** * Arbitrary key-value metadata for channel-specific extension fields. * Examples: { channelMessageId, language, reactionEmoji, channelType }. * Adapters should store channel-native IDs here so round-trip correlation * is possible without altering the canonical fields. */ metadata: Record; /** * Optional thread or reply-chain identifier. * For threaded channels (Matrix, Discord threads, Telegram topics) this * groups messages into a logical thread scoped to the same channelId. */ threadId?: string; /** * The canonical message ID this message is a reply to. * Maps to channel-native reply/quote mechanisms in each adapter. */ replyToId?: string; /** * Binary or URI-referenced attachments. * Each attachment carries its MIME type and a URL or base64 payload. */ attachments?: ChannelAttachment[]; /** Wall-clock timestamp when the message was sent/received. */ timestamp: Date; } interface ChannelAttachment { /** Filename or identifier. */ name: string; /** MIME type (e.g. "image/png", "application/pdf"). */ mimeType: string; /** * URL pointing to the attachment, OR a `data:` URI with base64 payload. * Adapters that receive file uploads SHOULD store to object storage and * populate a stable URL here rather than embedding the raw bytes. */ url: string; /** Size in bytes, if known. */ sizeBytes?: number; } ``` --- ## Channel Translation Reference The following sections document how each supported channel maps its native message format to and from `ChannelMessage`. ### Matrix | ChannelMessage field | Matrix equivalent | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) | | `channelId` | Matrix room ID (`!roomid:homeserver`) | | `senderId` | Matrix user ID (`@user:homeserver`) | | `senderType` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound | | `content` | `event.content.body` | | `contentType` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` | | `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` | | `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` | | `attachments` | Populated from `url` in `m.image` / `m.file` events | | `timestamp` | `new Date(event.origin_server_ts)` | | `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` | **Outbound:** Adapter sends `m.room.message` with `msgtype = m.text` (or `m.notice` for system messages). Markdown content is sent with `format = org.matrix.custom.html` and a rendered HTML body. --- ### Discord | ChannelMessage field | Discord equivalent | | -------------------- | ----------------------------------------------------------------------- | | `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake | | `channelId` | Discord channel ID (snowflake string) | | `senderId` | Discord user ID (snowflake) | | `senderType` | `"user"` for human members; `"agent"` for bot messages | | `content` | `message.content` | | `contentType` | `"markdown"` (Discord uses a markdown-like syntax natively) | | `threadId` | `message.thread.id` when the message is inside a thread channel | | `replyToId` | Mosaic ID looked up from `message.referenced_message.id` | | `attachments` | `message.attachments` mapped to `ChannelAttachment` | | `timestamp` | `new Date(message.timestamp)` | | `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` | **Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag. --- ### Telegram | ChannelMessage field | Telegram equivalent | | -------------------- | ------------------------------------------------------------------------------------------------------------- | | `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) | | `channelId` | Telegram `chat_id` (integer as string) | | `senderId` | Telegram `from.id` (integer as string) | | `senderType` | `"user"` for human senders; `"agent"` for bot-originated messages | | `content` | `message.text` or `message.caption` | | `contentType` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` | | `threadId` | `message.message_thread_id` (for supergroup topics) | | `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` | | `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachment` | | `timestamp` | `new Date(message.date * 1000)` | | `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` | **Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentType = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`. --- ### TUI (Terminal UI) The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the channel protocol so that TUI sessions can be treated as a first-class channel. | ChannelMessage field | TUI equivalent | | -------------------- | ------------------------------------------------------------------ | | `id` | Generated UUID (TUI has no native message IDs) | | `channelId` | `"tui:"` — the active conversation ID | | `senderId` | Authenticated Mosaic `userId` | | `senderType` | `"user"` for human input; `"agent"` for agent replies | | `content` | Raw text from stdin / agent output | | `contentType` | `"text"` for input; `"markdown"` for agent responses | | `threadId` | Not used (TUI sessions are linear) | | `replyToId` | Not used | | `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs | | `timestamp` | `new Date()` at the moment of send | | `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` | **Outbound:** The adapter writes rendered content to stdout. Markdown is rendered via a terminal markdown renderer (e.g. `marked-terminal`). Code blocks are syntax-highlighted when `metadata.colorSupport = true`. --- ### WebUI The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`). | ChannelMessage field | WebUI equivalent | | -------------------- | ------------------------------------------------------------ | | `id` | Generated UUID; echoed back in the WebSocket event | | `channelId` | `"webui:"` | | `senderId` | Authenticated Mosaic `userId` | | `senderType` | `"user"` for browser input; `"agent"` for agent responses | | `content` | Message text from the input field | | `contentType` | `"text"` or `"markdown"` | | `threadId` | Not used (conversation model handles threading) | | `replyToId` | Message ID the user replied to (UI reply affordance) | | `attachments` | Files uploaded via the file picker; stored to object storage | | `timestamp` | `new Date()` at send, or server timestamp from event | | `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` | **Outbound:** Adapter emits a `chat:message` Socket.IO event. The WebUI React component receives it and appends to the conversation list. Markdown content is rendered client-side via the existing markdown renderer component. --- ## Identity Mapping `mapIdentity(channelUserId)` resolves a channel-native user identifier to a Mosaic `userId`. This is required to attribute inbound messages to authenticated Mosaic accounts. The implementation must query a `channel_identities` table (or equivalent) keyed on `(channel_name, channel_user_id)`. When no mapping exists the method returns `null` and the message is treated as anonymous (no Mosaic session context). ``` channel_identities channel_name TEXT -- e.g. "matrix", "discord" channel_user_id TEXT -- channel-native user identifier mosaic_user_id TEXT -- FK to users.id linked_at TIMESTAMP PRIMARY KEY (channel_name, channel_user_id) ``` Identity linking flows (OAuth dance, deep-link verification token, etc.) are out of scope for this document and will be specified in a separate identity-linking protocol document. --- ## Error Handling Conventions - `connect()` must throw a structured error (subclass of `ChannelConnectError`) if the initial connection cannot be established within a reasonable timeout (default: 10 s). - `sendMessage()` must throw `ChannelSendError` on terminal failures (auth revoked, channel not found). Transient failures (rate limit, network blip) should be retried internally with exponential backoff before throwing. - `health()` must never throw — it returns `{ status: 'disconnected' }` on error. - Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability. --- ## Versioning The `ChannelMessage` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide. Current version: **1.0.0**