Files
stack/docs/architecture/channel-protocol.md
2026-03-23 01:21:03 +00:00

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:

  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

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