A channel adapter bridges NanoClaw with a messaging platform. There are two ways to build one:
- Wrap a Chat SDK adapter with
createChatSdkBridge() — the thin path. If your platform has a Chat SDK adapter (the chat npm 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
ChannelAdapter natively — full control. You own the platform connection and translate messages yourself. WhatsApp and the built-in CLI channel work this way.
Either way, adapters live in 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, from src/channels/adapter.ts:
/** The v2 channel adapter contract. */
export interface ChannelAdapter {
name: string;
channelType: string;
/**
* Whether this adapter models conversations as threads.
*
* true — adapter's platform uses threads as the primary conversation unit
* (Discord, Slack, Linear, GitHub). One thread = one session; the
* agent replies into the originating thread.
* false — adapter's platform treats the channel itself as the conversation
* (Telegram, WhatsApp, iMessage). Thread ids are stripped at the
* router; agent replies go to the channel.
*/
supportsThreads: boolean;
// Lifecycle
setup(config: ChannelSetup): Promise<void>;
teardown(): Promise<void>;
isConnected(): boolean;
// Outbound delivery — returns the platform message ID if available
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<string | undefined>;
// Optional
setTyping?(platformId: string, threadId: string | null): Promise<void>;
syncConversations?(): Promise<ConversationInfo[]>;
resolveChannelName?(platformId: string): Promise<string | null>;
/**
* Subscribe the bot to a thread so follow-up messages route via the
* platform's "subscribed message" path (onSubscribedMessage in Chat SDK).
* Called by the router when a mention-sticky wiring first engages in a
* thread. Idempotent: calling twice on the same thread is a no-op.
*
* Platforms without a subscription concept can omit this; the router
* treats absence as a no-op.
*/
subscribe?(platformId: string, threadId: string): Promise<void>;
/**
* Open (or fetch) a DM with this user, returning the platform_id of the
* resulting DM channel. Called by the host on demand to initiate cold
* DMs — approvals, pairing handshakes, host-initiated notifications — to
* users who may never have messaged the bot themselves.
*
* Omit this method on channels where the user handle IS already the DM
* chat id (Telegram, WhatsApp, iMessage, email, Matrix). Callers will
* fall through to using the handle directly.
*
* For channels that distinguish user id from DM channel id (Discord,
* Slack, Teams, Webex, gChat): implement by delegating to Chat SDK's
* chat.openDM, which hits the platform's idempotent open-DM endpoint.
* Returning the same platform_id on repeated calls is expected.
*/
openDM?(userHandle: string): Promise<string>;
}
How the host calls each member:
| Member | Required | How the host uses it |
|---|
name / channelType | Yes | name labels logs; channelType keys the adapter in the active-adapter map and in messaging_groups.channel_type. The bridge sets both to the Chat SDK adapter’s name. |
supportsThreads | Yes | true: one thread = one session, replies go to the originating thread. false: the router strips thread ids and the channel itself is the conversation. This shapes session identity. |
setup(config) | Yes | Called once at startup by initChannelAdapters(). Connect to the platform here and hold on to config — its callbacks are how you hand messages to the host. |
teardown() | Yes | Called on shutdown by teardownChannelAdapters(). Close connections; errors are logged, not fatal. |
isConnected() | Yes | Part of the contract, but not currently called by the host outside tests. The bridge always returns true; native adapters should still report real connection state. |
deliver(platformId, threadId, message) | Yes | Called by the delivery module for every outbound message in a wired group. message.content is parsed JSON from the session outbox (messages_out), message.files carries attachments. Return the platform message id so later edits and reactions can target it. |
setTyping? | No | Called by the typing module while an agent is working, and re-fired on an interval to keep platforms’ short-lived typing indicators alive. |
syncConversations? | No | Returns discovered conversations (platformId, name, isGroup) for bulk import. |
resolveChannelName? | No | Resolves a platform id to a human-readable name on demand. |
subscribe? | No | See the doc comment — mention-sticky wirings call it when they first engage in a thread. |
openDM? | No | See the doc comment — only needed when the user id and the DM channel id differ. |
What setup() receives
/** Passed to the adapter at setup time. */
export interface ChannelSetup {
/** Called when an inbound message arrives from the platform. */
onInbound(platformId: string, threadId: string | null, message: InboundMessage): void | Promise<void>;
/**
* Called by admin-transport adapters (CLI) that want to route a message to
* an arbitrary channel/platform and optionally redirect replies elsewhere.
* Regular chat adapters should use `onInbound`; `onInboundEvent` skips the
* adapter-channel-type injection so the caller can target any wired mg.
*/
onInboundEvent(event: InboundEvent): void | Promise<void>;
/** Called when the adapter discovers metadata about a conversation. */
onMetadata(platformId: string, name?: string, isGroup?: boolean): void;
/** Called when a user clicks a button/action in a card (e.g., ask_user_question response). */
onAction(questionId: string, selectedOption: string, userId: string): void;
}
onInbound is the one you call for every platform message. Its InboundMessage payload carries:
| Field | Type | Meaning |
|---|
id | string | Platform message id. |
kind | 'chat' | 'chat-sdk' | Payload shape marker — 'chat-sdk' for bridge messages, 'chat' for native adapters. |
content | unknown | A JS object; the host JSON.stringifys it before writing to the session database. |
timestamp | string | ISO timestamp. |
isMention? | boolean | Platform-confirmed bot mention. The bridge sets it from the SDK’s mention/DM dispatch paths. Without it, the router never auto-creates a messaging group for the conversation and mention/mention-sticky wirings never engage — only pattern wirings fire on text. |
isGroup? | boolean | true for group/channel threads, false for DMs. |
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. At dc34ceb 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 calling registerChannelAdapter() at module top level. The factory contract, from src/channels/adapter.ts:
/** Factory function that creates a channel adapter (returns null if credentials missing). */
export type ChannelAdapterFactory = () => ChannelAdapter | Promise<ChannelAdapter> | null;
/** Registration entry for a channel adapter. */
export interface ChannelRegistration {
factory: ChannelAdapterFactory;
containerConfig?: {
mounts?: Array<{ hostPath: string; containerPath: string; readonly: boolean }>;
env?: Record<string, string>;
};
}
- The barrel —
src/channels/index.ts imports each channel module, which triggers its registerChannelAdapter() call. Main ships with only ./cli.js; channel skills (/add-slack, /add-discord, …) copy their module from the channels branch and append an import line.
- Null factory = clean skip — return
null when credentials are missing. The host logs Channel credentials missing, skipping and moves on, so an installed-but-unconfigured channel never crashes startup.
- Setup retries — if
setup() throws an error whose name === '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).
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-confirmed isMention flag 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/{adapterName} (port WEBHOOK_PORT, default 3000).
- Chunking — set
maxTextLength and 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
extractReplyContext to 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.files are posted as uploads.
- Cards and actions —
ask_user_question renders as a card with buttons; clicks are decoded and dispatched to onAction, and the card is edited to show the selected answer.
- Typing, subscribe, openDM —
setTyping maps to the SDK’s typing indicator, subscribe to the SDK state adapter, and openDM is exposed when the underlying adapter implements it.
You declare supportsThreads yourself — it’s a product decision, not something the bridge infers.
Worked example: Telegram
The core of the Telegram adapter on the channels branch (src/channels/telegram.ts) is a factory this short:
registerChannelAdapter('telegram', {
factory: () => {
const env = readEnvFile(['TELEGRAM_BOT_TOKEN']);
if (!env.TELEGRAM_BOT_TOKEN) return null;
const token = env.TELEGRAM_BOT_TOKEN;
const telegramAdapter = createTelegramAdapter({
botToken: token,
mode: 'polling',
});
const bridge = createChatSdkBridge({
adapter: telegramAdapter,
concurrency: 'concurrent',
extractReplyContext,
supportsThreads: false,
transformOutboundText: sanitizeTelegramLegacyMarkdown,
maxTextLength: 4000,
});
The rest of the file wraps the bridge with Telegram-specific extras — a 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>.ts modeled on an existing adapter — Telegram or Slack for the Chat SDK path, WhatsApp or cli.ts for native. End the module with a top-level registerChannelAdapter('<name>', { factory }) call that returns null when 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 started with your channel name — or Channel credentials missing, skipping until you add credentials.
If you’d rather ship it as a reusable skill so others can install it, see Extending NanoClaw.