43 KiB
Channel Protocol Architecture
Status: Official adapter baseline implemented by #756; extended registry/multiplexing remains iterative Authors: Mosaic Core Team Last Updated: 2026-07-14 Covers: M7-001 (OfficialChannelAdapter interface), M7-002 (ChannelMessageDto protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix)
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 implemented baseline is exported from @mosaicstack/types and consists of four contract groups:
OfficialChannelAdapter— transport lifecycle and connection health.ChannelMessageDto/ChannelAttachmentDto— canonical transport data.ChannelConversationRouteDto— stable logical-agent conversation and authorization address.ChannelResponseTargetDto— channel/thread destination for replies.
All channel-specific translation logic lives inside the adapter implementation. Runtime selection does not: gateway durable-session and provider services may rebind the logical session from Claude to Codex, Pi, OpenCode, or another harness without reconnecting the channel adapter.
M7-001: OfficialChannelAdapter Interface
interface OfficialChannelAdapter {
/** Stable, lowercase adapter identifier such as "discord" or "matrix". */
readonly name: string;
/** Establish both native-channel and gateway connections. */
start(): Promise<void>;
/** Gracefully close connections and release resources. */
stop(): Promise<void>;
/** Best-effort health; ordinary disconnection is a result, not an exception. */
health(): Promise<{
status: 'connected' | 'degraded' | 'disconnected';
detail?: string;
}>;
}
The small lifecycle seam lets the gateway host official plugins uniformly without moving native message translation into gateway core. Message ingress remains adapter-owned; gateway policy, durable session routing, auditing, and runtime/provider selection remain gateway-owned.
Stable conversation route
interface ChannelConversationRouteDto {
bindingId: string;
logicalAgentId: string;
conversationId: string;
channelName: string;
authorizationChannelId: string;
responseTarget: { channelId: string; threadId?: string };
}
Harness, provider, model, process, and native runtime-session identifiers are forbidden from this route. Runtime adapters consume the gateway's durable logical-session binding; channel adapters consume only the stable route and response target.
Typed ingress and egress ports
ChannelIngressPort is the transport-neutral direct-integration seam for official adapters. The current deployed Discord adapter preserves its existing HMAC-signed Socket.IO compatibility ingress so gateway-side service authentication, replay protection, approval handling, and correlation semantics remain unchanged; it normalizes the same ChannelIngressDto before signing. The adapter uses a supplied ChannelIngressPort directly when a future gateway registration provides one. New adapters must use the shared ports rather than adding channel branches to gateway core.
ChannelBindingDto contains the configuration-owned workspace/channel→logical-agent mapping and paired external principals; credentials are absent. After native allowlist, pairing, and role checks pass, an adapter submits ChannelIngressDto to ChannelIngressPort.receive(). It includes the normalized message, ChannelAuthorizedPrincipalDto, operation, correlation ID, native message ID, and stable route. Unauthorized input never reaches the port.
Gateway policy and runtime routing produce ChannelEgressDto, which ChannelEgressPort.send() delivers to the route's response target. Discord's existing HMAC envelope is its authenticated wire encoding of this boundary; future Matrix/Slack adapters use their native authenticated transports while preserving the same actor/operation/correlation semantics.
Adapter Registration
Adapters are registered with the gateway plugin host at startup. The host calls start()/stop() and may monitor health() on a configurable interval. A richer dynamic ChannelRegistry remains a compatible future extension of this lifecycle contract.
ChannelRegistry
└── register(adapter: OfficialChannelAdapter): void
└── getAdapter(name: string): OfficialChannelAdapter | null
└── listAdapters(): OfficialChannelAdapter[]
└── healthAll(): Promise<Record<string, AdapterHealth>>
M7-002: ChannelMessageDto Protocol
Canonical Message Format
interface ChannelMessageDto {
/**
* 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.
*/
channelName: string;
channelId: string;
/**
* Channel-native identifier of the message sender.
* For Mosaic-originated messages this is the Mosaic userId or agentId.
*/
senderId: string;
/** Sender classification. */
senderKind: '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
*/
contentKind: '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: Readonly<Record<string, ChannelMetadataValue>>;
/**
* 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?: readonly ChannelAttachmentDto[];
/** ISO-8601 wall-clock timestamp when the message was sent/received. */
timestamp: string;
}
interface ChannelAttachmentDto {
/** Channel-native attachment identifier. */
id: string;
/** Filename or display name. */
name: string;
/** MIME type when supplied by the channel. */
mimeType: string | null;
/**
* 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 ChannelMessageDto.
Matrix
| ChannelMessageDto field | Matrix equivalent |
|---|---|
id |
Generated UUID; metadata.channelMessageId = Matrix event ID ($...) |
channelId |
Matrix room ID (!roomid:homeserver) |
senderId |
Matrix user ID (@user:homeserver) |
senderKind |
Always "user" for inbound; "agent" or "system" for outbound |
content |
event.content.body |
contentKind |
"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
| ChannelMessageDto field | Discord equivalent |
|---|---|
id |
Generated UUID; metadata.channelMessageId = Discord message snowflake |
channelId |
Discord channel ID (snowflake string) |
senderId |
Discord user ID (snowflake) |
senderKind |
"user" for human members; "agent" for bot messages |
content |
message.content |
contentKind |
"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 ChannelAttachmentDto |
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 contentKind = "code" the adapter wraps in triple-backtick fences with the metadata.language tag.
Discord routing and thread policy
A configured Discord binding maps (guildId, parentChannelId) to a stable logical agent and a trusted gateway agent-config ID. Gateway verifies that configuration's name matches the binding logical agent before session creation. The stable conversation handle is derived from logical agent plus response channel/thread and never includes the active harness, provider, model, process, or agent-config ID.
| Inbound location/trigger | Conversation and response target |
|---|---|
| Authorized untagged parent-channel message | Parent channel; response is sent in-channel |
| Authorized bot mention in parent channel | Thread already attached to that message, or a new public thread |
| Authorized message already in a thread | Existing thread; no repeated mention and no nested thread |
/approve or /stop <approval> |
Current parent/thread durable session; no new topic is created |
Authorization order is fixed: guild allowlist → parent-channel allowlist → user allowlist → configured binding/pairing → operation role → per-user/channel message and thread rate limits → thread creation/dispatch. A normal Discord channel's category parent is never treated as the thread authorization parent. If requested thread creation fails, dispatch does not occur because the adapter cannot honor the response target.
Discord service ingress security
The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents DISCORD_SERVICE_TOKEN during its /chat connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads.
Telegram
| ChannelMessageDto 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) |
senderKind |
"user" for human senders; "agent" for bot-originated messages |
content |
message.text or message.caption |
contentKind |
"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 ChannelAttachmentDto |
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 contentKind = "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.
| ChannelMessageDto field | TUI equivalent |
|---|---|
id |
Generated UUID (TUI has no native message IDs) |
channelId |
"tui:<conversationId>" — the active conversation ID |
senderId |
Authenticated Mosaic userId |
senderKind |
"user" for human input; "agent" for agent replies |
content |
Raw text from stdin / agent output |
contentKind |
"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).
| ChannelMessageDto field | WebUI equivalent |
|---|---|
id |
Generated UUID; echoed back in the WebSocket event |
channelId |
"webui:<conversationId>" |
senderId |
Authenticated Mosaic userId |
senderKind |
"user" for browser input; "agent" for agent responses |
content |
Message text from the input field |
contentKind |
"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
Gateway identity-linking policy resolves a channel-native user identifier to a Mosaic userId and produces ChannelAuthorizedPrincipalDto. Adapters provide native identity evidence but cannot self-authorize Mosaic scope. Discord currently uses configuration-owned paired users; database-backed linking remains the canonical direction for dynamic Matrix/Slack identity.
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
start()must establish the native channel transport or throw a structured connection error. An adapter hosted inside the gateway must not wait for a loopback connection to that same not-yet-listening process; it starts the native transport, lets Socket.IO reconnect, and reportsdegradeduntil both links are ready.ChannelEgressPort.send()implementations must throw a typed terminal error for revoked auth, an invalid route, or a missing channel. Only transient rate/network/server failures are retried with bounded exponential backoff; Discord retries reuse a stable enforced nonce to prevent duplicate chunks, while permanent 4xx failures are not retried.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 ChannelMessageDto 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
M7-003: Matrix Integration Design
Homeserver Choice
Mosaic uses Conduit as the Matrix homeserver. Conduit is written in Rust, ships as a single binary, and has minimal operational overhead compared to Synapse or Dendrite. It supports the full Matrix Client-Server and Application Service APIs required by Mosaic.
Recommended deployment: Conduit runs as a Docker container alongside the Mosaic stack. A single Conduit instance is sufficient for most self-hosted deployments. Conduit's embedded RocksDB storage means no separate database is required for the homeserver itself.
Appservice Registration
Mosaic registers with the Conduit homeserver as a Matrix Application Service (appservice). This gives Mosaic the ability to:
- Create and control ghost users (virtual Matrix users representing Mosaic agents and provisioned accounts).
- Receive all events sent to rooms within the appservice's namespace without polling.
- Send events on behalf of ghost users without separate authentication.
Registration is done via a YAML registration file (mosaic-appservice.yaml) placed in Conduit's configuration directory:
id: mosaic
url: http://gateway:3000/_matrix/appservice
as_token: <random-secret>
hs_token: <random-secret>
sender_localpart: mosaic-bot
namespaces:
users:
- exclusive: true
regex: '@mosaic_.*:homeserver'
rooms:
- exclusive: false
regex: '.*'
aliases:
- exclusive: true
regex: '#mosaic-.*:homeserver'
The gateway exposes /_matrix/appservice endpoints to receive push events from Conduit. The as_token and hs_token are stored in Vault and injected at startup.
Room ↔ Conversation Mapping
Each Mosaic conversation maps to a single Matrix room. The mapping is stored in the database:
conversation_matrix_rooms
conversation_id TEXT -- FK to conversations.id
room_id TEXT -- Matrix room ID (!roomid:homeserver)
created_at TIMESTAMP
PRIMARY KEY (conversation_id)
Room creation is handled by the appservice on the first Matrix access to a conversation. Room names follow the pattern Mosaic: <conversation title>. Room topics contain the conversation ID for correlation.
When a conversation is deleted or archived in Mosaic, the corresponding Matrix room is tombstoned (m.room.tombstone event) and the room is left in a read-only state.
Space ↔ Team Mapping
Each Mosaic team maps to a Matrix Space. Spaces are Matrix rooms with a special m.space type that can contain child rooms.
team_matrix_spaces
team_id TEXT -- FK to teams.id
space_id TEXT -- Matrix room ID of the Space
created_at TIMESTAMP
PRIMARY KEY (team_id)
When a conversation room is shared with a team, the appservice adds it to the team's Space via m.space.child state events. Removing the share removes the child relationship.
Agent Ghost Users
Each Mosaic agent is represented in Matrix as an appservice ghost user:
- Matrix user ID format:
@mosaic_agent_<agentId>:homeserver - Display name: the agent's human-readable name (e.g. "Mosaic Assistant")
- Avatar: optional, configurable per agent
Ghost users are registered lazily — the appservice creates the ghost on first use. Ghost users are controlled exclusively by the appservice; they cannot log in via Matrix client credentials.
When an agent sends a message via the gateway, the Matrix adapter sends the event using user_id impersonation on the appservice's client endpoint, causing the message to appear as if sent by the ghost user.
Power Levels
Power levels in each Mosaic-managed room are set as follows:
| Entity | Power Level | Rationale |
|---|---|---|
Mosaic appservice bot (@mosaic-bot) |
100 (Admin) | Room management and moderation |
| Human Mosaic users | 50 (Moderator) | Can kick, redact, and invite |
| Agent ghost users | 0 (Default) | Message-only; cannot modify room state |
This arrangement ensures human users retain full control. An agent cannot modify room settings, kick members, or take administrative actions. Humans with moderator power can redact agent messages and intervene in ongoing conversations.
mermaid
graph TD
A[Mosaic Admin] -->|invites| B[Human User]
B -->|joins| C[Matrix Room / Conversation]
D[Agent Ghost User] -->|sends messages to| C
B -->|can redact/kick| D
E[Mosaic Bot] -->|manages room state| C
style A fill:#4a9eff
style B fill:#4a9eff
style D fill:#aaaaaa
style E fill:#ff9944
M7-004: Conversation Multiplexing
Architecture Overview
A single Mosaic conversation can be accessed simultaneously from multiple surfaces: TUI, WebUI, and Matrix. The gateway is the single source of truth for all conversation state. Each surface is a thin client that renders gateway-owned data.
┌─────────────────────────────────────────────────────┐
│ Gateway (NestJS) │
│ │
│ ConversationService ←→ MessageBus │
│ │ │ │
│ [DB: PostgreSQL] [Fanout: Valkey Pub/Sub] │
│ │ │
│ ┌─────────────────────┼──────────────┐ │
│ │ │ │ │
│ Socket.IO Socket.IO Matrix │ │
│ (TUI adapter) (WebUI adapter) (appservice)│ │
└──────────┼─────────────────────┼──────────────┘ │
│ │ │
CLI/TUI Browser Matrix
Client
Real-Time Sync Flow
- A message arrives on any surface (TUI keystroke, browser send, Matrix event).
- The surface's adapter normalizes the message to
ChannelMessageDtoand delivers it toConversationService. ConversationServicepersists the message to PostgreSQL, assigns a canonicalid, and publishes amessage:newevent to the Valkey pub/sub channel keyed byconversationId.- All active surfaces subscribed to that
conversationIdreceive the fanout event and push it to their respective clients:- TUI adapter: writes rendered output to the connected terminal session.
- WebUI adapter: emits a
chat:messageSocket.IO event to all browser sessions joined to that conversation. - Matrix adapter: sends an
m.room.messageevent to the conversation's Matrix room.
This ensures that a message typed in the TUI appears in the browser and in Matrix within the same round-trip latency as the Valkey fanout (typically <10 ms on co-located infrastructure).
Surface-to-Transport Mapping
| Surface | Transport to Gateway | Fanout Transport from Gateway |
|---|---|---|
| TUI | HTTPS REST + SSE or WebSocket | Socket.IO over stdio proxy |
| WebUI | Socket.IO (browser) | Socket.IO emit |
| Matrix | Matrix Client-Server API (appservice push) | Matrix m.room.message send |
Conflict Resolution
- Messages: Append-only. Messages are never edited in-place in Mosaic's canonical store. Matrix edit events (
m.replace) are treated as new messages withreplyToIdpointing to the original, preserving the full audit trail. - Metadata (title, tags, archived state): Last-write-wins. The timestamp of the most recent write wins. Concurrent metadata updates from different surfaces are serialized through
ConversationService; the final database write reflects the last persisted value. - Conversation membership: Set-merge semantics. Adding a user from any surface is additive. Removal requires an explicit delete action and is not overwritten by concurrent adds.
Session Isolation
Multiple TUI sessions or browser tabs connected to the same conversation receive all fanout messages independently. Each session maintains its own scroll position and local ephemeral state (typing indicator, draft text). Gateway does not synchronize ephemeral state across sessions.
M7-005: Remote Auth Bridging
Overview
Matrix users authenticate to Mosaic by linking their Matrix identity to an existing Mosaic account. There are two flows: token linking (primary) and OAuth bridge (alternative). Once linked, the Matrix session is persistent — there is no periodic login/logout cycle.
Token Linking Flow
- A Mosaic admin or the user themselves generates a short-lived link token via the Mosaic web UI or API (
POST /auth/channel-link-token). The token is a cryptographically random 32-byte hex string with a 15-minute TTL stored in Valkey. - The user opens a Matrix client and sends a DM to
@mosaic-bot:homeserver. - The user sends the command:
!link <token> - The appservice receives the
m.room.messageevent in the DM room, extracts the token, and callsAuthService.linkChannelIdentity({ channel: 'matrix', channelUserId: matrixUserId, token }). AuthServicevalidates the token, retrieves the associatedmosaicUserId, and writes a row tochannel_identities.- The appservice sends a confirmation reply in the DM room and invites the now-linked user to their personal Matrix Space.
User (Matrix) @mosaic-bot Mosaic Gateway
│ │ │
│ DM: !link <token> │ │
│────────────────────▶│ │
│ │ POST /auth/link │
│ │─────────────────────▶│
│ │ 200 OK │
│ │◀─────────────────────│
│ ✓ Linked! Joining │ │
│ your Space now │ │
│◀────────────────────│ │
OAuth Bridge Flow
An alternative flow for users who prefer browser-based authentication:
- The Mosaic bot sends the user a Matrix message containing an OAuth URL:
https://mosaic.example.com/auth/matrix-link?state=<nonce>&matrix_user=<encoded_mxid> - The user opens the URL in a browser. If not already logged in to Mosaic, they are redirected through the standard BetterAuth login flow.
- On successful authentication, Mosaic records the
channel_identitiesrow linkingmatrix_userto the authenticatedmosaicUserId. - The gateway sends a Matrix event to the pending DM room confirming the link.
Invite-Based Provisioning
When a Mosaic admin adds a new user account, the provisioning flow optionally associates a Matrix user ID with the new account at creation time:
- Admin provides
matrixUserIdwhen creating the account (POST /admin/users). UserServicewrites thechannel_identitiesrow immediately.- The Matrix adapter's provisioning hook fires, and the appservice:
- Creates the user's personal Matrix Space (if not already existing).
- Sends an invite to the Matrix user for their personal Space.
- Sends a welcome DM from
@mosaic-botwith onboarding instructions.
The invited user does not need to complete any linking step — the association is pre-established by the admin.
Session Lifecycle
Matrix sessions for linked users are persistent and long-lived. Unlike TUI sessions (which terminate when the terminal process exits), a Matrix user's access to their rooms remains intact as long as:
- Their Mosaic account is active (not suspended or deleted).
- Their
channel_identitiesrow exists (link not revoked). - They remain members of the relevant Matrix rooms.
Revoking a Matrix link (DELETE /auth/channel-link/matrix/<matrixUserId>) removes the channel_identities row and causes gateway principal resolution to deny the identity. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off).
M7-006: Agent-to-Agent Communication via Matrix
Dedicated Agent Rooms
When two Mosaic agents need to coordinate, a dedicated Matrix room is created for their dialogue. This provides a persistent, auditable channel for structured inter-agent communication that humans can observe.
Room naming convention:
#mosaic-agents-<agentA>-<agentB>:homeserver
Where agentA and agentB are the Mosaic agent IDs sorted lexicographically (to ensure the same room is used regardless of which agent initiates). The room alias is registered by the appservice.
agent_rooms
room_id TEXT -- Matrix room ID
agent_a_id TEXT -- FK to agents.id (lexicographically first)
agent_b_id TEXT -- FK to agents.id (lexicographically second)
created_at TIMESTAMP
PRIMARY KEY (agent_a_id, agent_b_id)
Room Membership and Power Levels
| Entity | Power Level |
|---|---|
| Mosaic appservice bot | 100 (Admin) |
| Human observers (invited) | 50 (Moderator, read-only by default) |
| Agent ghost users (agentA, agentB) | 0 (Default — message send only) |
Humans are invited to agent rooms with a read-only intent. By convention, human messages in agent rooms are prefixed with [HUMAN] and treated as interrupts by the gateway. Agents are instructed (via system prompt) to pause and acknowledge human messages before resuming their dialogue.
Message Format
Agents communicate using structured JSON embedded in Matrix event content. The Matrix event type is m.room.message with msgtype: "m.text" for compatibility. The structured payload is carried in a custom mosaic.agent_message field:
{
"msgtype": "m.text",
"body": "[Agent message — see mosaic.agent_message for structured content]",
"mosaic.agent_message": {
"schema_version": "1.0",
"sender_agent_id": "agent_abc123",
"conversation_id": "conv_xyz789",
"message_type": "request",
"payload": {
"action": "summarize",
"parameters": { "max_tokens": 500 },
"reply_to_event_id": "$previousEventId"
},
"timestamp_ms": 1711234567890
}
}
The body field contains a human-readable fallback so the conversation is legible in any Matrix client. The structured payload is parsed exclusively by the gateway's Matrix adapter.
Coordination Patterns
Request/Response: Agent A sends a message_type: "request" event. Agent B sends a message_type: "response" with reply_to_event_id referencing Agent A's event. The gateway correlates request/response pairs using the event IDs.
Broadcast: An agent sends a message_type: "broadcast" to a multi-agent room (more than two members). All agents in the room receive the event. No response is expected.
Delegation: Agent A sends a message_type: "delegate" with a payload.task object describing work to be handed off to Agent B. Agent B acknowledges with message_type: "delegate_ack" and later sends message_type: "delegate_complete" when done.
AgentA Gateway AgentB
│ delegate(task) │ │
│────────────────────▶│ │
│ │ Matrix event push │
│ │────────────────────▶│
│ │ delegate_ack │
│ │◀────────────────────│
│ │ [AgentB executes] │
│ │ delegate_complete │
│ │◀────────────────────│
│ task result │ │
│◀────────────────────│ │
Gateway Mediation
Agents do not call the Matrix Client-Server API directly. All inter-agent Matrix events are sent and received by the gateway's appservice. This means:
- The gateway can intercept, log, and rate-limit agent-to-agent messages.
- Agents that are offline (no active process) still have their messages delivered; the gateway queues them and delivers on the agent's next activation.
- The gateway can inject system messages (e.g. human interrupts, safety stops) into agent rooms without agent cooperation.
M7-007: Multi-User Isolation in Matrix
Space-per-Team Architecture
Isolation in Matrix is enforced through the Space hierarchy. Each organizational boundary in Mosaic maps to a distinct Matrix Space:
| Mosaic entity | Matrix Space | Visibility |
|---|---|---|
| Personal workspace (per user) | Personal Space | User only |
| Team | Team Space | Team members only |
| Public project | (no Space) | Configurable |
Rooms (conversations) are placed into Spaces based on their sharing configuration. A room can appear in at most one team Space at a time. Moving a room from one team Space to another removes the m.space.child link from the old Space and adds it to the new one.
Room Visibility Rules
Matrix room visibility within Conduit is controlled by:
- Join rules: All Mosaic-managed rooms use
join_rule: invite. Users cannot discover or join rooms without an explicit invite from the appservice. - Space membership: Rooms appear in a Space's directory only to users who are members of that Space.
- Room directory: The server room directory is disabled for Mosaic-managed rooms (
m.room.history_visibility: sharedfor team rooms,m.room.history_visibility: invitedfor personal rooms).
Personal Space Defaults
When a user account is created (or linked to Matrix), the appservice provisions a personal Space:
- Space name:
<username>'s Space - All conversations the user creates personally are added as children of their personal Space.
- No other users are members of this Space by default.
- Conversation rooms within the personal Space are only visible and accessible to the owner.
Team Shared Rooms
When a project or conversation is shared with a team:
- The appservice adds the room as a child of the team's Space (
m.space.childstate event in the Space room,m.space.parentstate event in the conversation room). - All current team members are invited to the conversation room.
- Newly added team members are automatically invited to all shared rooms in the team's Space by the appservice's team membership hook.
- If sharing is revoked, the appservice removes the
m.space.childlink and kicks all team members who joined via the team share (users who were directly invited are unaffected).
Encryption
Encryption is optional and configured per room at creation time. Recommended defaults:
| Space type | Encryption default | Rationale |
|---|---|---|
| Personal Space | Enabled | Privacy-first for individual users |
| Team Space | Disabled | Operational visibility; admin auditing |
| Agent rooms | Disabled | Gateway must read structured payloads |
When encryption is enabled, the appservice's ghost users must participate in key exchange (using Matrix's Olm/Megolm protocol). The gateway holds the device keys for all ghost users it controls. This constraint means encrypted rooms require the gateway to be the E2E session holder — messages are end-to-end encrypted between human clients and gateway-held ghost device keys, not between human clients themselves.
Admin Visibility
A Conduit server administrator can see:
- Room metadata: names, aliases, topic, membership list.
- Unencrypted event content in unencrypted rooms.
A Conduit server administrator cannot see:
- Content of encrypted rooms (without holding a device key for a room member).
Mosaic does not grant gateway admin credentials to application-level admin users. The Conduit admin interface is restricted to infrastructure operators. Application-level admins manage users and rooms through the Mosaic API, which interacts with the appservice layer only.
Data Retention
Matrix events in Mosaic-managed rooms follow Mosaic's configurable retention policy:
room_retention_policies
room_id TEXT -- Matrix room ID (or wildcard pattern)
retention_days INT -- NULL = keep forever
applies_to TEXT -- "personal" | "team" | "agent" | "all"
created_at TIMESTAMP
The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical ChannelMessageDto record unless the Mosaic retention policy also covers it.
Default retention values:
| Room type | Default retention |
|---|---|
| Personal conversation rooms | 365 days |
| Team conversation rooms | 730 days |
| Agent-to-agent rooms | 90 days |
| System/audit rooms | 1825 days (5 years) |
Retention settings are configurable by Mosaic admins via the admin API and apply to both the Matrix event store and the Mosaic message store in lockstep.