- 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
Selected members ofsrc/channels/adapter.ts (see the source for the complete contract):
The
setThreadTitle doc comment above says “the router fires it once”; that comment is stale in source. The router only dispatches the session-created hook (below); the actual setThreadTitle caller is a channel-side module registered on that hook (Slack’s onboarding module on the channels branch). On a trunk-only install nothing calls it.What setup() receives
onInbound is the one you call for every platform message. Its InboundMessage payload carries:
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. A context can declare sessionMode: 'per-thread' when each platform thread is structurally its own conversation; creation then stamps both session_mode = 'per-thread' and threads = 1. Omitting sessionMode keeps shared. Omitting defaults falls back to core defaults keyed on supportsThreads, 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; non-gateway adapters are registered on the shared webhook server unless their runtime mode is
pollingat/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. - Raw-text recovery — pass
extractRawText(raw)to recover readable text that the SDK adapter leaves only inmessage.raw. Non-empty recovered text is appended to the serialized message body with a blank-line separator before the raw payload is discarded. Without an extractor, the body is unchanged. - 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.
Hooks for channel modules
A channel is often more than its adapter: the Slack agents feature, for example, ships host modules alongside the bridge. Rather than editing core, a channel-side module registers into these seams. The three runtime observation hooks (membership, session created, post-delivery) are fire-and-forget: a failing hook is logged and can never affect routing, delivery, or SDK dispatch. The other three are load-bearing by design: a throwing bridge inbound policy fails adapter setup,extendTool throws loudly on an unknown tool or a property collision, and a wizard pre-step’s return value pre-binds the skill’s inputs.
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.