Skip to main content
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:
How the host calls each member:

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 calling registerChannelAdapter() at module top level. The factory contract, from src/channels/adapter.ts:
  • The barrelsrc/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).

Wiring defaults

A channel declares its default engagement and threading behavior through defaults?: 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:
These feed the wiring defaults an operator gets when they don’t pass explicit --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-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/{routingPath} (port WEBHOOK_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 an instance and 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 via registerWebhookHandler() — raw routes take priority over adapter routes.
  • 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 actionsask_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, openDMsetTyping 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:
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:
  1. 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.
  2. Append import './<name>.js'; to the barrel, src/channels/index.ts.
  3. Add platform dependencies to package.json (e.g. @chat-adapter/<name>).
  4. Build (npm run build) and restart the service.
  5. 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.
Last modified on July 16, 2026