> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nanoclaw.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Channel adapter interface

> The ChannelAdapter contract for building your own channel — lifecycle, delivery, registration, and the Chat SDK bridge.

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](https://github.com/vercel/chat) 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](/channels/overview) for the install model.

## The contract

The full interface, from `src/channels/adapter.ts`:

```ts theme={null}
/** The v2 channel adapter contract. */
export interface ChannelAdapter {
  name: string;
  channelType: string;

  /**
   * Adapter-instance name — distinguishes N adapters of one platform
   * (e.g. three Slack apps in one workspace). Defaults to channelType.
   * channelType stays the SEMANTIC platform key (user ids, formatting,
   * container config); instance is a host-side routing key only.
   * Must be unique across active adapters and URL-safe (no '/', '?', ':').
   */
  instance?: 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` is the semantic platform key — user ids, formatting, and `messaging_groups.channel_type`. The bridge sets both to the Chat SDK adapter's name.                                                                                                                                                                                                                                                                                                                                |
| `instance?`                              | No       | Names this adapter instance so N adapters of one platform (e.g. three Slack apps in one workspace) can coexist. The host keys the active-adapter map by `instance ?? channelType`, stamps it on inbound events and `messaging_groups.instance`, and routes replies and typing back through the instance a message arrived on. Omit it and the instance defaults to `channelType` — single-instance installs are byte-for-byte unchanged. Must be unique across active adapters and URL-safe (`[A-Za-z0-9._-]`). |
| `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](/concepts/entity-model).                                                                                                                                                                                                                                                                                               |
| `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

```ts theme={null}
/** 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.stringify`s 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.                                                                                                                                                                                                                       |

<Note>
  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`).
</Note>

`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`:

```ts theme={null}
/** 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;
  /**
   * Wiring-time defaults for this channel, resolvable WITHOUT instantiating
   * the adapter — offline creation paths (setup, init-first-agent, ncl on a
   * host where the factory returned null for missing creds) read them from
   * the registry. Channel modules pass the same const here and to the adapter.
   */
  defaults?: ChannelDefaults;
  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).

### 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:

```ts theme={null}
export interface ChannelContextDefaults {
  engageMode: 'pattern' | 'mention' | 'mention-sticky';
  engagePattern?: string;   // required iff engageMode === 'pattern'; may contain the literal {name}
  threads: boolean;         // default thread honoring; must be false when supportsThreads is false
  unknownSenderPolicy: 'strict' | 'request_approval' | 'public';
}

export interface ChannelDefaults {
  dm: ChannelContextDefaults;
  group: ChannelContextDefaults;
  mentions: 'platform' | 'dm-only' | 'never';   // which mention signal the adapter emits
}
```

These feed the wiring defaults an operator gets when they don't pass explicit `--engage-mode`/`--threads` flags (see [ncl wirings](/reference/ncl-cli#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 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:

```ts theme={null}
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:

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](/extend/overview).
