Files
stack/docs/DEVELOPER-GUIDE/integrations/channel-adapters.md
T
2026-08-13 17:56:13 +00:00

17 KiB

Channel adapters

Status: Current shared channel types plus the Discord reference/compatibility implementation. A shared gateway adapter registry, Telegram parity, and Matrix channel integration remain unimplemented or unproven.

Last verified: 2026-08-10 against the source and focused tests listed in Evidence.

Audience: Developers implementing or reviewing channel integrations.

The gateway remains the policy and runtime boundary. Channel code translates native events, applies its native admission checks, and delivers normalized ingress/egress; it must not choose a provider, harness, process, or native runtime session on behalf of the gateway.

Current source boundaries

Boundary Current authority Current claim
Shared contract packages/types/src/channel/channel.dto.ts, channel-adapter.ts DTOs, ports, lifecycle health, and delivery error codes exported by @mosaicstack/types.
Discord translation plugins/discord/src/index.ts First adapter implementing the shared lifecycle/egress seam, with an optional direct ingress port and a tested Socket.IO compatibility path.
Discord behavior plugins/discord/src/index.test.ts Current allowlist, pairing, role, thread, route, attachment, replay-envelope, egress, retry, and health behavior.
Gateway compatibility apps/gateway/src/chat/chat.gateway.ts, chat.gateway-auth.ts /chat Socket.IO service/session authentication, signed Discord envelope validation, trusted binding selection, raw chat dispatch, and raw stream egress.
Host registry apps/gateway/src/plugin/plugin.interface.ts, plugin.module.ts, plugin.service.ts Lifecycle-only IChannelPlugin[] hosting. This is not a universal OfficialChannelAdapter registry.
Telegram plugins/telegram/src/index.ts, package.json Raw legacy Telegraf/Socket.IO behavior only; no shared-contract or authenticated gateway parity.
Matrix No current channel adapter boundary in the cited implementation Matrix-related fleet/runtime code is not evidence of a gateway channel adapter.

Executable source and tests outrank older architecture or quarantine pages. The canonical architecture summary is architecture/channel-protocol.md.

Shared contract

@mosaicstack/types currently exports the channel types through packages/types/src/channel/index.ts and packages/types/src/index.ts.

The lifecycle and port seams are:

interface OfficialChannelAdapter {
  readonly name: string;
  start(): Promise<void>;
  stop(): Promise<void>;
  health(): Promise<ChannelAdapterHealthDto>;
}

interface ChannelIngressPort {
  receive(ingress: ChannelIngressDto): Promise<void>;
}

interface ChannelEgressPort {
  send(egress: ChannelEgressDto): Promise<void>;
}

The current DTO boundary includes:

  • ChannelMessageDto for normalized native messages and JSON-safe metadata;
  • ChannelAttachmentDto for bounded external attachment references;
  • ChannelAuthorizedPrincipalDto for the already-authorized channel actor and viewer/operator/admin role;
  • ChannelBindingDto for configuration-owned workspace/channel/logical-agent binding;
  • ChannelResponseTargetDto for a channel and optional thread;
  • ChannelConversationRouteDto for binding, logical agent, stable conversation ID, authorization channel, and response target;
  • ChannelIngressDto for correlation, native message ID, operation, principal, message, and route; and
  • ChannelEgressDto for correlation, normalized output, and route.

Current operations are message.send, approval.create, and session.stop. Current delivery errors are invalid_route, destination_unavailable, and delivery_failed; there is no shared revoked-auth error code or executable protocol-version contract in this surface.

Stable route rule

The Discord adapter derives:

<logical-agent-id>:discord:<response-channel-id>

The binding address is derived from the configured guild, parent channel, and logical-agent instance. The route intentionally omits runtime provider, harness, model, process, and native runtime-session identifiers. Gateway provider/runtime layers own those identities; durable-session ownership applies only after explicit enrollment and is not implied by the external route.

This is a route-integrity rule, not proof that the gateway has a universal channel session API. A future adapter must derive its route from trusted configuration and native channel/thread identity; it must not accept a caller-selected logical agent or runtime target.

Current Discord implementation

Native ingress

DiscordPlugin currently:

  1. ignores bot-authored and non-guild messages;
  2. resolves a thread's actual parent text channel, while leaving normal category parents out of authorization;
  3. applies default-deny guild, parent-channel, and user allowlists;
  4. resolves configuration-owned instanceId/agentConfigId bindings and paired-user roles before thread creation or dispatch;
  5. applies message and mention-thread limits before side effects;
  6. creates/reuses a mention thread or preserves an existing thread target; and
  7. maps the event to a ChannelIngressDto when an ingressPort dependency is supplied.

The normalized message uses channelName: "discord", the response target as channelId, senderKind: "user", markdown for non-empty text, image/file for attachment-only input, mapped attachments, and metadata containing the native channel message ID and guild ID. It does not currently claim a universal metadata shape for mentions, embeds, channel type, or replies.

Socket.IO compatibility path

The gateway-hosted plugin is currently constructed without a direct ChannelIngressPort in plugin.module.ts, so it uses the established compatibility path:

  1. start() connects to ${DISCORD_GATEWAY_URL}/chat with auth.discordServiceToken.
  2. Normal sends emit a signed message envelope; approval and stop emit discord:approve and discord:stop envelopes.
  3. The HMAC-SHA-256 signature covers the ordered Discord payload using DISCORD_SERVICE_TOKEN.
  4. The gateway verifies the service token/signature, allowlists, binding/operation role, stable conversation ID, attachment bounds, and replay key before dispatch.
  5. The gateway selects the binding's trusted agentConfigId and verifies its name equals the logical-agent instance. It attempts normal conversation binding/persistence before dispatch, but the external route is not a UUID and no route-to-UUID mapping currently proves durable ordinary-chat history.
  6. Agent output currently returns as raw Socket.IO agent:start, agent:text, and agent:end events. The plugin buffers the text and calls its typed Discord egress at stream end.

This compatibility path carries enough normalized identity to preserve current security and routing behavior, but it is not a gateway-produced ChannelIngressDto/ChannelEgressDto flow through a shared host registry. The gateway can continue live dispatch after persistence failure; adapter authors must not claim durable continuity until external routes map to UUID conversations and fresh-message restart tests pass.

Egress and health

DiscordPlugin.send() is a typed ChannelEgressPort implementation. It validates the route and message alignment before destination lookup, sends at a 1,900-character boundary, retries transient 429/5xx/network failures up to three times, uses one deterministic enforced nonce per correlation/chunk, and does not retry permanent failures. It reports connected, degraded, or disconnected from Discord client readiness and gateway socket connectivity.

The host wrapper currently exposes only name, start, stop, and optional project provisioning. It does not expose health(), inject the direct ingress port, produce ChannelEgressDto values from the gateway, or provide healthAll/getAdapter semantics.

Adapter authoring boundary

For a future official adapter, preserve these current architectural boundaries:

  1. Normalize native messages to the shared DTOs and preserve native message ID, correlation ID, channel/thread identity, attachments, and response target.
  2. Enforce native guild/room/channel/user/pairing/role policy before thread/room creation or gateway dispatch.
  3. Select the logical agent from trusted configuration; never from a caller-controlled provider, model, harness, or route field.
  4. Keep the route stable when the gateway changes runtime provider or harness.
  5. Validate egress route and destination before sending; bound chunks, retries, and attachment metadata.
  6. Return sanitized errors and expose non-throwing lifecycle health for ordinary disconnected state.
  7. Add happy-path and failure-path tests for authorization ordering, replay/idempotency, route integrity, native side effects, egress, reconnect, and health.

These are implementation constraints for future work, not evidence that the missing shared registry already exists.

Parity status

Telegram: raw legacy adapter, not shared parity

The current Telegram source:

  • launches Telegraf and a Socket.IO client;
  • reads TELEGRAM_BOT_TOKEN and TELEGRAM_GATEWAY_URL through the gateway plugin factory, whose URL default is http://localhost:14242;
  • accepts text messages only and ignores attachment-only messages;
  • maps each Telegram chat.id to telegram-<chatId>;
  • emits a raw { conversationId, content, role: "user" } object rather than ChannelIngressDto;
  • has no shared DTO import, channel binding, principal/role policy, native message ID, attachment mapping, route-safe egress, or health method; and
  • has no package test file in this checkout; its script is vitest run --passWithNoTests.

Its Socket.IO connection does not send the Discord service token or a BetterAuth session. A configured TELEGRAM_BOT_TOKEN therefore must not be described as an authenticated official channel. Shared Telegram parity requires a separate implementation and focused security/contract tests.

Matrix: no current channel adapter

No current gateway adapter, shared-port wiring, channel binding, identity resolver, persistence boundary, authentication path, or focused channel test establishes Matrix as a Mosaic channel. Matrix code elsewhere in the repository belongs to other transport/runtime work and must not be promoted into channel-adapter instructions without a separate contract and evidence.

Shared registry: draft/unimplemented

The current PLUGIN_REGISTRY is an array of IChannelPlugin lifecycle wrappers. It is not a registry of OfficialChannelAdapter instances and does not inject ChannelIngressPort/ChannelEgressPort, aggregate adapter health, or remove the gateway's Discord-specific auth/envelope/approval/stop/replay branches.

A future registry must specify binding and credential ownership, ingress/egress injection, health/error semantics, compatibility with the existing Socket.IO clients, and tests proving that adapters cannot bypass gateway authorization or route validation before it can be documented as current architecture.

Safe verification commands

The focused package commands used by the current Discord evidence are:

pnpm --filter @mosaicstack/types build
pnpm --filter @mosaicstack/types typecheck
pnpm --filter @mosaicstack/discord-plugin typecheck
pnpm --filter @mosaicstack/discord-plugin lint
pnpm --filter @mosaicstack/discord-plugin test
cd apps/gateway && pnpm exec vitest run \
  src/plugin/discord-ingress.security.spec.ts \
  src/chat/chat.gateway-redaction.spec.ts \
  src/__tests__/integration/tess-cross-surface.integration.test.ts

These commands do not require starting Gateway, Discord, Telegram, Matrix, a queue, or a database. The Discord package test includes its configured coverage thresholds; the gateway command is a focused Vitest run rather than a claim of full repository integration coverage.

Evidence