Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
17 KiB
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:
IChannelAdapter— the interface each channel driver must implement.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
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<void>;
/**
* Gracefully disconnect from the channel backend.
* Must flush in-flight sends and release resources before resolving.
*/
disconnect(): Promise<void>;
/**
* 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>): 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<void>;
/**
* 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<string | null>;
}
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<Record<string, AdapterHealth>>
M7-002: ChannelMessage Protocol
Canonical Message Format
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<string, unknown>;
/**
* 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:<conversationId>" — 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:<conversationId>" |
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 ofChannelConnectError) if the initial connection cannot be established within a reasonable timeout (default: 10 s).sendMessage()must throwChannelSendErroron 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