Building Channel Plugins for OpenClaw
Step-by-step guide to creating a messaging channel plugin for OpenClaw, covering DM security, pairing, reply threading, and outbound messaging. Essential for developers integrating new platforms.
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 walks through building a channel plugin that links OpenClaw with a messaging platform, covering DM security, pairing, reply threading, and outbound messaging.
Info
Just starting with OpenClaw plugins? Check Getting Started for details on package layout and manifest configuration.
What your plugin owns
Channel plugins skip implementing send/edit/react tools; core supplies a single shared message tool. Your plugin handles:
- Config - account lookup and setup wizard
- Security - DM policy and allowlists
- Pairing - DM approval flow
- Session grammar - mapping provider-specific conversation ids to base chats, thread ids, and parent fallbacks
- Outbound - sending text, media, and polls to the platform
- Threading - reply threading behavior
- Heartbeat typing - optional typing/busy signals for heartbeat delivery targets
Core manages the shared message tool, prompt wiring, the outer session-key shape, generic :thread: bookkeeping, and dispatch.
Model-picker product actions are also core-owned. A channel that renders a ModelPickerAction declares its ModelPickerCapabilityProfile, then wraps the typed action in a transport-private authenticated callback envelope. Keep approval, command, URL, web-app, question, callback, and model-picker actions distinct until that encoding boundary; never infer picker intent from a raw callback string. Actor and source-message checks stay with the channel.
Message adapter
Expose a message adapter with defineChannelMessageAdapter from openclaw/plugin-sdk/channel-outbound. Declare only the durable final-send capabilities your native transport genuinely supports, backed by a contract test that verifies the native side effect and returned receipt. Point text/media sends at 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, see Channel outbound API.
If your existing outbound adapter already has the right send methods and capability metadata, derive the message adapter with createChannelMessageAdapterFromOutbound(...) instead of hand-writing another bridge. Adapter sends return MessageReceipt values. For legacy ids, derive them with listMessageReceiptPlatformIds(...) or resolveMessageReceiptPrimaryId(...) instead of keeping parallel messageIds fields.
Declare live and finalizer capabilities precisely - core relies on these to determine what a channel can do, and drift between the declared and actual behavior is a contract test failure:
| Surface | Values |
|---|---|
message.live.capabilities | draftPreview, previewFinalization, progressUpdates, nativeStreaming, quietFinalization |
message.live.finalizer.capabilities | finalEdit, normalFallback, discardPending, previewReceipt, retainOnAmbiguousFailure |
Channels that finalize a draft preview in place should route the runtime logic through defineFinalizableLivePreviewAdapter(...) plus deliverWithFinalizableLivePreviewAdapter(...), and keep the declared capabilities backed by verifyChannelMessageLiveCapabilityAdapterProofs(...) and verifyChannelMessageLiveFinalizerProofs(...) tests so native preview, progress, edit, fallback/retention, cleanup, and receipt behavior cannot drift silently.
Progress visibility acceptance
Progress callbacks report what the operator can see, not merely what a plugin queued. Return true after accepting visible progress and false while delivery is pending or when no visible update occurred. Existing synchronous and asynchronous callbacks that return void remain backward-compatible and are treated as visible; new acceptance-aware implementations should use an explicit boolean.
Commentary delivery ownership
Set commentaryPayloadsEnabled: true when the channel supports durable commentary messages. Channels that normally render commentary in one evolving progress draft can also provide shouldDeliverCommentaryPayloads. Core freezes verbose visibility for the turn, registers that getter through onVerboseProgressVisibility, evaluates the delivery callback once before dispatch, and snapshots that result for the whole turn. Session changes apply on the next turn. The callback is inert unless commentaryPayloadsEnabled is also true; without that static opt-in, core neither evaluates the callback nor freezes the registered visibility getter.
Return false while the draft owns normal progress and true when verbose progress makes that draft yield to durable commentary. Keep the callback synchronous and read only channel-owned, already prepared state. Omitting it preserves durable delivery for existing plugins that use the static opt-in. The callback does not control reasoning, partial replies, tool progress, or final answers.
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(...).
TTS voice delivery
Declare native voice-note behavior under capabilities.tts.voice. Set synthesisTarget: "voice-note" when TTS providers should produce a native voice-note format. Set captionedFinalText: true only when the outbound voice operation accepts visible final text and enforces its transport's caption and overflow rules. Core then holds final-mode streamed text for that operation and falls back to text when the voice payload is proven unsent.
The legacy dispatchInboundReplyWithBase helper remains available from the deprecated openclaw/plugin-sdk/inbound-reply-dispatch compatibility shim. Do not use it for new channel code; start with the message adapter, receipts, and receive/send lifecycle helpers on openclaw/plugin-sdk/channel-outbound instead.
Inbound ingress (experimental)
Channels that migrate inbound authorization can rely on the experimental
openclaw/plugin-sdk/channel-ingress-runtime subpath within runtime receive
paths. This subpath takes platform facts, raw allowlists, route descriptors,
command facts, and access group configuration, then yields sender/route/command/activation
projections along with the ordered ingress graph, while platform lookup and
side effects remain in the plugin. Keep plugin identity normalization in the
descriptor you hand to the resolver; never serialize raw match values from
the resolved state or decision. The
Channel ingress API documents the API design,
ownership boundary, and test expectations.
Hand the exact resolver result to the host-injected registered context builder
as channelIngress. Execution results must carry the final
agent/session/message/event contextBinding; decision-only resolver calls may
leave it out. This keeps the native plugin's record-, epoch-, and scope-bound participant evidence intact through one-shot queued run admission without
exposing it in message context fields. The standalone public builder is not an
authoritative substitute. Never reconstruct evidence from sender, route, room,
account, thread, message, transport, or session values. Legacy adapters can explicitly pass
channelIngress: "unsupported" only when the path is source-proven to lack an
authoritative Phase 0 integration. Supported paths must pass the exact result;
omission is invalid production wiring. Missing, fake, stale, reused, or mixed
supported evidence projects as unknown, never as an allow signal.
Durable ingress and replay dedupe
Channels adopting durable ingress should use createChannelIngressMonitor
from openclaw/plugin-sdk/channel-outbound unless they need 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, so a late platform redelivery of
the same event_id is rejected durably for the tombstone retention window.
See Channel outbound API
for the monitor API and shutdown contract.
That tombstone is 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,
a logical message key that 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 a longer window than the channel's tombstone
retention. If your guard key would equal 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. A process crash between those steps replays the row and
can 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. Give each call the stable ingress
eventId plus an effect name. 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.
Runtime lifecycle status
For channel-authored runtime state, ChannelAccountSnapshot.lifecycle is the
successor to healthState. Existing plugins may keep publishing healthState
during adoption, and core-derived policy writes remain supported. There is no
removal date; removal waits for external channel-plugin adoption.
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
When a channel plugin adds message-tool parameters that carry media sources, those parameter names should be surfaced through plugin.actions.describeMessageTool(...).mediaSourceParams. Core relies on this explicit list for sandbox path normalization and for outbound media-access policy, which removes the need for shared-core special cases around provider-specific avatar, attachment, or cover-image parameters.
An action-keyed map like { "set-profile": ["avatarUrl", "avatarPath"] } is the preferred structure, so unrelated actions do not pick up another action's media arguments. A flat array remains viable for parameters that are deliberately shared across every exposed action.
For channels that need to expose a temporary public URL for a platform-side media fetch, createHostedOutboundMediaStore(...) from openclaw/plugin-sdk/outbound-media can be used together with plugin state stores. Keep platform route parsing and token enforcement inside the channel plugin; the shared helper handles only media loading, expiry metadata, chunk rows, and cleanup.
prepareUrl({ mediaAccess }) forwards host-authorized local media access to the shared outbound loader. Hosted media capacity defaults to overflowPolicy: "evict-oldest" for compatibility. When issued URLs must stay valid until expiry, use "reject-new", and configure both backing keyed stores with "reject-new" so independent writers cannot evict live rows. To inspect the guarded loader's exact bytes and metadata when a transport must reject a payload class, use validateBeforePersist. Treat its buffer as read-only and throw to reject before capability creation or any store write. Authenticate bearer requests with readMetadata(...) before calling read(...) so invalid tokens and HEAD requests do not hydrate stored media chunks.
Inbound attachments rely on 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 requires provider-specific shaping for message(action="send"), actions.prepareSendPayload(...) is the preferred option. 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 do not need approval-specific code. Core owns same-chat /approve, shared approval button payloads, and generic fallback delivery. ChannelPlugin.approvals was removed; put approval delivery/native/render/auth facts on one approvalCapability object instead. plugin.auth is login/logout only - core no longer reads approval auth hooks from that object.
Use approvalCapability.delivery only for native approval routing or fallback suppression, and approvalCapability.render only when a channel truly needs custom approval payloads instead of the shared renderer.
Approval auth
- The canonical seam for approval auth is defined by
approvalCapability.authorizeActorActionandapprovalCapability.getActionAvailabilityState. - To check whether same-chat approval auth is available, rely on
getActionAvailabilityState. Even when native delivery is turned off, keep configured approvers accessible for/approve; for delivery or setup guidance, depend on the native initiating-surface state instead. - When your channel exposes native exec approvals, apply
approvalCapability.getExecInitiatingSurfaceStateto capture the initiating-surface or native-client state if it diverges from same-chat approval auth. Core leverages that exec-specific hook to tellenabledapart fromdisabled, determine if the initiating channel supports native exec approvals, and add the channel to native-client fallback guidance. For typical scenarios,createApproverRestrictedNativeApprovalCapability(...)supplies this information. - If stable owner-like DM identities can be derived from existing configuration, pull
createResolvedApproverActionAuthAdapterfromopenclaw/plugin-sdk/approval-runtimeto limit same-chat/approvewithout introducing approval-specific logic into core. - For custom approval auth that deliberately permits only same-chat fallback, have
openclaw/plugin-sdk/approval-auth-runtimereturnmarkImplicitSameChatApprovalAuthorization({ authorized: true }); otherwise, core treats the outcome as explicit approver authorization. - When a channel-owned native callback resolves approvals directly, invoke
isImplicitSameChatApprovalAuthorization(...)before resolution so implicit fallback still passes through the channel's standard actor authorization.
Payload lifecycle and setup guidance
- For channel-specific payload lifecycle behavior, such as suppressing duplicate local approval prompts or dispatching typing indicators prior to delivery, use
outbound.shouldSuppressLocalPayloadPromptoroutbound.beforeDeliverPayload. - When the disabled-path reply should spell out the exact config knobs required to activate native exec approvals, employ
approvalCapability.describeExecApprovalSetup. The hook receives{ channel, channelLabel, accountId }; named-account channels should present account-scoped paths likechannels.<channel>.accounts.<id>.execApprovals.*rather than top-level defaults. - Use
approvalCapability.describePluginApprovalSetupwhen plugin approval failure guidance is safe to display for no-route and timeout failures.createApproverRestrictedNativeApprovalCapability(...)does not derive this fromdescribeExecApprovalSetup; pass the same helper explicitly only when plugin and exec approvals genuinely share the same native setup.
Native approval delivery
For native approval delivery, keep channel code focused on target normalization plus transport and presentation details. Pull in createChannelExecApprovalProfile, createChannelNativeOriginTargetResolver, createChannelApproverDmTargetResolver, and createApproverRestrictedNativeApprovalCapability from openclaw/plugin-sdk/approval-runtime. Encapsulate the channel-specific facts behind approvalCapability.nativeRuntime, preferably via createChannelApprovalNativeRuntimeAdapter(...) or createLazyChannelApprovalNativeRuntimeAdapter(...), so core can build the handler and manage request filtering, routing, dedupe, expiry, gateway subscription, and routed-elsewhere notices.
nativeRuntime breaks down into several smaller seams:
availability- whether the account is set up and whether a request warrants handlingpresentation- translate the shared approval view model into pending, resolved, or expired native payloads or final actionstransport- prepare targets and send, update, or delete native approval messagesinteractions- optional bind, unbind, and clear-action hooks for native buttons or reactions, plus an optionalcancelDeliveredhook. ImplementcancelDeliveredwhendeliverPendingregisters in-process or persistent state, such as a reaction target store, so that state can be freed if a handler stop cancels delivery beforebindPendingexecutes, or whenbindPendingyields no handleobserve- optional delivery diagnostics hooks
Additional approval helpers:
- When a channel supports both session-origin native delivery and explicit approval forwarding targets, pull in
createNativeApprovalChannelRouteGatesfromopenclaw/plugin-sdk/approval-native-runtime. This helper consolidates approval configuration selection,modehandling, agent/session filters, account binding, session-target matching, and target-list matching, while the caller keeps responsibility for 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, pass the channel's documented default mode explicitly. - For messaging transports whose native approval target is a channel-owned normalized destination,
createNativeApprovalMessagingTargetResolverscentralizes channel matching and{ to, accountId, threadId }normalization. Keep group authorization, approver mapping, and other transport policy within the channel plugin. - By default,
createChannelNativeOriginTargetResolverrelies on the shared channel-route matcher for{ to, accountId, threadId }targets. SupplytargetsMatchonly when a channel has provider-specific equivalence rules, like Slack timestamp prefix matching. ProvidenormalizeTargetForMatchwhen the channel must canonicalize provider ids before the default route matcher or a customtargetsMatchcallback executes, while keeping the original target for delivery. UsenormalizeTargetonly when the resolved delivery target itself requires canonicalization. - If runtime-owned objects such as a client, token, Bolt app, or webhook receiver are needed, register them via
openclaw/plugin-sdk/channel-runtime-context. The generic runtime-context registry allows core to bootstrap capability-driven handlers from channel startup state without adding approval-specific wrapper glue. - Turn to the lower-level
createChannelApprovalHandlerorcreateChannelNativeApprovalRuntimeonly when the capability-driven seam lacks sufficient expressiveness. - Native approval channels must direct both
accountIdandapprovalKindthrough those helpers.accountIdscopes multi-account approval policy to the correct bot account, andapprovalKindmakes exec vs plugin approval behavior available to the channel without hardcoded branches in core. - Core also owns approval reroute notices. Channel plugins should not emit their own "approval went to DMs / another channel" follow-up messages from
createChannelNativeApprovalRuntime; instead, expose accurate origin plus approver-DM routing through the shared approval capability helpers, and let core aggregate actual deliveries before posting any notice back to the initiating chat. - Preserve the delivered approval id kind end-to-end. Native clients should not guess or rewrite exec vs plugin approval routing from channel-local state.
- Pass that explicit
approvalKindtoresolveApprovalOverGateway. This leverages the canonicalapproval.resolveservice and returns the recorded winner when another surface answers first. The older explicitresolveMethodinput 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 exec and plugin approvals, while still letting auth differ by approval kind; Slack keeps native approval routing available for both exec and plugin ids.
createApproverRestrictedNativeApprovalAdapterstill exists as a compatibility wrapper, but new code should prefer the capability builder and exposeapprovalCapabilityon 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-runtimeopenclaw/plugin-sdk/approval-client-runtimeopenclaw/plugin-sdk/approval-delivery-runtimeopenclaw/plugin-sdk/approval-gateway-runtimeopenclaw/plugin-sdk/approval-reference-runtimeopenclaw/plugin-sdk/approval-handler-adapter-runtimeopenclaw/plugin-sdk/approval-handler-runtimeopenclaw/plugin-sdk/approval-native-runtimeopenclaw/plugin-sdk/approval-reply-runtimeopenclaw/plugin-sdk/channel-runtime-context
Likewise, 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-runtimecovers 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-setupcovers the optional-install setup builders plus a few setup-safe primitives:createOptionalChannelSetupSurface,createOptionalChannelSetupAdapter,createOptionalChannelSetupWizard,DEFAULT_ACCOUNT_ID,createTopLevelChannelDmPolicy,setSetupChannelEnabled, andsplitSetupEntries.- Use the broader
openclaw/plugin-sdk/setupseam only when you also need the heavier shared setup/config helpers such asmoveSingleAccountChannelSectionToDefaultAccount(...).
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 relies on environment-based configuration or authentication, surface that through the channel config schema and setup descriptors. Reserve channel runtime envVars or local constants strictly for operator-facing text.
When your channel needs to appear in status, channels list, channels status, or SecretRef scans before the plugin runtime boots, place openclaw.setupEntry inside package.json. That entrypoint must remain import-safe within read-only command paths and should hand back the channel metadata, a setup-safe config adapter, a status adapter, and the channel secret target metadata those summaries require. Avoid launching clients, listeners, or transport runtimes from the setup entry.
Keep the main channel entry import surface narrow as well. Discovery can inspect the entry and the channel plugin module to register capabilities without activating the channel. Files such as channel-plugin-api.ts should expose the channel plugin object without pulling in setup wizards, transport clients, socket listeners, subprocess launchers, or service startup modules. Move those runtime components into modules loaded from registerFull(...), runtime setters, or lazy capability adapters.
Other narrow channel subpaths
For other hot channel paths, favor the narrow helpers over broader legacy surfaces:
openclaw/plugin-sdk/account-core,openclaw/plugin-sdk/account-id,openclaw/plugin-sdk/account-resolution, andopenclaw/plugin-sdk/account-helpersfor multi-account config and default-account fallbackopenclaw/plugin-sdk/inbound-envelopeandopenclaw/plugin-sdk/channel-inboundfor inbound route/envelope and record-and-dispatch wiringreadAgentRunTerminalOutcome(dispatchResult)fromopenclaw/plugin-sdk/channel-inboundwhen terminal reactions or status UI must distinguish a completed core agent run from a recovered failed run. It returns"completed"or"failed"only when a core run actually started, andundefinedfor commands, dedupe, busy, pre-run abort, and custom dispatch results. Delivery counts and visibility remain transport facts, including successful delivery of an error payload; the process-local carrier is not serialized to JSON.createInboundEventDeliveryCorrelation(...)fromopenclaw/plugin-sdk/inbound-event-deliverywhen successful outbound sends must retire an active inbound-event marker; create one tracker per channel and keep target matching in the channel pluginopenclaw/plugin-sdk/channel-targetsfor target parsing helpersopenclaw/plugin-sdk/channel-outboundfor outbound identity/send delegates and typed payload planningbuildThreadAwareOutboundSessionRoute(...)fromopenclaw/plugin-sdk/channel-corewhen an outbound route should preserve an explicitreplyToId/threadIdor recover the current:thread:session after 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-runtimefor thread-binding lifecycle and adapter registration
Auth-only channels can usually stop at the default path: core handles approvals and the plugin just exposes outbound/auth capabilities. Native approval channels such as Matrix, Slack, Telegram, and custom chat transports should use the shared native helpers instead of rolling their own approval lifecycle.
Inbound mention policy
Keep inbound mention handling split in 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:
- Compute local mention facts.
- Pass those facts into
resolveInboundMentionDecision({ facts, policy }). - Use
decision.effectiveWasMentioned,decision.shouldBypassMention, anddecision.shouldSkipin 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) is what 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 checks plugins.entries.acme-chat.config. Apply it to
plugin-managed settings that fall outside the channel account configuration.
channelConfigs.acme-chat.schema verifies channels.acme-chat and serves as the
cold-path reference for config schema, setup, and UI surfaces prior to
plugin runtime startup. Consult Plugin manifest for the complete
top-level field listing.
Build the channel plugin object
The ChannelPlugin interface exposes numerous optional adapter surfaces. Begin
with the essentials - id, config, and setup - then introduce adapters as required.
Instantiate 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 handling both canonical top-level DM keys and legacy nested keys, pull utilities from plugin-sdk/channel-config-helpers: resolveChannelDmAccess, resolveChannelDmPolicy, resolveChannelDmAllowFrom, and normalizeChannelDmPolicy prioritize account-local values over inherited root ones. Combine the same resolver with doctor repair via normalizeLegacyDmAliases so runtime and migration share one contract.
When a channel deliberately enforces stricter DM session routing than the
global configuration, surface that via security.dmRouting so Doctor
and security audit agree on the session owner with runtime. The optional
resolveDmScope callback fires before core route resolution; its context
carries cfg, accountId, the resolved account, and a principalId
for bounded allowlist entries. resolveDmRoute receives those fields plus
the resolved core route; it can return { sessionKey } for a common final
bucket, { kind: "isolated" } for an unrecognized peer, or { kind: "core" }
to retain core dmScope namespace analysis. For wildcard/open policy,
principalId is missing and an undefined result gets reported as unverified.
Diagnostics never fabricate a peer ID. Keep both callbacks pure and
import-safe since read-only diagnostics execute without channel runtime.
What createChatChannelPlugin does for you
Rather than coding low-level adapter interfaces by hand, supply declarative options and let the builder assemble them:
| Option | What it wires |
|---|---|
security.dm | Scoped DM security resolver derived from config fields |
pairing.text | Text-based DM pairing flow with code exchange |
threading | Reply-to-mode resolver (fixed, account-scoped, or custom) |
outbound.attachedResults | Send functions yielding result metadata (message IDs); needs a sibling channel id so core can stamp the returned delivery result |
Raw adapter objects can be passed instead of declarative options when full control is needed.
Raw outbound adapters may define a chunker(text, limit, ctx) function.
The optional ctx.formatting holds delivery-time formatting choices
like maxLinesPerMessage; apply it before sending so reply threading
and chunk boundaries get resolved once by shared outbound delivery.
Send contexts also include replyToIdSource (implicit or explicit)
when a native reply target has been resolved, letting payload helpers keep
explicit reply tags without using an implicit single-use reply slot.
Group tool-policy adapters
A channel implementing group.resolveToolPolicy and supporting
toolsBySender must pass the full ChannelGroupContext to its
shared policy resolver. Specifically, respect senderPolicyMode: "never"
by omitting 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, such as an
explicitly capped scheduled run. Plugins must not infer the mode from
inbound metadata, store it as channel state, or expose it as config. Add
an adapter test proving the mode skips a wildcard toolsBySender entry
without dropping the matching base tools restriction.
Native plugin command ownership
Channel plugins publishing provider-native command catalogs should adopt
openclaw/plugin-sdk/plugin-command-runtime. Build one runtime while
planning the catalog, merge its candidates with built-in and skill entries,
and keep the winning candidate object in the registered handler closure.
After the provider catalog is finalized, invoke
retainNativeCatalog(provider) when at least one plugin candidate remains;
if listener registration can fail synchronously, call it after those
listeners are installed. This captures the current channel-account lifecycle
so a registry reload restarts only accounts whose handlers retain that
registry generation.
Call prepareDispatch(rawArgs) only on that winner and execute the returned
dispatch with dispatch.execute(context). Carry an explicit
{ kind: "non-plugin" } decision for retained built-in and skill winners.
This ensures the advertised command and
its executable plugin registration stay on the same registry generation.
Candidates expose only immutable display/auth/progress metadata plus an opaque process-local dispatch. They do not expose handlers, plugin roots, or registry rows. Dispatches cannot cross runtime factories or channels, and a registry replacement makes new executions return an unavailable result instead of rematching command text against the replacement registry. A command already admitted before retirement may finish on its captured generation. Do not serialize candidates or dispatches; project only their display fields into provider API payloads.
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(/* ... */);
},
});
Store channel-specific CLI descriptors in registerCliMetadata(...) so that OpenClaw can
surface them in root help without spinning up the full channel runtime.
Normal full loads continue to read the same descriptors for actual command
registration. Keep registerFull(...) exclusively for runtime tasks.
defineChannelPluginEntry takes care of the registration-mode split on its own.
When registerFull(...) registers gateway RPC methods, apply a
plugin-specific prefix. Core admin namespaces (config.*,
exec.approvals.*, wizard.*, update.*) remain reserved and always
point to operator.admin. Check
Entry Points for the complete
list of options.
Add a setup entry
Add 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);
When the channel is disabled or not configured, OpenClaw loads this file instead of the full entry point. Setup flows avoid pulling in heavy runtime code this way. Refer to Setup and Config for more.
Bundled workspace channels that separate setup-safe exports into sidecar
modules can use defineBundledChannelSetupEntry(...) from
openclaw/plugin-sdk/channel-entry-contract when they also require an
explicit setup-time runtime setter.
Handle inbound messages
Your plugin must receive messages from the platform and pass them along to OpenClaw. The usual approach is a webhook that validates the request and routes 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 varies by channel. Every channel plugin manages its own inbound pipeline. Review bundled channel plugins (such as the Microsoft Teams or Google Chat plugin package) for real examples.
Test
Put 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
-
Threading options, Fixed, account-scoped, or custom reply modes
-
Message tool integration, describeMessageTool and action discovery
-
Target resolution, inferTargetChatType, looksLikeId, reservedLiterals, resolveTarget
-
Runtime helpers, TTS, STT, media, subagent via api.runtime
-
Channel inbound API, Shared inbound event lifecycle: ingest, resolve, record, dispatch, finalize
Note
Some bundled helper seams remain for maintaining bundled plugins and compatibility. They are not the recommended approach for new channel plugins; use the generic channel/setup/reply/runtime subpaths from the common SDK surface unless you are directly maintaining that bundled plugin family.
Next steps
- Provider Plugins - if your plugin also provides models
- SDK Overview - full subpath import reference
- SDK Testing - test utilities and contract tests
- Plugin Manifest - full manifest schema