Skip to content

Channel adapters

源码版本v2026.6.11

Responsibilities

A channel adapter is the glue layer that translates "22 chat platforms with different protocols" into OpenClaw's unified internal event stream. Each channel implements a set of adapter interfaces; the gateway only deals with this interface set, not caring whether underneath is Telegram polling, Slack Socket Mode, or Discord Gateway.

Adapters come in three groups:

  1. GatewayAdapterstartAccount / stopAccount, responsible for bringing up a long-running connection (polling / webhook / WebSocket) for an account and feeding platform raw events into the agent main loop; loginWithQrStart / loginWithQrWait / logoutAccount handle channels like WhatsApp that require QR-code login.
  2. MessageAdaptersend.text / send.media / send.payload / send.poll as four outbound methods, plus an ack policy declaration for receive and a streaming preview capability declaration for live. This is the unified outbound sending interface.
  3. Fine-grained capability adaptersconfig / setup / pairing / security / groups / mentions / outbound / status / gateway / auth / approval / elevated / commands / lifecycle / secrets / allowlist / doctor / bindings / conversationBindings / streaming / threading / message / messaging / agentPrompt / directory / resolver / actions / heartbeat, each optional — channels implement by capability; unimplemented ones stay undefined.

Design motivation

The direct reason for splitting gateway / message / a pile of small adapters is that capability differences between channels are large:

Telegram supports both webhook and long polling; WhatsApp requires QR-code; Slack uses Socket Mode (WebSocket); Discord uses Gateway (WebSocket); iMessage uses an AppleScript local bridge. If you tried to wrap all of this in one unified Channel interface, you'd either stuff in a pile of optional fields or have each channel implement a lot of empty methods.

On the outbound side, some channels support replyToId, some threadId, some nativeQuote, some streaming preview (draft preview). These capabilities are declarative — the adapter declares nativeStreaming: true in capabilities; core code uses it if declared, and falls back to a degraded path if not.

The ChannelGatewayContext received by startAccount carries abortSignal, channelRuntime, setStatus, getStatus — these are capabilities the gateway injects into the channel, not ones the channel invents. channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher is the entry through which the gateway lets the channel feed inbound events into the agent main loop.

So the adapter layer's design is: fine-grained interfaces + all optional + declarative capabilities. Channels implement on demand; core code picks strategies based on declared capabilities.

Key files

Data flow

Channel startup enters through gateway.startAccount(ctx) (telegram/channel.ts:L993):

typescript
gateway: {
  startAccount: async (ctx) => {
    const account = ctx.account;
    // ... probe token, resolve bot info
    return startTelegramMonitor({
      token,
      accountId: account.accountId,
      cfg: ctx.cfg,
      runtime: ctx.runtime,
      channelRuntime: ctx.channelRuntime,
      abortSignal: ctx.abortSignal,
      useWebhook: Boolean(account.config.webhookUrl),
      webhookUrl: account.config.webhookUrl,
      webhookSecret: account.config.webhookSecret,
      // ...
    });
  },
  stopAccount: async ({ account, accountId, log }) => {
    const released = await releaseStoppedTelegramPollingLease({ token, accountId });
    if (released) {
      log?.info?.(`[${accountId}] released stopped Telegram polling lease`);
    }
  },
}

A few notes: ctx.account is the structured account object parsed by Channel config; ctx.channelRuntime is the capability bundle the gateway injects, containing reply / routing / text / session / media / commands / groups / pairing eight submodules; abortSignal is the gateway lifecycle signal — when the gateway stops, the long-running connection must drop immediately.

Slack's startAccount (slack/channel.ts:L757) is thinner, delegating directly to monitorSlackProvider: the two tokens botToken + appToken are trimmed before passing in (appToken is Socket Mode only; botToken is for the normal bot API); other fields are similar to telegram — channelRuntime / abortSignal / setStatus / getStatus. Slack Socket Mode is a WebSocket long-running connection implemented inside monitorSlackProvider; setStatus / getStatus let the monitor report account runtime state back into the snapshot in the Channel registry.

Discord startup adds an extra rate-limit defense (discord/channel.ts:L690):

typescript
const startupDelayMs = resolveDiscordStartupDelayMs(ctx.cfg, account.accountId);
if (startupDelayMs > 0) {
  ctx.log?.info(
    `[${account.accountId}] delaying provider startup ${Math.round(startupDelayMs / 1000)}s to reduce Discord startup rate limits`,
  );
  try {
    await sleepWithAbort(startupDelayMs, ctx.abortSignal);
  } catch {
    return;
  }
}

sleepWithAbort lets the wait also respond to abortSignal — if the gateway stops, give up startup immediately rather than finishing the sleep then exiting. Discord's token state must be validated first too; configured_unavailable throws (discord/channel.ts:L685), avoiding launching with an unresolved SecretRef.

WhatsApp QR login is two-phase (whatsapp/channel.ts:L345): loginWithQrStart brings up a web login and returns a QR code; loginWithQrWait polls waiting for the user to scan; logoutAccount calls logoutWeb to clear authDir. The two methods work together so the setup UI can render the QR code and block-wait for the result, rather than stuffing the whole login into one ultra-long promise.

Outbound goes through MessageAdapter (message/types.ts:L306):

typescript
export type ChannelMessageSendAdapter<
  TConfig = OpenClawConfig,
  TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
> = {
  text?: (ctx: ChannelMessageSendTextContext<TConfig>) => Promise<TSendResult>;
  media?: (ctx: ChannelMessageSendMediaContext<TConfig>) => Promise<TSendResult>;
  payload?: (ctx: ChannelMessageSendPayloadContext<TConfig>) => Promise<TSendResult>;
  poll?: (ctx: ChannelMessageSendPollContext<TConfig>) => Promise<TSendResult>;
  lifecycle?: ChannelMessageSendLifecycleAdapter<TConfig, TSendResult>;
};

The four methods correspond to four outbound payload types: text plain text, media media, payload rich cards, poll polls. lifecycle is a hook: beforeSend / afterSendSuccess / afterSendFailure / afterCommit, letting channels insert their own logic around sends (Slack, for example, needs to update the parent message's reply count).

Inbound goes through the ack policy (message/types.ts:L374):

typescript
export type ChannelMessageReceiveAckPolicy =
  | "after_receive_record"
  | "after_agent_dispatch"
  | "after_durable_send"
  | "manual";

The four policies control "when to ack this message as received by the platform" — after_receive_record is the earliest, acking as soon as it's persisted; after_agent_dispatch after the agent gets the event; after_durable_send after the reply is durably delivered; manual lets the channel decide. defineChannelMessageAdapter defaults to manual (message/adapter.ts:L12), because most channels need to decide ack timing themselves.

The whole inbound → outbound chain:

Boundaries and failures

  • unresolved SecretRef throws on start: Discord checks tokenStatus === "configured_unavailable" in startAccount (discord/channel.ts:L685) and throws rather than connecting with an empty token, avoiding edge-state logs like "token is empty string".
  • polling lease release: Telegram's stopAccount must call releaseStoppedTelegramPollingLease (telegram/channel.ts:L1094); otherwise the prior round's polling lease is still around on restart, colliding with the token.
  • rate limit proactive delay: Discord waits startupDelayMs before starting and can be interrupted by abortSignal (discord/channel.ts:L696). This makes "prevent Discord concurrent-startup limits" an explicit sleep rather than a failure-retry.
  • bundled load failure only warns: describeBundledChannelLoadError (bundled.ts:L318) suggests running openclaw doctor --fix; one channel failing doesn't affect the other channels.
  • channelRuntime is optional: ChannelGatewayContext.channelRuntime is an optional field; external plugins should check for undefined before using (types.adapters.ts:L313), otherwise a mismatched SDK version NPEs.
  • ack defaults to manual: defineChannelMessageAdapter defaults receive to manual (message/adapter.ts:L12); when a channel doesn't actively declare an ack policy, core code doesn't auto-ack for it — preventing "messages thought-processed but actually lost".
  • declarative capability fallback: durableFinalDeliveryCapabilities (message/types.ts:L17) lists 12 capabilities; core code only takes the corresponding reliable-delivery path for ones the channel declares as true in capabilities; undeclared ones fall back, no throw.

Summary

The adapter layer encapsulates the differences across 22 channels behind a set of fine-grained, fully optional, declarative-capability interfaces. GatewayAdapter handles start/stop and login; MessageAdapter handles outbound send and inbound ack; fine-grained adapters each handle one capability facet. The gateway injects capabilities through channelRuntime, controls lifecycle through abortSignal, and picks strategies declaratively through capabilities.

How the Channel registry gets a plugin entry and calls its startAccount, and how agent events reach the MessageAdapter to send back to the platform, see Chat broadcast. How each channel is configured (token, allowFrom, accounts) in openclaw.json is in Channel config.