This guide walks through building a channel plugin that connects OpenClaw to a messaging platform. By the end you will have a working channel with DM security, pairing, reply threading, and outbound messaging.Documentation Index
Fetch the complete documentation index at: https://docs.openclaw.ai/llms.txt
Use this file to discover all available pages before exploring further.
If you have not built any OpenClaw plugin before, read
Getting Started first for the basic package
structure and manifest setup.
How channel plugins work
Channel plugins do not need their own send/edit/react tools. OpenClaw keeps one sharedmessage tool in core. Your plugin owns:
- Config - account resolution and setup wizard
- Security - DM policy and allowlists
- Pairing - 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 threaded
- Heartbeat typing - optional typing/busy signals for heartbeat delivery targets
:thread: bookkeeping, and dispatch.
New channel plugins should also expose a message adapter with
defineChannelMessageAdapter from openclaw/plugin-sdk/channel-message. The
adapter declares which durable final-send capabilities the native transport
actually supports and points text/media sends at the same transport functions as
the legacy outbound adapter. Only declare a capability when a contract test
proves the native side effect and returned receipt.
For the full API contract, examples, capability matrix, receipt rules, live
preview finalization, receive ack policy, tests, and migration table, see
Channel message API.
If the existing outbound adapter already has the right send methods and
capability metadata, use createChannelMessageAdapterFromOutbound(...) to
derive the message adapter instead of hand-writing another bridge.
Adapter sends should return MessageReceipt values. When compatibility code
still needs legacy ids, derive them with listMessageReceiptPlatformIds(...)
or resolveMessageReceiptPrimaryId(...) instead of keeping parallel
messageIds fields in new lifecycle code.
Preview-capable channels should also declare message.live.capabilities with
the exact live lifecycle they own, such as draftPreview,
previewFinalization, progressUpdates, nativeStreaming, or
quietFinalization. Channels that finalize a draft preview in place should
also declare message.live.finalizer.capabilities, such as finalEdit,
normalFallback, discardPending, previewReceipt, and
retainOnAmbiguousFailure, and route the runtime logic through
defineFinalizableLivePreviewAdapter(...) plus
deliverWithFinalizableLivePreviewAdapter(...). Keep those capabilities backed
by verifyChannelMessageLiveCapabilityAdapterProofs(...) and
verifyChannelMessageLiveFinalizerProofs(...) tests so native preview,
progress, edit, fallback/retention, cleanup, and receipt behavior cannot drift
silently.
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/turn helpers such as createChannelTurnReplyPipeline,
dispatchInboundReplyWithBase, and recordInboundSessionAndDispatchReply
remain available for compatibility dispatchers. Do not use those names for new
channel code; new plugins should start with the message adapter, receipts, and
receive/send lifecycle helpers on openclaw/plugin-sdk/channel-message.
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.
If your channel adds message-tool params that carry media sources, expose those
param names through 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 returning 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 that
are intentionally shared across every exposed action.
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 perform
the actual send through the outbound/message adapter. Use
actions.handleAction(...) for send only as a compatibility fallback for
payloads that cannot be serialized and retried.
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, keep them ordered from the
narrowest parent to the broadest/base conversation.
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 plugins should prefer
it over ad hoc String(threadId) comparisons.
Plugins with provider-specific target grammar can inject their parser into
resolveChannelRouteTargetWithParser(...) and still get the same route target
shape and thread fallback semantics core uses.
Bundled plugins that need the same parsing before the channel registry boots
can also expose a top-level session-key-api.ts file with a matching
resolveSessionConversation(...) export. Core uses that bootstrap-safe surface
only when the runtime plugin registry is not available yet.
messaging.resolveParentConversationCandidates(...) remains available as a
legacy compatibility fallback when a plugin only needs 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.
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. - Prefer one
approvalCapabilityobject on the channel plugin when the channel needs approval-specific behavior. ChannelPlugin.approvalsis removed. Put approval delivery/native/render/auth facts onapprovalCapability.plugin.authis login/logout only; core no longer reads approval auth hooks from that object.approvalCapability.authorizeActorActionandapprovalCapability.getActionAvailabilityStateare the canonical approval-auth seam.- Use
approvalCapability.getActionAvailabilityStatefor same-chat approval auth availability. - If your channel exposes native exec approvals, use
approvalCapability.getExecInitiatingSurfaceStatefor the initiating-surface/native-client state when it differs from same-chat approval auth. Core uses that exec-specific hook to distinguishenabledvsdisabled, decide whether the initiating channel supports native exec approvals, and include the channel in native-client fallback guidance.createApproverRestrictedNativeApprovalCapability(...)fills this in for the common case. - Use
outbound.shouldSuppressLocalPayloadPromptoroutbound.beforeDeliverPayloadfor channel-specific payload lifecycle behavior such as hiding duplicate local approval prompts or sending typing indicators before delivery. - Use
approvalCapability.deliveryonly for native approval routing or fallback suppression. - Use
approvalCapability.nativeRuntimefor channel-owned native approval facts. Keep it lazy on hot channel entrypoints withcreateLazyChannelApprovalNativeRuntimeAdapter(...), which can import your runtime module on demand while still letting core assemble the approval lifecycle. - Use
approvalCapability.renderonly when a channel truly needs custom approval payloads instead of the shared renderer. - Use
approvalCapability.describeExecApprovalSetupwhen the channel wants the disabled-path reply to explain the exact config knobs needed to enable native exec approvals. The hook receives{ channel, channelLabel, accountId }; named-account channels should render account-scoped paths such aschannels.<channel>.accounts.<id>.execApprovals.*instead of top-level defaults. - If a channel can infer stable owner-like DM identities from existing config, use
createResolvedApproverActionAuthAdapterfromopenclaw/plugin-sdk/approval-runtimeto restrict same-chat/approvewithout adding approval-specific core logic. - If a channel needs native approval delivery, keep channel code focused on target normalization plus transport/presentation facts. Use
createChannelExecApprovalProfile,createChannelNativeOriginTargetResolver,createChannelApproverDmTargetResolver, andcreateApproverRestrictedNativeApprovalCapabilityfromopenclaw/plugin-sdk/approval-runtime. Put the channel-specific facts behindapprovalCapability.nativeRuntime, ideally viacreateChannelApprovalNativeRuntimeAdapter(...)orcreateLazyChannelApprovalNativeRuntimeAdapter(...), so core can assemble the handler and own request filtering, routing, dedupe, expiry, gateway subscription, and routed-elsewhere notices.nativeRuntimeis split into a few smaller seams: createChannelNativeOriginTargetResolveruses the shared channel-route matcher by default for{ to, accountId, threadId }targets. PasstargetsMatchonly when a channel has provider-specific equivalence rules, such as Slack timestamp prefix matching.- Pass
normalizeTargetForMatchtocreateChannelNativeOriginTargetResolverwhen the channel needs to canonicalize provider ids before the default route matcher or a customtargetsMatchcallback runs, while preserving the original target for delivery. UsenormalizeTargetonly when the resolved delivery target itself should be canonicalized. availability- whether the account is configured and whether a request should be handledpresentation- map the shared approval view model into pending/resolved/expired native payloads or final actionstransport- prepare targets plus send/update/delete native approval messagesinteractions- optional bind/unbind/clear-action hooks for native buttons or reactionsobserve- optional delivery diagnostics hooks- If the channel needs runtime-owned objects such as a client, token, Bolt app, or webhook receiver, register them through
openclaw/plugin-sdk/channel-runtime-context. The generic runtime-context registry lets core bootstrap capability-driven handlers from channel startup state without adding approval-specific wrapper glue. - Reach for the lower-level
createChannelApprovalHandlerorcreateChannelNativeApprovalRuntimeonly when the capability-driven seam is not expressive enough yet. - Native approval channels must route both
accountIdandapprovalKindthrough those helpers.accountIdkeeps multi-account approval policy scoped to the right bot account, andapprovalKindkeeps exec vs plugin approval behavior available to the channel without hardcoded branches in core. - Core now owns approval reroute notices too. Channel plugins should not send their own “approval went to DMs / another channel” follow-up messages from
createChannelNativeApprovalRuntime; instead, expose accurate origin + 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.
- Different approval kinds can intentionally expose different native surfaces.
Current bundled examples:
- Slack keeps native approval routing available for both exec and plugin ids.
- Matrix keeps the same native DM/channel routing and reaction UX for exec and plugin approvals, while still letting auth differ by approval kind.
createApproverRestrictedNativeApprovalAdapterstill exists as a compatibility wrapper, but new code should prefer the capability builder and exposeapprovalCapabilityon the plugin.
openclaw/plugin-sdk/approval-auth-runtimeopenclaw/plugin-sdk/approval-client-runtimeopenclaw/plugin-sdk/approval-delivery-runtimeopenclaw/plugin-sdk/approval-gateway-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
openclaw/plugin-sdk/setup-runtime,
openclaw/plugin-sdk/setup-adapter-runtime,
openclaw/plugin-sdk/reply-runtime,
openclaw/plugin-sdk/reply-dispatch-runtime,
openclaw/plugin-sdk/reply-reference, and
openclaw/plugin-sdk/reply-chunking when you do not need the broader umbrella
surface.
For setup specifically:
openclaw/plugin-sdk/setup-runtimecovers the runtime-safe setup helpers: import-safe setup patch adapters (createPatchedAccountSetupAdapter,createEnvPatchedAccountSetupAdapter,createSetupInputPresenceValidator), lookup-note output,promptResolvedAllowFrom,splitSetupEntries, and the delegated setup-proxy buildersopenclaw/plugin-sdk/setup-adapter-runtimeis the narrow env-aware adapter seam forcreateEnvPatchedAccountSetupAdapteropenclaw/plugin-sdk/channel-setupcovers the optional-install setup builders plus a few setup-safe primitives:createOptionalChannelSetupSurface,createOptionalChannelSetupAdapter,
channelEnvVars. Keep channel runtime envVars or local
constants for operator-facing copy only.
If your channel can appear in status, channels list, channels status, or
SecretRef scans before the plugin runtime starts, add openclaw.setupEntry in
package.json. That entrypoint should be safe to import in read-only command
paths and should return the channel metadata, setup-safe config adapter, status
adapter, and channel secret target metadata needed for those summaries. Do not
start clients, listeners, or transport runtimes from the setup entry.
Keep the main channel entry import path narrow too. Discovery can evaluate the
entry and the channel plugin module to register capabilities without activating
the channel. Files such as channel-plugin-api.ts should export the channel
plugin object without importing setup wizards, transport clients, socket
listeners, subprocess launchers, or service startup modules. Put those runtime
pieces in modules loaded from registerFull(...), runtime setters, or lazy
capability adapters.
createOptionalChannelSetupWizard, DEFAULT_ACCOUNT_ID,
createTopLevelChannelDmPolicy, setSetupChannelEnabled, and
splitSetupEntries
- use the broader
openclaw/plugin-sdk/setupseam only when you also need the heavier shared setup/config helpers such asmoveSingleAccountChannelSectionToDefaultAccount(...)
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.
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, andopenclaw/plugin-sdk/account-helpersfor multi-account config and default-account fallbackopenclaw/plugin-sdk/inbound-envelopeandopenclaw/plugin-sdk/inbound-reply-dispatchfor inbound route/envelope and record-and-dispatch wiringopenclaw/plugin-sdk/messaging-targetsfor target parsing/matchingopenclaw/plugin-sdk/outbound-mediaandopenclaw/plugin-sdk/outbound-runtimefor media loading plus outbound identity/send delegates and 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 registrationopenclaw/plugin-sdk/agent-media-payloadonly when a legacy agent/media payload field layout is still requiredopenclaw/plugin-sdk/telegram-command-configfor Telegram custom-command normalization, duplicate/conflict validation, and a fallback-stable command config contract
Inbound mention policy
Keep inbound mention handling split in two layers:- plugin-owned evidence gathering
- shared policy evaluation
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
requireMention- explicit mention result
- implicit mention allowlist
- command bypass
- final skip decision
- Compute local mention facts.
- Pass those facts into
resolveInboundMentionDecision({ facts, policy }). - Use
decision.effectiveWasMentioned,decision.shouldBypassMention, anddecision.shouldSkipin your inbound gate.
api.runtime.channel.mentions exposes the same shared mention helpers for
bundled channel plugins that already depend on runtime injection:
buildMentionRegexesmatchesMentionPatternsmatchesMentionWithExplicitimplicitMentionKindWhenresolveInboundMentionDecision
implicitMentionKindWhen and
resolveInboundMentionDecision, import from
openclaw/plugin-sdk/channel-mention-gating to avoid loading unrelated inbound
runtime helpers.
The older resolveMentionGating* helpers remain on
openclaw/plugin-sdk/channel-inbound as compatibility exports only. New code
should use resolveInboundMentionDecision({ facts, policy }).
Walkthrough
Package and manifest
Create the standard plugin files. The
channel field in package.json is
what makes this a channel plugin. For the full package-metadata surface,
see Plugin Setup and Config:configSchema validates plugins.entries.acme-chat.config. Use it for
plugin-owned settings that are not the channel account config. channelConfigs
validates channels.acme-chat and is the cold-path source used by config
schema, setup, and UI surfaces before the plugin runtime loads.Build the channel plugin object
The For channels that accept both canonical top-level DM keys and legacy nested keys, use the helpers from
ChannelPlugin interface has many optional adapter surfaces. Start with
the minimum - id and setup - and add adapters as you need them.Create src/channel.ts:src/channel.ts
plugin-sdk/channel-config-helpers: resolveChannelDmAccess, resolveChannelDmPolicy, resolveChannelDmAllowFrom, and normalizeChannelDmPolicy keep account-local values ahead of inherited root values. Pair the same resolver with doctor repair through normalizeLegacyDmAliases so runtime and migration read the same contract.What createChatChannelPlugin does for you
What createChatChannelPlugin does for you
Instead of implementing low-level adapter interfaces manually, you pass
declarative options and the builder composes them:
You can also pass raw adapter objects instead of the declarative options
if you need full control.Raw outbound adapters may define a
| Option | What it wires |
|---|---|
security.dm | Scoped DM security resolver 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 that return result metadata (message IDs) |
chunker(text, limit, ctx) function.
The optional ctx.formatting carries delivery-time formatting decisions
such as maxLinesPerMessage; apply it before sending so reply threading
and chunk boundaries are resolved once by shared outbound delivery.
Send contexts also include replyToIdSource (implicit or explicit)
when a native reply target was resolved, so payload helpers can preserve
explicit reply tags without consuming an implicit single-use reply slot.Wire the entry point
Create Put channel-owned CLI descriptors in
index.ts:index.ts
registerCliMetadata(...) so OpenClaw
can show them in root help without activating the full channel runtime,
while normal full loads still pick up the same descriptors for real command
registration. Keep registerFull(...) for runtime-only work.
If registerFull(...) registers gateway RPC methods, use a
plugin-specific prefix. Core admin namespaces (config.*,
exec.approvals.*, wizard.*, update.*) stay reserved and always
resolve to operator.admin.
defineChannelPluginEntry handles the registration-mode split automatically. See
Entry Points for all
options.Add a setup entry
Create OpenClaw loads this instead of the full entry 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
setup-entry.ts for lightweight loading during onboarding:setup-entry.ts
defineBundledChannelSetupEntry(...) from
openclaw/plugin-sdk/channel-entry-contract when they also need an
explicit setup-time runtime setter.Handle inbound messages
Your plugin needs to receive messages from the platform and forward them to
OpenClaw. The typical pattern is a webhook that verifies the request and
dispatches it through your channel’s inbound handler:
Inbound message handling is channel-specific. Each channel plugin owns
its own inbound pipeline. Look at bundled channel plugins
(for example the Microsoft Teams or Google Chat plugin package) for real patterns.
Test
Write colocated tests in For shared test helpers, see Testing.
src/channel.test.ts:src/channel.test.ts
File structure
Advanced topics
Threading options
Fixed, account-scoped, or custom reply modes
Message tool integration
describeMessageTool and action discovery
Target resolution
inferTargetChatType, looksLikeId, resolveTarget
Runtime helpers
TTS, STT, media, subagent via api.runtime
Channel turn kernel
Shared inbound turn lifecycle: ingest, resolve, record, dispatch, finalize
Some bundled helper seams still exist for bundled-plugin maintenance and
compatibility. They are not the recommended pattern for new channel plugins;
prefer the generic channel/setup/reply/runtime subpaths from the common SDK
surface unless you are maintaining that bundled plugin family directly.
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