Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
911
docs/plugins/sdk-channel-plugins.md
Normal file
911
docs/plugins/sdk-channel-plugins.md
Normal file
@@ -0,0 +1,911 @@
|
||||
---
|
||||
summary: "Step-by-step guide to building a messaging channel plugin for OpenClaw"
|
||||
title: "Building channel plugins"
|
||||
sidebarTitle: "Channel Plugins"
|
||||
read_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 builds a channel plugin that connects OpenClaw to a messaging
|
||||
platform: DM security, pairing, reply threading, and outbound messaging.
|
||||
|
||||
<Info>
|
||||
New to OpenClaw plugins? Read [Getting Started](/plugins/building-plugins)
|
||||
first for package structure and manifest setup.
|
||||
</Info>
|
||||
|
||||
## What your plugin owns
|
||||
|
||||
Channel plugins do not implement send/edit/react tools; core provides one
|
||||
shared `message` tool. 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
|
||||
|
||||
Core owns the shared message tool, prompt wiring, the outer session-key shape,
|
||||
generic `:thread:` bookkeeping, and dispatch.
|
||||
|
||||
## Message adapter
|
||||
|
||||
Expose a `message` adapter with `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 proves the native side effect and returned receipt. Point text/media
|
||||
sends at the same transport functions the legacy `outbound` adapter uses. For
|
||||
the full API contract, capability matrix, receipt rules, live preview
|
||||
finalization, receive ack policy, tests, and migration table, see
|
||||
[Channel outbound API](/plugins/sdk-channel-outbound).
|
||||
|
||||
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 uses these to decide
|
||||
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.
|
||||
|
||||
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 `createChannelTurnReplyPipeline`,
|
||||
`dispatchInboundReplyWithBase`, and `recordInboundSessionAndDispatchReply`
|
||||
remain available for compatibility dispatchers. Do not use them 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 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/activation
|
||||
projections plus the ordered ingress graph, while platform lookup and side
|
||||
effects stay 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](/plugins/sdk-channel-ingress) for the API design,
|
||||
ownership boundary, and test expectations. The older
|
||||
`openclaw/plugin-sdk/channel-ingress` subpath stays exported as a deprecated
|
||||
compatibility facade for third-party plugins.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
## 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
|
||||
|
||||
- `approvalCapability.authorizeActorAction` and
|
||||
`approvalCapability.getActionAvailabilityState` are the canonical
|
||||
approval-auth seam.
|
||||
- Use `getActionAvailabilityState` for same-chat approval auth availability.
|
||||
Keep configured approvers available for `/approve` even when native delivery
|
||||
is disabled; use native initiating-surface state for delivery/setup guidance
|
||||
instead.
|
||||
- If your channel exposes native exec approvals, use
|
||||
`approvalCapability.getExecInitiatingSurfaceState` for the
|
||||
initiating-surface/native-client state when it differs from same-chat
|
||||
approval auth. Core uses that exec-specific hook to distinguish `enabled` vs
|
||||
`disabled`, 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.
|
||||
- If a channel can infer stable owner-like DM identities from existing config,
|
||||
use `createResolvedApproverActionAuthAdapter` from
|
||||
`openclaw/plugin-sdk/approval-runtime` to restrict same-chat `/approve`
|
||||
without adding approval-specific core logic.
|
||||
- If custom approval auth intentionally allows only same-chat fallback, return
|
||||
`markImplicitSameChatApprovalAuthorization({ authorized: true })` from
|
||||
`openclaw/plugin-sdk/approval-auth-runtime`; otherwise core treats the
|
||||
result as explicit approver authorization.
|
||||
- If a channel-owned native callback resolves approvals directly, use
|
||||
`isImplicitSameChatApprovalAuthorization(...)` before resolving so implicit
|
||||
fallback still goes 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 before delivery.
|
||||
- Use `approvalCapability.describeExecApprovalSetup` when 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 as
|
||||
`channels.<channel>.accounts.<id>.execApprovals.*` instead of top-level
|
||||
defaults.
|
||||
- Use `approvalCapability.describePluginApprovalSetup` when plugin approval
|
||||
failure guidance is safe to show for plugin approval no-route and timeout
|
||||
failures. `createApproverRestrictedNativeApprovalCapability(...)` does not
|
||||
infer this from `describeExecApprovalSetup`; pass the same helper explicitly
|
||||
only when plugin and exec approvals truly use the same native setup.
|
||||
|
||||
### Native approval delivery
|
||||
|
||||
If a channel needs native approval delivery, keep channel code focused on
|
||||
target normalization plus transport/presentation facts. Use
|
||||
`createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`,
|
||||
`createChannelApproverDmTargetResolver`, and
|
||||
`createApproverRestrictedNativeApprovalCapability` from
|
||||
`openclaw/plugin-sdk/approval-runtime`. Put the channel-specific facts behind
|
||||
`approvalCapability.nativeRuntime`, ideally via
|
||||
`createChannelApprovalNativeRuntimeAdapter(...)` or
|
||||
`createLazyChannelApprovalNativeRuntimeAdapter(...)`, so core can assemble the
|
||||
handler and own request filtering, routing, dedupe, expiry, gateway
|
||||
subscription, and routed-elsewhere notices.
|
||||
|
||||
`nativeRuntime` is split into a few smaller seams:
|
||||
|
||||
- `availability` - whether the account is configured and whether a request
|
||||
should be handled
|
||||
- `presentation` - map the shared approval view model into
|
||||
pending/resolved/expired native payloads or final actions
|
||||
- `transport` - prepare targets plus send/update/delete native approval
|
||||
messages
|
||||
- `interactions` - optional bind/unbind/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 the delivery before `bindPending` runs, or when
|
||||
`bindPending` returns no handle
|
||||
- `observe` - optional delivery diagnostics hooks
|
||||
|
||||
Other approval helpers:
|
||||
|
||||
- Use `createNativeApprovalChannelRouteGates` from
|
||||
`openclaw/plugin-sdk/approval-native-runtime` when a channel supports both
|
||||
session-origin native delivery and explicit approval forwarding targets. The
|
||||
helper centralizes approval config selection, `mode` handling, agent/session
|
||||
filters, account binding, session-target matching, and target-list matching
|
||||
while callers still own the channel id, default forwarding mode, account
|
||||
lookup, transport-enabled check, target normalization, and turn-source
|
||||
target resolution. Do not use it to create core-owned channel policy
|
||||
defaults; pass the channel's documented default mode explicitly.
|
||||
- `createChannelNativeOriginTargetResolver` uses the shared channel-route
|
||||
matcher by default for `{ to, accountId, threadId }` targets. Pass
|
||||
`targetsMatch` only when a channel has provider-specific equivalence rules,
|
||||
such as Slack timestamp prefix matching. Pass `normalizeTargetForMatch` when
|
||||
the channel needs to canonicalize provider ids before the default route
|
||||
matcher or a custom `targetsMatch` callback runs, while preserving the
|
||||
original target for delivery. Use `normalizeTarget` only when the resolved
|
||||
delivery target itself should be canonicalized.
|
||||
- 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 `createChannelApprovalHandler` or
|
||||
`createChannelNativeApprovalRuntime` only when the capability-driven seam is
|
||||
not expressive enough yet.
|
||||
- Native approval channels must route both `accountId` and `approvalKind`
|
||||
through those helpers. `accountId` keeps multi-account approval policy
|
||||
scoped to the right bot account, and `approvalKind` keeps exec vs plugin
|
||||
approval behavior available to the channel without hardcoded branches in
|
||||
core.
|
||||
- Core 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: 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.
|
||||
- `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-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`
|
||||
|
||||
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-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 and generic startup/config
|
||||
flows should know those env names before runtime loads, declare them in the
|
||||
plugin manifest with `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.
|
||||
|
||||
### 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 config 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/outbound-media` for media loading and
|
||||
`openclaw/plugin-sdk/channel-outbound` for outbound identity/send delegates
|
||||
and payload planning
|
||||
- `buildThreadAwareOutboundSessionRoute(...)` from
|
||||
`openclaw/plugin-sdk/channel-core` when an outbound route should preserve
|
||||
an explicit `replyToId`/`threadId` or 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-runtime` for thread-binding lifecycle
|
||||
and adapter registration
|
||||
- `openclaw/plugin-sdk/agent-media-payload` only when a legacy agent/media
|
||||
payload field layout is still required
|
||||
- `openclaw/plugin-sdk/telegram-command-config` (deprecated: no bundled
|
||||
plugin uses it in production) for Telegram custom-command normalization,
|
||||
duplicate/conflict validation, and a fallback-stable command config
|
||||
contract; prefer plugin-local command config handling for new plugin code
|
||||
|
||||
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:
|
||||
|
||||
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.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
implicitMentionKindWhen,
|
||||
matchesMentionWithExplicit,
|
||||
resolveInboundMentionDecision,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
|
||||
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 decision = resolveInboundMentionDecision({
|
||||
facts,
|
||||
policy: {
|
||||
isGroup,
|
||||
requireMention,
|
||||
allowedImplicitMentionKinds: requireExplicitMention ? [] : ["reply_to_bot", "quoted_bot"],
|
||||
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
|
||||
|
||||
<Steps>
|
||||
<a id="step-1-package-and-manifest"></a>
|
||||
<Step title="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](/plugins/sdk-setup#openclaw-channel):
|
||||
|
||||
<CodeGroup>
|
||||
```json package.json
|
||||
{
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json openclaw.plugin.json
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
`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](/plugins/manifest) for the full
|
||||
top-level field reference.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Build the channel plugin object">
|
||||
The `ChannelPlugin` interface has many optional adapter surfaces. Start with
|
||||
the minimum - `id`, `config`, and `setup` - and add adapters as you need
|
||||
them.
|
||||
|
||||
Create `src/channel.ts`:
|
||||
|
||||
```typescript 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 accept both canonical top-level DM keys and legacy nested keys, use the helpers from `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.
|
||||
|
||||
<Accordion title="What createChatChannelPlugin does for you">
|
||||
Instead of implementing low-level adapter interfaces manually, you pass
|
||||
declarative options and the builder composes them:
|
||||
|
||||
| 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); requires a sibling `channel` id so core can stamp the returned delivery result |
|
||||
|
||||
You can also pass raw adapter objects instead of the declarative options
|
||||
if you need full control.
|
||||
|
||||
Raw outbound adapters may define a `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.
|
||||
</Accordion>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Wire the entry point">
|
||||
Create `index.ts`:
|
||||
|
||||
```typescript 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(/* ... */);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Put channel-owned CLI descriptors in `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.
|
||||
`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.*`) stay reserved and always
|
||||
resolve to `operator.admin`. See
|
||||
[Entry Points](/plugins/sdk-entrypoints#definechannelpluginentry) for all
|
||||
options.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Add a setup entry">
|
||||
Create `setup-entry.ts` for lightweight loading during onboarding:
|
||||
|
||||
```typescript setup-entry.ts
|
||||
import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core";
|
||||
import { acmeChatPlugin } from "./src/channel.js";
|
||||
|
||||
export default defineSetupPluginEntry(acmeChatPlugin);
|
||||
```
|
||||
|
||||
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](/plugins/sdk-setup#setup-entry) 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.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="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:
|
||||
|
||||
```typescript
|
||||
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 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.
|
||||
</Note>
|
||||
|
||||
</Step>
|
||||
|
||||
<a id="step-6-test"></a>
|
||||
<Step title="Test">
|
||||
Write colocated tests in `src/channel.test.ts`:
|
||||
|
||||
```typescript 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);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm test <bundled-plugin-root>/acme-chat/
|
||||
```
|
||||
|
||||
For shared test helpers, see [Testing](/plugins/sdk-testing).
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## File structure
|
||||
|
||||
```text
|
||||
<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
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Threading options" icon="git-branch" href="/plugins/sdk-entrypoints#registration-mode">
|
||||
Fixed, account-scoped, or custom reply modes
|
||||
</Card>
|
||||
<Card title="Message tool integration" icon="puzzle" href="/plugins/architecture#channel-plugins-and-the-shared-message-tool">
|
||||
describeMessageTool and action discovery
|
||||
</Card>
|
||||
<Card title="Target resolution" icon="crosshair" href="/plugins/architecture-internals#channel-target-resolution">
|
||||
inferTargetChatType, looksLikeId, reservedLiterals, resolveTarget
|
||||
</Card>
|
||||
<Card title="Runtime helpers" icon="settings" href="/plugins/sdk-runtime">
|
||||
TTS, STT, media, subagent via api.runtime
|
||||
</Card>
|
||||
<Card title="Channel inbound API" icon="bolt" href="/plugins/sdk-channel-inbound">
|
||||
Shared inbound event lifecycle: ingest, resolve, record, dispatch, finalize
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Provider Plugins](/plugins/sdk-provider-plugins) - if your plugin also provides models
|
||||
- [SDK Overview](/plugins/sdk-overview) - full subpath import reference
|
||||
- [SDK Testing](/plugins/sdk-testing) - test utilities and contract tests
|
||||
- [Plugin Manifest](/plugins/manifest) - full manifest schema
|
||||
|
||||
## Related
|
||||
|
||||
- [Plugin SDK setup](/plugins/sdk-setup)
|
||||
- [Building plugins](/plugins/building-plugins)
|
||||
- [Agent harness plugins](/plugins/sdk-agent-harness)
|
||||
Reference in New Issue
Block a user