Building Channel Plugins for OpenClaw: A Step-by-Step Guide

Learn how to build a messaging channel plugin for OpenClaw. This guide covers DM security, pairing, reply threading, and outbound messaging for developers.

Read this when

  • You are building a new messaging channel plugin
  • You want to connect OpenClaw to a messaging platform
  • You need to understand the ChannelPlugin adapter surface

This guide explains how to build a channel plugin that links OpenClaw with a messaging platform, covering DM security, pairing, reply threading, and outbound messaging.

Info

If you are unfamiliar with OpenClaw plugins, start with Getting Started to learn about package structure and manifest setup.

What your plugin owns

Channel plugins do not implement send, edit, or react tools; the core provides a single shared message tool. Your plugin is responsible for:

  • Config - account resolution and the setup wizard
  • Security - DM policies and allowlists
  • Pairing - the DM approval flow
  • Session grammar - how provider-specific conversation IDs map to base chats, thread IDs, and parent fallbacks
  • Outbound - sending text, media, and polls to the platform
  • Threading - how replies are organized into threads
  • Heartbeat typing - optional typing or busy signals for heartbeat delivery targets

The core handles the shared message tool, prompt wiring, the outer session-key shape, generic :thread: bookkeeping, and dispatch.

Message adapter

Create an message adapter using defineChannelMessageAdapter from openclaw/plugin-sdk/channel-outbound. Declare only the durable final-send capabilities your native transport actually supports, backed by a contract test that verifies the native side effect and returned receipt. Route text and media sends through the same transport functions the legacy outbound adapter uses. For the complete API contract, capability matrix, receipt rules, live preview finalization, receive ack policy, tests, and migration table, refer to Channel outbound API.

If your existing outbound adapter already provides the correct send methods and capability metadata, derive the message adapter with createChannelMessageAdapterFromOutbound(...) instead of writing another bridge manually. Adapter sends return MessageReceipt values. For legacy IDs, derive them using listMessageReceiptPlatformIds(...) or resolveMessageReceiptPrimaryId(...) instead of keeping separate messageIds fields.

Declare live and finalizer capabilities precisely. The core uses these to determine what a channel can do, and any mismatch between declared and actual behavior results in a contract test failure:

SurfaceValues
message.live.capabilitiesdraftPreview, previewFinalization, progressUpdates, nativeStreaming, quietFinalization
message.live.finalizer.capabilitiesfinalEdit, normalFallback, discardPending, previewReceipt, retainOnAmbiguousFailure

Channels that finalize a draft preview in place should route runtime logic through defineFinalizableLivePreviewAdapter(...) and deliverWithFinalizableLivePreviewAdapter(...), and keep the declared capabilities backed by verifyChannelMessageLiveCapabilityAdapterProofs(...) and verifyChannelMessageLiveFinalizerProofs(...) tests. This prevents silent drift in native preview, progress, edit, fallback/retention, cleanup, and receipt behavior.

Inbound receivers that defer platform acknowledgements should declare message.receive.defaultAckPolicy and supportedAckPolicies instead of hiding ack timing in monitor-local state. Cover every declared policy with verifyChannelMessageReceiveAckPolicyAdapterProofs(...).

Legacy reply helpers such as dispatchInboundReplyWithBase and recordInboundSessionAndDispatchReply remain available for compatibility dispatchers. Do not use them for new channel code. Instead, start with the message adapter, receipts, and receive/send lifecycle helpers on openclaw/plugin-sdk/channel-outbound.

Inbound ingress (experimental)

Channels migrating inbound authorization can use the experimental openclaw/plugin-sdk/channel-ingress-runtime subpath from runtime receive paths. It accepts platform facts, raw allowlists, route descriptors, command facts, and access group config, then returns sender, route, command, and activation projections plus the ordered ingress graph. Platform lookup and side effects remain in the plugin. Keep plugin identity normalization in the descriptor you pass to the resolver; do not serialize raw match values from the resolved state or decision. See Channel ingress API for the API design, ownership boundary, and test expectations.

Durable ingress and replay dedupe

Channels adopting durable ingress should use createChannelIngressMonitor from openclaw/plugin-sdk/channel-outbound unless they require a materially different admission or pump contract. Enqueue the raw transport envelope at a single receive chokepoint (no normalization at receive time), gate the transport ack on the durable append for webhook transports, derive one serialized lane per conversation, and mark the event complete at dispatch adoption. The queue's primary key is (queue_name, event_id), and completion tombstones the row instead of deleting it. This means a late platform redelivery of the same event_id is durably rejected for the tombstone retention window. See Channel outbound API for the monitor API and shutdown contract.

That tombstone defines the layering rule for replay guards (openclaw/plugin-sdk/persistent-dedupe): a drained channel keeps a separate replay guard only when the guard's identity or retention exceeds the queue's. This applies when a logical message key differs from the transport delivery ID (Telegram dedupes chat_id:message_id because debounce merges can re-surface a message under a fresh update_id), or when a longer window is needed than the channel's tombstone retention. If your guard key equals the drain event_id, delete the guard when adopting the drain and size completedTtlMs/completedMaxEntries to cover the old guard window instead. Non-dedupe protections such as age fences are unrelated to this rule. Stable outbound message IDs use the shared outbound-echo registry from openclaw/plugin-sdk/channel-outbound instead of a channel-local TTL cache.

Transport classes and retention

Classify a transport by the recovery guarantee at its receive boundary:

  • Ack-gated webhook or event delivery: acknowledge or return success only after the durable append. An append failure must leave the delivery eligible for retry or fail the receive boundary. This class includes Slack, SMS, Zalo, Microsoft Teams, Google Chat, LINE, and Synology Chat.
  • Awaited polling or stream delivery: advance the remote cursor or send the transport ack only after the append. When no explicit cursor exists, keep the receive callback serialized and awaited so an append failure cannot let the receive loop run ahead. Telegram polling, Signal, and Tlon use this class; Telegram webhook delivery follows the ack-gated rule above.
  • Non-replay sockets: IRC, Mattermost, Twitch, and Zalo Personal cannot ask the platform to redeliver an accepted event. Their durable queue protects the process crash window and supports local restart recovery; completion tombstones are near-inert against platform replay.

Use 30 days as the fleet tombstone-TTL convention, not as an SDK default. A high-volume redelivery window normally uses a 20,000-entry completed cap; lower-volume awaited and non-replay transports normally use 1,000-2,000. Current exceptions include LINE's 4,096-entry caps, SMS's 24-hour completed TTL, and Tlon's cap-only completed retention. Failed-row caps may also be lower than completed caps. TTL and cap both prune rows, so effective retention ends when the first bound is reached. Deviate only for a documented platform retry horizon, preserved shipped replay-guard window, expected volume or disk budget, or non-replay transport, and cover the retention contract with tests.

At-least-once side effects

Drain dispatch runs command side effects before the ingress row reaches its completion tombstone. If the process crashes between these steps, the row replays and may execute the side effect again. This at-least-once crash window is the default contract. For non-idempotent work such as config writes, storage clears, or visible acknowledgements outside the reply lane, use createIngressEffectOnce(...) from openclaw/plugin-sdk/ingress-effect-once. Pass the stable ingress eventId plus an effect name to each call. Create one helper per ingress queue/account and use a stable, unique namespacePrefix for that scope because transport event IDs may be queue-local. The helper commits its durable claim only after the effect succeeds; a thrown effect releases the claim so a drain retry can execute it again, while concurrent callers wait for the active claim. Durable state errors call onDiskError when provided and reject instead of falling back to process memory.

Set the helper's ttlMs to at least the channel's ingress tombstone retention plus the maximum delay between effect commit and row completion, including bounded downtime and drain retries. The effect record's TTL starts at commit, while tombstone retention starts later at completion; if pending-row lifetime is unbounded, no finite TTL covers arbitrary downtime. After the tombstone can no longer replay the row, older effect records are dead weight. Size stateMaxEntries for every distinct event/effect key that can exist in that retention window, accounting for the queue's completed-entry bound and the maximum effects per event. A lower cap evicts the oldest record before its TTL and allows that effect to execute again. Residual at-least-once windows remain if the process dies or persistence fails after the effect succeeds but before the claim commits, or if the record expires while its ingress row is still pending.

Account-scoped restart contract

Channel config changes restart the whole channel by default. A multi-account channel may set reload.accountScopedRestart: true only when configuration resolution reads channel-wide shared fields plus the selected account, never a sibling account, and the Gateway can stop and start one (channel, accountId) runtime without replacing sibling runtimes.

The scoped path applies only to changes under channels.<channel>.accounts.<non-default-id>.*. Changes to shared channel fields, accounts.default, removed or unresolvable accounts, and mixed changes that can affect inheritance are promoted to a whole-channel restart. Plugins that do not opt in always use the whole-channel path.

For channels using the durable ingress drain, the account monitor's stop path must first settle all accepted transport admissions, then dispose and await its drain. Starting the account opens the same account-keyed queue, whose initial drain recovers undispatched durable rows. Do not add a second reload-specific replay pass; queue recovery is the canonical restart path.

Treat this flag as a capability claim, not a performance preference. Contract tests should prove that adding and editing one named account leaves a sibling's resolved config unchanged, stopping one account settles only that account's monitor and drain, and a fresh monitor recovers that account's rows exactly once. If any guarantee cannot be proved, omit the flag.

Typing indicators

If your channel supports typing indicators outside inbound replies, expose heartbeat.sendTyping(...) on the channel plugin. Core calls it with the resolved heartbeat delivery target before the heartbeat model run starts and uses the shared typing keepalive/cleanup lifecycle. Add heartbeat.clearTyping(...) when the platform needs an explicit stop signal.

Media source params

If your channel adds message-tool params that carry media sources, expose those param names through plugin.actions.describeMessageTool(...).mediaSourceParams. Core uses that explicit list for sandbox path normalization and outbound media-access policy, so plugins do not need shared-core special cases for provider-specific avatar, attachment, or cover-image params.

Prefer an action-keyed map such as { "set-profile": ["avatarUrl", "avatarPath"] } so unrelated actions do not inherit another action's media args. A flat array still works for params intentionally shared across every exposed action.

Channels that must expose a temporary public URL for a platform-side media fetch can use createHostedOutboundMediaStore(...) from openclaw/plugin-sdk/outbound-media with plugin state stores. Keep platform route parsing and token enforcement in the channel plugin; the shared helper only owns media loading, expiry metadata, chunk rows, and cleanup.

Inbound attachments use ordered facts, not parallel Media* fields. Normalize channel records with toInboundMediaFacts(...) from openclaw/plugin-sdk/channel-inbound and pass them as media when building the inbound context. When a plugin must authorize local media reads, import getAgentScopedMediaLocalRoots(...) or getAgentScopedMediaLocalRootsForSources(...) from the focused openclaw/plugin-sdk/media-local-roots subpath. The old agent-media-payload builder/root facade is deprecated compatibility.

Native payload shaping

If your channel needs provider-specific shaping for message(action="send"), prefer actions.prepareSendPayload(...). Put native cards, blocks, embeds, or other durable data under payload.channelData.<channel> and let core send through the outbound/message adapter. Use actions.handleAction(...) for send only as a compatibility fallback for payloads that cannot be serialized and retried.

Session conversation grammar

If your platform stores extra scope inside conversation ids, keep that parsing in the plugin with messaging.resolveSessionConversation(...). That is the canonical hook for mapping rawId to the base conversation id, optional thread id, explicit baseConversationId, and any parentConversationCandidates. When you return parentConversationCandidates, order them from the narrowest parent to the broadest/base conversation.

messaging.resolveParentConversationCandidates(...) is a deprecated compatibility fallback for plugins that only need parent fallbacks on top of the generic/raw id. If both hooks exist, core uses resolveSessionConversation(...).parentConversationCandidates first and only falls back to resolveParentConversationCandidates(...) when the canonical hook omits them.

Bundled plugins that need the same parsing before the channel registry boots can expose a top-level session-key-api.ts file with a matching resolveSessionConversation(...) export (see the Feishu and Telegram plugins). Core uses that bootstrap-safe surface only when the runtime plugin registry is not available yet.

Use openclaw/plugin-sdk/channel-route when plugin code needs to normalize route-like fields, compare a child thread with its parent route, or build a stable dedupe key from { channel, to, accountId, threadId }. The helper normalizes numeric thread ids the same way core does, so prefer it over ad hoc String(threadId) comparisons. Plugins with provider-specific target grammar should expose messaging.resolveOutboundSessionRoute(...) so core gets provider-native session and thread identity without parser shims.

Account-scoped conversation binding support

Set conversationBindings.supportsCurrentConversationBinding when the channel supports generic current-conversation bindings. createChatChannelPlugin(...) sets this static capability to true by default.

If support differs by configured account, also implement conversationBindings.isCurrentConversationBindingSupported({ accountId }). Core evaluates this synchronous hook only after the static capability is enabled. Returning false makes generic current-conversation capability, bind, lookup, list, touch, and unbind operations unavailable for that account. Omitting the hook applies the static capability to every account.

Resolve the answer from already-loaded account config or runtime state. This hook gates only generic current-conversation bindings; it does not replace configured binding rules or plugin-owned session routing. Contract tests should cover at least one supported and one unsupported account through the ChannelPlugin["conversationBindings"] contract exported by openclaw/plugin-sdk/channel-core.

Approvals and channel capabilities

Most channel plugins can avoid writing approval specific logic. Core handles same chat /approve, shared approval button payloads, and a generic fallback delivery mechanism. ChannelPlugin.approvals has been removed; instead, place approval delivery, native, render, and auth facts on a single approvalCapability object. plugin.auth only covers login and logout, as core no longer reads approval auth hooks from that object.

Reserve approvalCapability.delivery solely for native approval routing or to suppress fallback behavior, and use approvalCapability.render only when a channel genuinely requires custom approval payloads instead of the shared renderer.

Approval auth

  • approvalCapability.authorizeActorAction and approvalCapability.getActionAvailabilityState form the standard approval auth boundary.
  • Employ getActionAvailabilityState to indicate same chat approval auth availability. Keep configured approvers accessible for /approve even when native delivery is turned off; rely on native initiating surface state for delivery and setup guidance instead.
  • When your channel supports native exec approvals, use approvalCapability.getExecInitiatingSurfaceState for the initiating surface or native client state when it differs from same chat approval auth. Core uses this exec specific hook to differentiate enabled from disabled, determine if the initiating channel supports native exec approvals, and include the channel in native client fallback guidance. createApproverRestrictedNativeApprovalCapability(...) provides this for the typical scenario.
  • If a channel can deduce stable owner like DM identities from existing configuration, use createResolvedApproverActionAuthAdapter from openclaw/plugin-sdk/approval-runtime to limit same chat /approve without introducing approval specific core logic.
  • If custom approval auth deliberately permits only same chat fallback, return markImplicitSameChatApprovalAuthorization({ authorized: true }) from openclaw/plugin-sdk/approval-auth-runtime; otherwise core treats the outcome as explicit approver authorization.
  • When a channel owned native callback resolves approvals directly, use isImplicitSameChatApprovalAuthorization(...) before resolving so implicit fallback still passes through the channel's normal actor authorization.

Payload lifecycle and setup guidance

  • Use outbound.shouldSuppressLocalPayloadPrompt or outbound.beforeDeliverPayload for channel specific payload lifecycle behavior, such as hiding duplicate local approval prompts or sending typing indicators prior to delivery.
  • Use approvalCapability.describeExecApprovalSetup when the channel wants the disabled path reply to describe the exact configuration knobs needed to enable native exec approvals. The hook receives { channel, channelLabel, accountId }; named account channels should render account scoped paths like channels.<channel>.accounts.<id>.execApprovals.* instead of top level defaults.
  • Use approvalCapability.describePluginApprovalSetup when plugin approval failure guidance is safe to display for plugin approval no route and timeout failures. createApproverRestrictedNativeApprovalCapability(...) does not derive this from describeExecApprovalSetup; explicitly pass the same helper only when plugin and exec approvals genuinely share the same native setup.

Native approval delivery

If a channel requires native approval delivery, keep channel code focused on target normalization and transport or presentation details. Use createChannelExecApprovalProfile, createChannelNativeOriginTargetResolver, createChannelApproverDmTargetResolver, and createApproverRestrictedNativeApprovalCapability from openclaw/plugin-sdk/approval-runtime. Place the channel specific facts behind approvalCapability.nativeRuntime, ideally through createChannelApprovalNativeRuntimeAdapter(...) or createLazyChannelApprovalNativeRuntimeAdapter(...), so core can assemble the handler and own request filtering, routing, deduplication, expiry, gateway subscription, and routed elsewhere notices.

nativeRuntime is divided into several smaller seams:

  • availability whether the account is configured and whether a request should be processed
  • presentation map the shared approval view model into pending, resolved, or expired native payloads or final actions
  • transport prepare targets and send, update, or delete native approval messages
  • interactions optional bind, unbind, and clear action hooks for native buttons or reactions, plus an optional cancelDelivered hook. Implement cancelDelivered when deliverPending registers in process or persistent state (such as a reaction target store) so that state can be released if a handler stop cancels delivery before bindPending runs, or when bindPending returns no handle
  • observe optional delivery diagnostics hooks

Other approval helpers:

  • When a channel supports both session-origin native delivery and explicit approval forwarding targets, use createNativeApprovalChannelRouteGates from openclaw/plugin-sdk/approval-native-runtime. This helper centralizes the selection of approval configuration, managing mode handling, agent/session filters, account binding, session-target matching, and target-list matching, while callers retain control over the channel id, default forwarding mode, account lookup, transport-enabled check, target normalization, and turn-source target resolution. Avoid using it to establish core-owned channel policy defaults; instead, explicitly pass the channel's documented default mode.

  • By default, createChannelNativeOriginTargetResolver employs the shared channel-route matcher for { to, accountId, threadId } targets. Pass targetsMatch only when a channel has provider-specific equivalence rules, such as matching Slack timestamp prefixes. Pass normalizeTargetForMatch when the channel must canonicalize provider ids before the default route matcher or a custom targetsMatch callback executes, while keeping the original target for delivery. Use normalizeTarget solely when the resolved delivery target itself requires canonicalization.

  • For runtime-owned objects like a client, token, Bolt app, or webhook receiver, register them through openclaw/plugin-sdk/channel-runtime-context. The generic runtime-context registry enables core to bootstrap capability-driven handlers from channel startup state without requiring approval-specific wrapper glue.

  • Only fall back to the lower-level createChannelApprovalHandler or createChannelNativeApprovalRuntime when the capability-driven seam lacks sufficient expressiveness.

  • Native approval channels must route both accountId and approvalKind through these helpers. accountId ensures multi-account approval policy stays scoped to the correct bot account, and approvalKind keeps exec versus plugin approval behavior available to the channel without hardcoded branches in core.

  • Core also owns approval reroute notices. Channel plugins should avoid sending their own "approval went to DMs or another channel" follow-up messages from createChannelNativeApprovalRuntime; instead, provide accurate origin and approver-DM routing through the shared approval capability helpers, letting core aggregate actual deliveries before posting any notice back to the originating chat.

  • Maintain the delivered approval id kind end-to-end. Native clients must not guess or rewrite exec versus plugin approval routing from channel-local state.

  • Pass that explicit approvalKind to resolveApprovalOverGateway. This leverages the canonical approval.resolve service and returns the recorded winner when another surface responds first. The older explicit resolveMethod input remains for command-backed controls; new native actions must not use it or infer kind from an ID.

  • Different approval kinds can intentionally expose different native surfaces. Current bundled examples: Matrix keeps the same native DM/channel routing and reaction UX for both exec and plugin approvals, while still allowing auth to differ by approval kind; Slack keeps native approval routing available for both exec and plugin ids.

  • createApproverRestrictedNativeApprovalAdapter still exists as a compatibility wrapper, but new code should prefer the capability builder and expose approvalCapability on the plugin.

Narrower approval runtime subpaths

For hot channel entrypoints, prefer these narrower subpaths over the broader approval-runtime barrel when you only need one part of that family:

  • openclaw/plugin-sdk/approval-auth-runtime
  • openclaw/plugin-sdk/approval-client-runtime
  • openclaw/plugin-sdk/approval-delivery-runtime
  • openclaw/plugin-sdk/approval-gateway-runtime
  • openclaw/plugin-sdk/approval-reference-runtime
  • openclaw/plugin-sdk/approval-handler-adapter-runtime
  • openclaw/plugin-sdk/approval-handler-runtime
  • openclaw/plugin-sdk/approval-native-runtime
  • openclaw/plugin-sdk/approval-reply-runtime
  • openclaw/plugin-sdk/channel-runtime-context

Similarly, prefer openclaw/plugin-sdk/reply-runtime, openclaw/plugin-sdk/reply-dispatch-runtime, openclaw/plugin-sdk/reply-reference, and openclaw/plugin-sdk/reply-chunking over broader umbrella surfaces when you do not need them all.

Setup subpaths

  • openclaw/plugin-sdk/setup-runtime covers the runtime-safe setup helpers: createSetupTranslator, import-safe setup patch adapters (createPatchedAccountSetupAdapter, createEnvPatchedAccountSetupAdapter, createSetupInputPresenceValidator), lookup-note output, promptResolvedAllowFrom, splitSetupEntries, and the delegated setup-proxy builders.
  • openclaw/plugin-sdk/channel-setup covers the optional-install setup builders plus a few setup-safe primitives: createOptionalChannelSetupSurface, createOptionalChannelSetupAdapter, createOptionalChannelSetupWizard, DEFAULT_ACCOUNT_ID, createTopLevelChannelDmPolicy, setSetupChannelEnabled, and splitSetupEntries.
  • Use the broader openclaw/plugin-sdk/setup seam only when you also need the heavier shared setup/config helpers such as moveSingleAccountChannelSectionToDefaultAccount(...).

If your channel only wants to advertise "install this plugin first" in setup surfaces, prefer createOptionalChannelSetupSurface(...). The generated adapter/wizard fail closed on config writes and finalization, and they reuse the same install-required message across validation, finalize, and docs-link copy.

If your channel supports env-driven setup or auth, expose it through the channel config schema and setup descriptors. Keep channel runtime envVars or local constants for operator-facing copy only.

If your channel needs to appear in status, channels list, channels status, or SecretRef scans before the plugin runtime starts, include openclaw.setupEntry inside package.json. This entrypoint must be safe to import along read-only command paths and should provide the channel metadata, a setup-safe config adapter, a status adapter, and the channel secret target metadata required by those summaries. Do not start clients, listeners, or transport runtimes from the setup entry.

Also keep the main channel entry import path narrow. Discovery can examine the entry and the channel plugin module to register capabilities without activating the channel. Files like channel-plugin-api.ts should export the channel plugin object without importing setup wizards, transport clients, socket listeners, subprocess launchers, or service startup modules. Place those runtime components in modules loaded from registerFull(...), runtime setters, or lazy capability adapters.

Other narrow channel subpaths

For other hot channel paths, prefer the narrow helpers over broader legacy surfaces:

  • openclaw/plugin-sdk/account-core, openclaw/plugin-sdk/account-id, openclaw/plugin-sdk/account-resolution, and openclaw/plugin-sdk/account-helpers for multi-account configuration and default-account fallback
  • openclaw/plugin-sdk/inbound-envelope and openclaw/plugin-sdk/channel-inbound for inbound route/envelope and record-and-dispatch wiring
  • openclaw/plugin-sdk/channel-targets for target parsing helpers
  • openclaw/plugin-sdk/channel-outbound for outbound identity/send delegates and typed payload planning
  • buildThreadAwareOutboundSessionRoute(...) from openclaw/plugin-sdk/channel-core when an outbound route should keep an explicit replyToId/threadId or restore the current :thread: session while the base session key still matches. Provider plugins can override precedence, suffix behavior, and thread id normalization when their platform has native thread delivery semantics.
  • openclaw/plugin-sdk/thread-bindings-runtime for thread-binding lifecycle and adapter registration

Auth-only channels can usually stop at the default path: core handles approvals and the plugin only exposes outbound/auth capabilities. Native approval channels such as Matrix, Slack, Telegram, and custom chat transports should use the shared native helpers instead of building their own approval lifecycle.

Inbound mention policy

Keep inbound mention handling split into two layers:

  • plugin-owned evidence gathering
  • shared policy evaluation

Use openclaw/plugin-sdk/channel-mention-gating for mention-policy decisions. Use openclaw/plugin-sdk/channel-inbound only when you need the broader inbound helper barrel.

Good fit for plugin-local logic:

  • reply-to-bot detection
  • quoted-bot detection
  • thread-participation checks
  • service/system-message exclusions
  • platform-native caches needed to prove bot participation

Good fit for the shared helper:

  • requireMention
  • explicit mention result
  • implicit mention allowlist
  • command bypass
  • final skip decision

Preferred flow:

  1. Compute local mention facts.
  2. Pass those facts into resolveInboundMentionDecision({ facts, policy }).
  3. Use decision.effectiveWasMentioned, decision.shouldBypassMention, and decision.shouldSkip in your inbound gate.
import {
  implicitMentionKindWhen,
  matchesMentionWithExplicit,
  resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveChannelImplicitMentions } from "openclaw/plugin-sdk/channel-ingress-runtime";

const wasMentioned = matchesMentionWithExplicit({
  text,
  mentionRegexes,
  explicit: {
    hasAnyMention,
    isExplicitlyMentioned,
    canResolveExplicit,
  },
});

const facts = {
  canDetectMention: true,
  wasMentioned,
  hasAnyMention,
  implicitMentionKinds: [
    ...implicitMentionKindWhen("reply_to_bot", isReplyToBot),
    ...implicitMentionKindWhen("quoted_bot", isQuoteOfBot),
  ],
};

const implicitMentions = resolveChannelImplicitMentions({
  cfg,
  channel: channelId,
  accountId,
});

const decision = resolveInboundMentionDecision({
  facts,
  policy: {
    isGroup,
    requireMention,
    implicitMentions,
    allowTextCommands,
    hasControlCommand,
    commandAuthorized,
  },
});

if (decision.shouldSkip) return;

matchesMentionWithExplicit(...) returns a boolean. hasAnyMention, isExplicitlyMentioned, and canResolveExplicit come from the channel's own native mention metadata (message entities, reply-to-bot flags, and similar); supply false/undefined values when your platform cannot detect them.

api.runtime.channel.mentions exposes the same shared mention helpers for bundled channel plugins that already depend on runtime injection: buildMentionRegexes, matchesMentionPatterns, matchesMentionWithExplicit, implicitMentionKindWhen, resolveInboundMentionDecision.

If you only need implicitMentionKindWhen and resolveInboundMentionDecision, import from openclaw/plugin-sdk/channel-mention-gating to avoid loading unrelated inbound runtime helpers.

Walkthrough

Package and manifest

Create the standard plugin files. The channels field in openclaw.plugin.json (not a kind field) marks a manifest as owning a channel. For the full package-metadata surface, see Plugin Setup and Config:

{
  "name": "@myorg/openclaw-acme-chat",
  "version": "1.0.0",
  "type": "module",
  "openclaw": {
    "extensions": ["./index.ts"],
    "setupEntry": "./setup-entry.ts",
    "channel": {
      "id": "acme-chat",
      "label": "Acme Chat",
      "blurb": "Connect OpenClaw to Acme Chat."
    }
  }
}
{
  "id": "acme-chat",
  "channels": ["acme-chat"],
  "name": "Acme Chat",
  "description": "Acme Chat channel plugin",
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {}
  },
  "channelConfigs": {
    "acme-chat": {
      "schema": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "token": { "type": "string" },
          "allowFrom": {
            "type": "array",
            "items": { "type": "string" }
          }
        }
      },
      "uiHints": {
        "token": {
          "label": "Bot token",
          "sensitive": true
        }
      }
    }
  }
}

configSchema validates plugins.entries.acme-chat.config. Use it for plugin-owned settings that are not the channel account config. channelConfigs.acme-chat.schema validates channels.acme-chat and is the cold-path source used by config schema, setup, and UI surfaces before the plugin runtime loads. See Plugin manifest for the full top-level field reference.

Build the channel plugin object

The ChannelPlugin interface exposes several optional adapter surfaces. Begin with the essentials: id, config, and setup. Then add more adapters as your requirements grow.

Create src/channel.ts:

import {
  createChatChannelPlugin,
  createChannelPluginBase,
} from "openclaw/plugin-sdk/channel-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core";
import { acmeChatApi } from "./client.js"; // your platform API client

type ResolvedAccount = {
  accountId: string | null;
  token: string;
  allowFrom: string[];
  dmPolicy: string | undefined;
};

function resolveAccount(
  cfg: OpenClawConfig,
  accountId?: string | null,
): ResolvedAccount {
  const section = (cfg.channels as Record<string, any>)?.["acme-chat"];
  const token = section?.token;
  if (!token) throw new Error("acme-chat: token is required");
  return {
    accountId: accountId ?? null,
    token,
    allowFrom: section?.allowFrom ?? [],
    dmPolicy: section?.dmSecurity,
  };
}

export const acmeChatPlugin = createChatChannelPlugin<ResolvedAccount>({
  base: createChannelPluginBase({
    id: "acme-chat",
    // Account resolution/inspection belongs on `config`, not `setup`.
    // `setup` covers onboarding writes (applyAccountConfig, validateInput).
    config: {
      listAccountIds: () => ["default"],
      resolveAccount,
      inspectAccount(cfg, accountId) {
        const section =
          (cfg.channels as Record<string, any>)?.["acme-chat"];
        return {
          enabled: Boolean(section?.token),
          configured: Boolean(section?.token),
          tokenStatus: section?.token ? "available" : "missing",
        };
      },
    },
    setup: {
      applyAccountConfig: ({ cfg, input }) => ({
        ...cfg,
        channels: {
          ...cfg.channels,
          "acme-chat": { ...(cfg.channels as any)?.["acme-chat"], ...input },
        },
      }),
    },
  }),

  // DM security: who can message the bot
  security: {
    dm: {
      channelKey: "acme-chat",
      resolvePolicy: (account) => account.dmPolicy,
      resolveAllowFrom: (account) => account.allowFrom,
      defaultPolicy: "allowlist",
    },
  },

  // Pairing: approval flow for new DM contacts
  pairing: {
    text: {
      idLabel: "Acme Chat username",
      message: "Send this code to verify your identity:",
      notify: async ({ target, code }) => {
        await acmeChatApi.sendDm(target, `Pairing code: ${code}`);
      },
    },
  },

  // Threading: how replies are delivered
  threading: { topLevelReplyToMode: "reply" },

  // Outbound: send messages to the platform
  outbound: {
    attachedResults: {
      channel: "acme-chat",
      sendText: async (params) => {
        const result = await acmeChatApi.sendMessage(
          params.to,
          params.text,
        );
        return { messageId: result.id };
      },
    },
    base: {
      sendMedia: async (params) => {
        await acmeChatApi.sendFile(params.to, params.filePath);
      },
    },
  },
});

For channels that handle both canonical top-level DM keys and legacy nested keys, use the utility functions from plugin-sdk/channel-config-helpers: resolveChannelDmAccess, resolveChannelDmPolicy, resolveChannelDmAllowFrom, and normalizeChannelDmPolicy give priority to account-local values over inherited root ones. Link the same resolver with doctor repair through normalizeLegacyDmAliases so that runtime and migration interpret the same contract.

What createChatChannelPlugin does for you

Rather than implementing low-level adapter interfaces by hand, you supply declarative options and the builder assembles them:

OptionWhat it connects
security.dmScoped DM security resolver driven by configuration fields
pairing.textText-based DM pairing flow using code exchange
threadingReply-to-mode resolver (fixed, account-scoped, or custom)
outbound.attachedResultsSend functions that return result metadata (message IDs); requires a sibling channel id so the core can tag the returned delivery result

If you need complete control, you can supply raw adapter objects instead of the declarative options.

A raw outbound adapter may define a chunker(text, limit, ctx) function. The optional ctx.formatting carries formatting decisions made at delivery time, like maxLinesPerMessage; apply it before sending so that reply threading and chunk boundaries are resolved once by the shared outbound delivery. Send contexts also include replyToIdSource (implicit or explicit) when a native reply target was resolved, letting payload helpers keep explicit reply tags without consuming an implicit single-use reply slot.

Group tool-policy adapters

A channel that implements group.resolveToolPolicy and supports toolsBySender must pass the complete ChannelGroupContext to its shared policy resolver. Specifically, honor senderPolicyMode: "never" by skipping sender-specific overlays at both the matched-group and wildcard scopes while still applying the base tools policy.

OpenClaw activates this mode only for trusted non-ingress execution whose sender authority was already captured in a server-owned envelope, for example an explicitly capped scheduled run. Plugins must not derive the mode from inbound metadata, persist it as channel state, or expose it as configuration. Add an adapter test that confirms the mode skips a wildcard toolsBySender entry without dropping the matching base tools restriction.

Wire the entry point

Create index.ts:

import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
import { acmeChatPlugin } from "./src/channel.js";

export default defineChannelPluginEntry({
  id: "acme-chat",
  name: "Acme Chat",
  description: "Acme Chat channel plugin",
  plugin: acmeChatPlugin,
  registerCliMetadata(api) {
    api.registerCli(
      ({ program }) => {
        program
          .command("acme-chat")
          .description("Acme Chat management");
      },
      {
        descriptors: [
          {
            name: "acme-chat",
            description: "Acme Chat management",
            hasSubcommands: false,
          },
        ],
      },
    );
  },
  registerFull(api) {
    api.registerGatewayMethod(/* ... */);
  },
});

Place channel-owned CLI descriptors in registerCliMetadata(...) so OpenClaw can display them in root help without loading the full channel runtime, while normal full loads still pick up the same descriptors for actual command registration. Keep registerFull(...) for runtime-only tasks. defineChannelPluginEntry handles the registration-mode split automatically. If registerFull(...) registers gateway RPC methods, use a plugin-specific prefix. Core admin namespaces (config.*, exec.approvals.*, wizard.*, update.*) remain reserved and always resolve to operator.admin. See Entry Points for all available options.

Add a setup entry

Create setup-entry.ts for lightweight loading during onboarding:

import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core";
import { acmeChatPlugin } from "./src/channel.js";

export default defineSetupPluginEntry(acmeChatPlugin);

OpenClaw loads this entry instead of the full one when the channel is disabled or unconfigured. It avoids pulling in heavy runtime code during setup flows. See Setup and Config for details.

Bundled workspace channels that split setup-safe exports into sidecar modules can use defineBundledChannelSetupEntry(...) from openclaw/plugin-sdk/channel-entry-contract when they also need an explicit setup-time runtime setter.

Handle inbound messages

Your plugin must receive messages from the platform and forward them to OpenClaw. The typical approach is a webhook that validates the request and dispatches it through your channel's inbound handler:

registerFull(api) {
  api.registerHttpRoute({
    path: "/acme-chat/webhook",
    auth: "plugin", // plugin-managed auth (verify signatures yourself)
    handler: async (req, res) => {
      const event = parseWebhookPayload(req);

      // Your inbound handler dispatches the message to OpenClaw.
      // The exact wiring depends on your platform SDK -
      // see a real example in the bundled Microsoft Teams or Google Chat plugin package.
      await handleAcmeChatInbound(api, event);

      res.statusCode = 200;
      res.end("ok");
      return true;
    },
  });
}

Note

Inbound message handling depends on the channel. Each channel plugin manages its own inbound pipeline. Examine bundled channel plugins (for example the Microsoft Teams or Google Chat plugin package) for real-world patterns.

Test

Write colocated tests in src/channel.test.ts:

import { describe, it, expect } from "vitest";
import { acmeChatPlugin } from "./channel.js";

describe("acme-chat plugin", () => {
  it("resolves account from config", () => {
    const cfg = {
      channels: {
        "acme-chat": { token: "test-token", allowFrom: ["user1"] },
      },
    } as any;
    const account = acmeChatPlugin.config.resolveAccount(cfg, undefined);
    expect(account.token).toBe("test-token");
  });

  it("inspects account without materializing secrets", () => {
    const cfg = {
      channels: { "acme-chat": { token: "test-token" } },
    } as any;
    const result = acmeChatPlugin.config.inspectAccount!(cfg, undefined);
    expect(result.configured).toBe(true);
    expect(result.tokenStatus).toBe("available");
  });

  it("reports missing config", () => {
    const cfg = { channels: {} } as any;
    const result = acmeChatPlugin.config.inspectAccount!(cfg, undefined);
    expect(result.configured).toBe(false);
  });
});
pnpm test <bundled-plugin-root>/acme-chat/

For shared test helpers, see Testing.

File structure

<bundled-plugin-root>/acme-chat/
├── package.json              # openclaw.channel metadata
├── openclaw.plugin.json      # Manifest with config schema
├── index.ts                  # defineChannelPluginEntry
├── setup-entry.ts            # defineSetupPluginEntry
├── api.ts                    # Public exports (optional)
├── runtime-api.ts            # Internal runtime exports (optional)
└── src/
    ├── channel.ts            # ChannelPlugin via createChatChannelPlugin
    ├── channel.test.ts       # Tests
    ├── client.ts             # Platform API client
    └── runtime.ts            # Runtime store (if needed)

Advanced topics

Note

Certain bundled helper seams remain for bundled-plugin maintenance and backward compatibility. These are not the recommended approach for new channel plugins. Unless you are directly maintaining that bundled plugin family, prefer the generic channel/setup/reply/runtime subpaths from the common SDK surface.

Next steps