- Wrap a Chat SDK adapter with
createChatSdkBridge()— the thin path. If your platform has a Chat SDK adapter (thechatnpm package and its@chat-adapter/*family), the bridge handles webhook/gateway plumbing, message chunking, cards, typing, and threads for you. Discord, Slack, and Telegram work this way. - Implement
ChannelAdapternatively — full control. You own the platform connection and translate messages yourself. WhatsApp and the built-in CLI channel work this way.
src/channels/ on the channels branch and are installed onto your instance via channel skills — see Channels overview for the install model.
The contract
The full interface, fromsrc/channels/adapter.ts:
What setup() receives
onInbound is the one you call for every platform message. Its InboundMessage payload carries:
The doc comment on
InboundMessage.isMention in src/channels/adapter.ts describes a router fallback that text-matches the agent group name — that comment is stale. No such fallback exists; pattern mode is the disambiguator (see evaluateEngage in src/router.ts).InboundEvent (used by onInboundEvent) adds an explicit channelType plus an optional replyTo: DeliveryAddress that redirects the agent’s reply elsewhere — a router-layer concept for admin transports like the CLI. Regular chat adapters never set it.
Registration
Adapters self-register on import by callingregisterChannelAdapter() at module top level. The factory contract, from src/channels/adapter.ts:
- The barrel —
src/channels/index.tsimports each channel module, which triggers itsregisterChannelAdapter()call. Main ships with only./cli.js; channel skills (/add-slack,/add-discord, …) copy their module from thechannelsbranch and append an import line. - Null factory = clean skip — return
nullwhen credentials are missing. The host logsChannel credentials missing, skippingand moves on, so an installed-but-unconfigured channel never crashes startup. - Setup retries — if
setup()throws an error whosename === 'NetworkError'(Chat SDK’s transient network error), the host retries after 2 s, 5 s, then 10 s before giving up. Any other error fails fast — bad tokens shouldn’t loop. A failed adapter is logged and skipped; other channels still start. containerConfig— optional extra mounts and env vars the container runner injects into agent containers for groups wired to this channel (e.g. mounting a media store).
Wiring defaults
A channel declares its default engagement and threading behavior throughdefaults?: ChannelDefaults — carried on both the registration entry (above) and the adapter itself. Because the registration copy is resolvable without constructing the adapter, offline creation paths read it when stamping wiring rows. Each declaration has a dm and a group context:
--engage-mode/--threads flags (see ncl wirings), and the per-context threads value is what a per-wiring --threads override is hard-ANDed against. Omitting defaults falls back to a core default keyed on supportsThreads (fallbackChannelDefaults in channel-registry.ts), so stale adapter copies keep working.
The Chat SDK path
createChatSdkBridge(config) from src/channels/chat-sdk-bridge.ts wraps a Chat SDK Adapter into a complete ChannelAdapter. What it handles for you:
- Inbound dispatch — wires all four SDK paths (subscribed threads, new mentions, DMs, plain messages) into
onInbound, with the platform-confirmedisMentionflag set correctly for each. - Webhook or gateway registration — gateway-capable adapters (Discord) get a supervised gateway listener with exponential backoff; everything else is registered on the shared webhook server at
/webhook/{routingPath}(portWEBHOOK_PORT, default 3000). The routing path defaults to the adapter name, so single-instance routes are unchanged; a second instance of the same platform passes aninstanceand registers on/webhook/{instance}with its own signing secret and Chat SDK state namespace. Modules can also claim a raw/webhook/{path}for non–Chat SDK webhooks viaregisterWebhookHandler()— raw routes take priority over adapter routes. - Chunking — set
maxTextLengthand outbound text longer than the platform limit is split on paragraph → line → word (space) → hard-character boundaries into multiple messages. Files ride on the first chunk, and the first chunk’s id is returned so edits and reactions still target the head of the reply. Without it, platforms like Discord (2000) and Telegram (4096) truncate silently. - Reply context — pass
extractReplyContextto pull quoted-reply text and sender out of the platform’s raw message. - Attachments — inbound attachments are downloaded and base64-embedded before serialization; outbound
message.filesare posted as uploads. - Cards and actions —
ask_user_questionrenders as a card with buttons; clicks are decoded and dispatched toonAction, and the card is edited to show the selected answer. - Typing, subscribe, openDM —
setTypingmaps to the SDK’s typing indicator,subscribeto the SDK state adapter, andopenDMis exposed when the underlying adapter implements it.
supportsThreads yourself — it’s a product decision, not something the bridge infers.
Worked example: Telegram
The core of the Telegram adapter on thechannels branch (src/channels/telegram.ts) is a factory this short:
resolveChannelName implementation against the Bot API, retry-wrapped setup, and a pairing interceptor around onInbound. None of that is required by the contract; the bridge alone is a working channel.
Checklist for a new adapter
This is what the/add-* channel skills automate — doing it by hand:
- Write
src/channels/<name>.tsmodeled on an existing adapter — Telegram or Slack for the Chat SDK path, WhatsApp orcli.tsfor native. End the module with a top-levelregisterChannelAdapter('<name>', { factory })call that returnsnullwhen credentials are missing. - Append
import './<name>.js';to the barrel,src/channels/index.ts. - Add platform dependencies to
package.json(e.g.@chat-adapter/<name>). - Build (
npm run build) and restart the service. - Verify registration: the log should show
Channel adapter startedwith your channel name — orChannel credentials missing, skippinguntil you add credentials.