On this page
On this page
Building plugins
Building channel plugins
This guide builds a channel plugin that connects OpenClaw to a messaging platform: DM security, pairing, reply threading, and outbound messaging.
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
- Formatting contract - optional
agentPrompt.inboundFormattingHints, resolved per delivering account. Despite its name, core gives it to every OpenClaw agent turn whose visible text reaches the channel: replies, heartbeats, cron announces, subagent announces, and cron runs without a reply route that can send with themessagetool (for exampledelivery.mode: "none"). Such a run uses the message tool's default channel: its current channel, or the only configured channel. Amessagetool send to another channel does not get that channel's rules, and external ACP agents do not receive it. Keep all formatting rules in this one hook. - Message guidance -
agentPrompt.messageToolHintssupplies account-scoped instructions. SetagentPrompt.customInlineButtonHint: truewhen the channel owns inline-button guidance; core then omits its generic callback example and capability-configuration hint. Return no button instructions when the account has buttons disabled. The flag defaults tofalse. - Question input - declare
agentPrompt.questionInputMode: "buttons"only when your channel supports nativeask_usercontrols. Core resolves this from the inbound channel when preparing the conversation's tool capabilities; omission keepsask_userout of the tool catalog so the agent asks in plain text.
Core owns the shared message tool, prompt wiring, the outer session-key shape,
generic :thread: bookkeeping, and dispatch. For configured agent group
threads, core also owns participant selection, follow-up rounds, and turn
budgets. Keep those policies out of channel adapters.
Core also owns model-picker product actions. A channel that renders a
ModelPickerAction declares its ModelPickerCapabilityProfile, then encodes
the typed action in a transport-private authenticated callback envelope. Keep
approval, command, URL, web-app, question, callback, and model-picker actions
distinguishable until that encoding boundary; never infer picker intent from a
raw callback string. Actor and source-message checks remain channel-owned.
Opted-in public child sessions
The ingress resolver accepts an optional host-invocation intent,
childSessionPublication: { audience: "public", assertCurrent }. Supply it only
after explicit operator opt-in and provider-native proof that the entire
supplied context is public. Unknown audience metadata must deny publication.
assertCurrent synchronously rechecks account policy and the live ingress
owner; never derive this field from message text, stored permalinks, or model
arguments. The ordinary decision-only resolver does not grant authority.
For mutable source policy, the intent may also supply
prepare: async () => assertCurrent. The host invokes it once at run admission
and retains the returned synchronous guard for child publication. The channel
must reacquire current authorization and routing with bounded retries and return
null if the prepared prompt's route, access tier, or public-context proof is no
longer valid. This drops the turn without trying another model or publishing a
generic model-failure reply. Throw operational failures instead; these stop model
fallback but produce a retryable access-verification error. Preparation cannot
renew an already admitted run; use-time checks and revocation remain mandatory.
An exact host resolver-to-context handoff binds the intent to one admitted
run. Only its fresh immediate isolated visible children can receive public
grants, in their creation commits before launch. Private/draft, incognito, existing,
forked, retargeted, and descendant sessions do not qualify. The intent cannot
be serialized for future turns or transport fallback. This does not grant
creator/admin authority or change Team collaboration permissions. Consumers
of onVisibleWorkSessions may use publicRead: true as the creation receipt;
a normalized URL alone is not proof of anonymous access.
The host awaits onVisibleWorkSessions(sessions, creation) for fresh visible
children before their first turn starts, so a channel can persist session-scoped
origin context before prompt assembly. The optional second argument supplies
creation.createdSessionKey even when there is no public Gateway origin or the
Control UI is disabled; in that case sessions is empty. Existing one-argument
consumers still receive only usable URL descriptors. The callback may receive the
same child again when the parent turn settles; make writes idempotent by child
session key. Only immediate children of that turn are reported. Return a Promise
when initialization must finish before the child runs.
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:
configSchema validates plugins.entries.acme-chat.config. Use it for
plugin-owned settings that are not the channel account config.
channelConfigs.acme-chat.schema validates channels.acme-chat and is the
cold-path source used by config schema, setup, and UI surfaces before the
plugin runtime loads. See Plugin manifest for the full
top-level field reference.
Build the channel plugin object
The ChannelPlugin interface has many optional adapter surfaces. Start with
the minimum - id, config, and setup - and add adapters as you need
them. createChatChannelPlugin defaults omitted capabilities to direct
messages; declare capabilities.chatTypes when the channel supports more.
Set capabilities.reactions when the channel supports reactions. Channels
limited to one bot reaction per message set capabilities.reactionSlots to
"single"; "multiple" or omission means independent emoji. When a Control UI
reaction is removed from a single-slot channel, the mirror restores the newest
remaining emoji or clears the slot when none remain.
config.inspectAccount is synchronous and returns metadata
for read-only diagnostics, including disabled or configured-but-unavailable
accounts. Return enabled, configured, and applicable credential status
fields without requiring secret resolution. Its result is not a resolved
account: operational hooks such as checks and account status builders receive
config.resolveAccountAsync or config.resolveAccount results instead.
Diagnostics expose only status-safe fields from the inspection result.
Include the same account enablement and configuration decisions used by the
runtime, including duplicate-account suppression. If configured is omitted,
diagnostics use a recorded Gateway value when available; otherwise they report
that configuration status is unavailable.
Ordinary channels.status reads combine this metadata with the lifecycle's
recorded account status. Publish changing health and provider details through
setStatus; account snapshot and channel summary hooks run only for explicit
live checks on this RPC. All live hooks share a per-channel deadline. They
should also honor their supplied timeout because timing out the RPC cannot
cancel an already-running plugin operation.
Selection before secret redemption also reads this metadata directly. Directory
auto-selection requires configured: true; callers can still select the channel
explicitly when configuration status is unknown.
Operational account reads can be asynchronous. Define
config.resolveAccountAsync(cfg, accountId) when account resolution reads
durable credentials, and config.hasConfiguredStateAsync({ cfg, env }) for
the matching operational configured-state check. Use
config.describeAccountAsync(account, cfg) for state-backed account summaries,
config.resolveAllowFromAsync({ cfg, accountId }) for stored allowlists, and
security.resolveDmPolicyAsync({ cfg, accountId, account }) for stored DM policy.
These optional callbacks return a Promise of the same result as their
synchronous counterparts. Core awaits the async hook whenever it is present;
neither a rejection nor an empty result retries the synchronous hook. Keep
synchronous signatures for older hosts and external consumers, and forward
async hooks through any adapter that wraps the channel.
Prepare current credentials for each operation, and revalidate live authority
after awaited preparation before any side effect. Account objects and registry
generations are not credential caches. Read-only inspectAccount and
config-only bootstrap activation remain separate; persisted credentials alone
do not enable a channel.
gateway.loginWithQrStart receives an optional
assertCredentialPersistenceCurrent() callback. Retain it with the QR login
and invoke it immediately before credential writes, including deferred writes
after the RPC returns. It throws when the Gateway's account configuration or
plugin lifetime changes. Compose it with the plugin's own active-login guard;
do not replace that guard or tie QR lifetime to the initiating RPC. Omitted
account IDs retain plugin-specific single-account profile selection; the
Gateway rejects omission when multiple accounts are configured.
Create src/channel.ts:
For channels that accept both current 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.
The scoped, top-level, and hybrid config adapter builders read allowlists and
default targets from the account returned by resolveAccount. To use a
different, lightweight account shape for these read-only accessors, provide
resolveAccessorAccount; resolveAllowFrom and resolveDefaultTo then receive
that projection. A separate accessor type requires its own resolver. The
builders do not convert a runtime account into another account shape.
For channel-specific secret activation, createChannelSecretContract from
openclaw/plugin-sdk/channel-secret-basic-runtime combines channelKey,
account/channel registry specs, and a collect callback. The callback receives
config, defaults, context, channelKey, channel, and the resolved account
surface; it runs only when the channel record exists. Channels that start an implicit
default alongside named accounts supply hasImplicitDefaultAccount, using the same
credential-readiness rule as account listing. The shared surface then includes that
default as a consumer of root secrets. Keep field activation rules in collect.
External channel plugins must also publish a lightweight secret-contract
artifact: secret-contract-api (preferred) or contract-api, with a .js,
.mjs, .cjs, .ts, .mts, or .cts extension, in the plugin root or
dist/. Built OpenClaw prefers dist/; source runs prefer the root.
Include the artifact and its dependencies in the published package and declare
the channel in the plugin manifest's channels or channelConfigs.
The loader reads named exports: secretTargetRegistryEntries supplies targets
for openclaw secrets audit and secret planning; collectRuntimeConfigAssignments
collects channel SecretRefs during runtime preparation. Export both for full
support; a default export alone is ignored. Defining ChannelPlugin.secrets
alone does not publish an external contract for these paths.
Config-backed logout handlers can use clearAccountFieldsFromConfigSection
from openclaw/plugin-sdk/channel-config-helpers. Pass cfg, sectionKey,
accountId, and the plugin-owned fields to remove. It returns
{ nextConfig, changed, cleared } without writing config or resolving
credentials. Root fields clear together only for the exact default account
when at least one value is truthy. Nested fields use clearAccountEntryFields
semantics: an empty account ID selects accounts.default, and empty or
whitespace strings are removed without reporting cleared unless
markClearedOnFieldPresence: true is set. Unchanged config retains its object
identity; cleanup prunes only branches it changes. Keep file-reference
selection, persistence, environment reporting, and other logout side effects
in the plugin.
If a channel intentionally applies stricter DM session routing than the
global config, expose that behavior through security.dmRouting so Doctor
and security audit resolve the same session owner as runtime. The optional
resolveDmScope callback runs before core route resolution; its context
includes cfg, accountId, the resolved account, and a principalId
for finite allowlist entries. resolveDmRoute receives those fields plus
the resolved core route; it may return { sessionKey } for a shared final
bucket, { kind: "isolated" } for an unknown peer, or { kind: "core" }
to preserve core dmScope namespace analysis. For wildcard/open policy,
principalId is absent and an undefined result is reported as unverified.
Diagnostics never invent a peer ID. Keep both callbacks pure and
import-safe because read-only diagnostics run without channel runtime.
Channel-specific security diagnostics can use security.collectWarnings.
Legacy string results are warning severity. Return the structured
SecurityAuditFinding shape (checkId, severity, title, detail, and
optional remediation) when the producer must declare informational or
critical severity; the same finding is used by Doctor and the main security
audit. Use collectAuditFindings only for diagnostics that should appear in
the full security audit but not Doctor.
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.
The optional sessionKey identifies the host-resolved session that produced
the send, including core-routed and durable sends. It is the source session,
not a session inferred from the delivery target or its policy. It does not
identify whether the current turn came from a user or an internal event.
For payload planning, openclaw/plugin-sdk/channel-outbound exports
createOutboundPayloadPlan(payloads, context) for raw reply text, including
legacy reply/audio tags, MEDIA: directives, and optional Markdown-image
extraction. Use createStructuredOutboundPayloadPlan(payloads) only after
the producer has resolved those controls into explicit payload fields.
The structured planner does not reinterpret remaining text as delivery
directives or silence tokens. Downstream automatic-reply silence policy
still applies, and channels retain their opted-in presentation transforms,
including Markdown-image extraction. Both operations use
projectOutboundPayloadPlanForDelivery(plan) for their delivery projection.
A final delivery can carry a supplemental notice before the answer.
Use isReplyPayloadTerminalContent(payload) from
openclaw/plugin-sdk/reply-payload when deciding whether to complete a task.
It excludes reasoning, commentary, and supplemental status or TTS payloads,
while retaining terminal errors and host-marked command results.
It classifies the reply lane; it does not check content, sendability, or authority.
isHostNoticePayload(payload) from the same subpath identifies host errors,
status notices, and metadata-only host diagnostics. Agent-to-agent channels
can exclude these in messaging.transformReplyPayload while still preserving
run failures through replyOptions.onAgentRunTerminalOutcome and their native
task status. This callback stays bound to the originating request when its
turn runs as a queued followup; collected turns notify every source's terminal
observer. Human-facing channels should continue to
deliver useful host notices.
replyOptions.onFollowupQueueDisposition reports terminal queue-cap rejection
(queue-cap or queue-cap-new) and eviction (queue-cap-old) for the original
source. These messages will not run, so channels should finalize their status
without waiting for onAgentRunTerminalOutcome. Overflow retained for a summary
remains pending; sources evicted from the summary cap report queue-cap-old.
A queuedDeliveryCorrelations entry stays bound to its source through collect
batching. Its optional shouldSuppressReply(payload) applies before an
origin-routed send, which bypasses the immediate reply dispatcher. All source
policies in a collected batch must agree to suppress it; an explicit request
can therefore retain feedback alongside passive messages. onReplyDelivered
runs only after confirmed origin delivery, so deduplication must not record
a failed or transport-pending send as delivered.
Its begin() cleanup receives an optional settlement with visibleReplySent,
failed, and completed after delivery and presentation cleanup finish.
completed identifies successful execution, including deliberate silence;
cancellation does not count as recovery from an earlier failure.
transportPending: true means delivery remains owned by transport without a
later callback to this source; clear activity UI without claiming success or failure
unless visibleReplySent or failed already proves the outcome.
An omitted settlement means the attempt deferred, queued a source retry,
or uses a separate source-owned delivery outcome (such as a Gateway reply batch).
Use the settlement for delivery-dependent completion UI; an agent's successful
execution callback can arrive before any reply is sent.
When cloning a host-supplied reply, use copyReplyPayloadMetadata(source, clone)
from openclaw/plugin-sdk/reply-payload to preserve its non-serialized runtime
metadata. Persisted transcript delivery facts cannot replace that metadata.
hasReplyPayloadFinalDeliveryCapture(payload) from the same subpath identifies
replies whose confirmed delivery the shared dispatcher will record. Channel
adapters must skip their own transcript mirror for those replies, including
fallback sends, while still reporting the delivery outcome normally.
The shared channel turn lifecycle owns pending-final delivery admission. It records direct-send custody immediately before platform I/O, or transfers custody to the durable delivery queue. Its reply dispatcher skips the intermediate queued claim; terminal settlement and replay suppression remain owned by the delivery lifecycle.
When recovering a payload from earlier source text, apply
preserveReplyPayloadMediaSelection(current, recovered) from
openclaw/plugin-sdk/channel-outbound.
This retains media and attachment choices changed by delivery modifiers, while
allowing text and reply intent to recover independently. Unchanged empty media
does not prevent transcript recovery. With unchanged media, the operation prefers
current prepared references over their recorded source aliases and retains distinct
recovered media. It preserves the candidate’s other runtime metadata.
After recovering or projecting fields on a normalized reply, finish with
createStructuredOutboundPayloadPlan from openclaw/plugin-sdk/channel-outbound.
This preserves literal text and the host's recorded single-use target policy.
Before filtering media, use collectReplyMediaEntries(payload, projectedMediaUrls?)
from openclaw/plugin-sdk/channel-outbound to retain each URL's attachment metadata. Filter those
entries together so positional names and referenced records stay with their media.
Entries can also carry sourceUrls for references staged by the host. When recording
delivered media, request entries for only the URLs confirmed accepted by the transport;
source aliases for removed or unsent media are not delivery evidence.
A block with ReplyPayload.textMode: "delta" carries a disjoint text chunk.
Preview adapters append it within the current answer lane; omitted textMode
means a replacement snapshot. Never infer this from matching prefixes.
Finals always replace, and durable sends/transcripts use the payload's own
text, not an accumulated preview. A text-rewriting delivery modifier clears
delta mode so its replacement remains authoritative. When appending a chunk
to an existing preview, use stripReplyPayloadResponsePrefix(payload, text)
to remove only the response prefix recorded by normalization; retain the
decorated text for the first chunk and for independent durable sends.
Streaming delivery can carry one OutboundPayloadPlan through the optional
onPreparedBlockReply(plan, context), dispatcher sendPreparedReply(kind, plan),
and adapter deliverPrepared(plan, info) operations. Modifiers rebuild that
plan from the changed payload fields without reinterpreting literal text.
Channel turn adapters can forward the same plan through
deliverPreparedWithProviderMessageSending, and durable inbound delivery uses
deliverStructuredInboundReplyWithMessageSendContext({ ...context, plan }).
A prepared or filtered agent registry from the current Gateway publication
is not a reload successor: ordinary final replies use that Gateway’s live
channel registry and do not require a handoff callback.
Both durable inbound helpers accept an optional synchronous
prepareRuntimeHandoff(cfg) callback for final replies after an unrelated
plugin reload. The channel must reject a changed admitted sender and return
a config that pins the verified credential for all parts of that delivery.
Core requires the exact retained channel registration and unchanged channel,
shared-default, and owning-plugin settings for every successor handoff.
Channels without this callback deliver with the successor's unchanged
config; add the callback when the sender credential can change outside
config (environment, token files, SecretRef values). The callback must not
persist credentials or change unrelated settings.
Existing raw callbacks remain supported. An older adapter receives the
payload through its original callback; it must adopt the prepared operation
to avoid reparsing literal text in its own normalization code.
Conversation tool-policy adapters
Resolve configured permissions at execution admission. Direct conversations
use security.resolveDirectToolPolicy(context); shared conversations use
groups.resolveToolPolicy(context). The host passes current cfg and the
original authenticated account, sender, and conversation facts. Direct
callbacks receive an explicit conversationId; use that value rather than
inferring a chat from the destination execution session key.
Resolve through your plugin's existing account/config helpers.
Preserve requester facts across delegation, not previously calculated policy. A new delegated execution resolves destination policy for the original requester; an already admitted execution keeps its prepared policy. Human input still waiting for admission uses current configuration when admitted. Shared history is unchanged, and delegation may reach a more capable agent under its destination authorization.
access.toolPolicy and MsgContext.ConversationToolPolicy remain deprecated
original-request snapshots for external plugins. Migrate configuration-derived
producers to the adapters above; see the
compatibility lifetime.
A channel that implements groups.resolveToolPolicy and supports
toolsBySender must forward the complete ChannelGroupContext to its
shared policy resolver. In particular, honor senderPolicyMode: "never"
by skipping sender-specific overlays at both the matched-group and wildcard
scopes while still applying the base tools policy.
OpenClaw sets 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 derive the mode from
inbound metadata, persist it as channel state, or expose it as config. Add
an adapter test that proves the mode skips a wildcard toolsBySender entry
without dropping the matching base tools restriction.
Native plugin command ownership
Channel plugins that publish provider-native command catalogs should use
openclaw/plugin-sdk/plugin-command-runtime. Create one runtime while
planning the catalog, merge its candidates with built-in and skill entries,
and retain the winning candidate object in the registered handler closure.
A plugin registry replacement drains and restarts loaded channel accounts
so their handlers, command catalogs, and routes use the new generation.
Manually stopped accounts stay stopped. Ordinary channel config changes
still restart only the affected channel or accounts.
retainNativeCatalog(provider) has been deprecated since 2026.9.2 and
will be removed in the next breaking SDK release; it is retained for
callers written against 2026.9.1, and existing calls only assert that the
captured registry generation is still active.
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 keeps the advertised command and
its executable plugin registration 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:
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 for all
options.
Add a setup entry
Create setup-entry.ts for lightweight loading during onboarding:
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 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:
Direct-message adapters can use dispatchInboundDirectDm from
openclaw/plugin-sdk/channel-inbound. Its optional synchronous
assertAuthority: () => void callback carries the channel owner's live
sender and lifecycle checks through asynchronous preparation. Capture the
selected account, peer identity, and policy before yielding; the callback
must check their current authority, not just compare a saved token. On
current hosts, it runs before session recording,
at metadata transaction and commit admission, and before agent dispatch.
Throw to refuse the pending effect. The callback is transient authority;
never serialize it or replace it with inboundAccessAuthorized: true.
The same callback is available on channel turn plans and prepared turns.
Direct session-recording callers can pass assertAuthority; the metadata
owner receives it as assertCommitAllowed and uses its existing writer
admission. Metadata failures keep their existing best-effort reporting,
while the subsequent dispatch check still refuses revoked authority.
Older hosts may ignore these additive fields: their presence is not
capability negotiation. The checks after host preparation and at metadata
commit require a host that implements this contract. Plugins must also
check live authority at effects they initiate directly, such as notices
and provider requests.
Routes registered with auth: "gateway" use the Gateway's credential
checks. Before a handler discloses protected data, performs a mutation, or starts other side effects,
finish reading and validating its body and waiting for queued work, then call
await getPluginRuntimeGatewayRequestScope()?.revalidate?.() from
openclaw/plugin-sdk/plugin-runtime. The request-scoped capability rechecks
an admitted device credential or signed Control UI cookie and its original
scopes through the Gateway auth owner. Cookie checks include expiry, the
current authentication generation, and the current profile role ceiling.
An effective role-policy change invalidates an in-flight cookie request, so
previously prepared data is not disclosed under outdated permissions.
It writes the standard HTTP 401 error and throws if the grant expired,
was revoked, rotated, or narrowed. Let the rejection stop the handler; an
error handler must not replace an already-ended response. The capability
expires with the HTTP response and is absent for other authentication paths.
This check authorizes the work about to start. It does not cancel an external operation already in progress. Revalidate again before later independent mutations, such as saving a published or imported profile after relay I/O.
Test
Write colocated tests in src/channel.test.ts:
For shared test helpers, see Testing.
File structure
Delegated context reads
Bundled actions can prove equivalence between provider-native delivery aliases and
the current conversation with
actions.messageActionTargetAliases[action].matchesCurrentConversationAsync.
The callback receives { args, accountId, toolContext } and returns
Promise<boolean>. The host awaits it only after checking the current provider,
account, and any additional requested targets. External registrations cannot use
this callback to bypass exact-current matching. A successful async match does not
replace live caller or registration authority; the host rechecks those before
dispatch.
Async alias proof requires the selected bundled registration to be loaded. Liveness checks use its captured owner authority and the loaded registry; they never discover or load a bundled fallback after the registration is retired. Normal bundled runtime registration satisfies this requirement. The retained synchronous path keeps its existing compatibility behavior.
The async callback takes precedence over matchesCurrentConversation when both
are present. A false result or rejected promise never falls back to the legacy
callback. The synchronous callback is deprecated for storage-backed matching but
remains supported for older plugins and hosts, with no removal version scheduled.
Keep its return type strictly boolean: older hosts treat a returned promise as
truthy rather than awaiting it. Hosts predating the async companion ignore the new
field and use only the synchronous callback. An async-only alias therefore cannot
prove equivalence on those hosts; exact normalized target matching still works.
Verified official installed plugins can delegate supported conversation, metadata, and attachment reads to provider-owned access checks. Channel-origin requests need server-owned current provider, account, and conversation context. An authenticated dashboard user turn can also use those provider-owned checks without native channel context, including Incognito sessions and fresh messages after reconnect. Ordinary transport loss does not cancel an already admitted turn. This permission belongs only to that turn; background work and scheduled jobs keep their separate authorization. Normal chat, session participation, and tool permissions, along with provider account, destination, action, and requester policies, remain in force.
Account-created scheduled reads use the live job's recorded creator account and origin.
An external creator origin restricts reads to that provider; a missing or unknown origin
cannot authorize a read. Omitting accountId selects the recorded creator account,
including after the provider's default account changes. Provider destination and action
policies remain in force. See Scheduled tool policy
for reauthorization and execution rules.
An adapter lists actions that support the lifetime fence in actions.readAuthorityActions.
Its actions.providerOwnedReadGates declaration separately identifies the actions
whose admission the provider owns. The host also classifies the action as eligible;
a later host addition does not opt existing adapters into it.
Only host-verified official registrations qualify. Discord supports read, search,
reactions, list-pins, thread-list, channel-info, permissions, member-info,
role-info, emoji-list, channel-list, voice-status, and event-list.
Feishu supports read, reactions, list-pins, member-info, channel-info,
channel-list, and configured sticker-search.
Matrix supports read, reactions, list-pins, emoji-list, member-info, and
channel-info.
Mattermost supports read.
Slack supports read, reactions, list-pins, member-info, emoji-list, and
download-file.
Older external adapters and unverified plugins retain the exact-current-conversation
restriction. These declarations apply only to the listed read actions.
Delegated Slack member info is limited to the current requester on the same account, and emoji discovery uses the trusted workspace. Neither metadata action requires a channel target.
Microsoft Teams supports read, search, reactions, list-pins, member-info,
channel-info, and channel-list under the Teams access rules.
Discord's permissions action inspects the bot's permissions for an allowed channel.
Guild-wide metadata reads require the requested guild to be allowed by the selected
account's current configuration, with unrestricted or wildcard channel access.
The narrow exception is an active thread-list naming a parent channelId: the
parent must be allowed and its metadata must confirm the requested guild before
the guild-wide fetch. The result includes only allowed threads under that parent
and member records for those returned threads. An active list without a parent
still requires guild-wide channel access; archived lists keep their channel-scoped path.
Only direct operators receive the filtered-results relaxation for channel-list;
delegated channel-list callers still require guild-wide channel access.
The transport contract is mandatory for opt-in adapters:
- Capture
captureChannelReadAuthority()fromopenclaw/plugin-sdk/fetch-runtimewhen submitting each request, before handing it to a shared queue. - Retain that exact callback through waits and retries; invoke it immediately before every provider request, including target lookup requests, after any asynchronous DNS or dispatcher preparation.
- An absent callback means this invocation has no additional read-authority fence. A thrown error stops the request; do not retry with a new callback.
Bundled transports also capture captureEffectAuthority() from
openclaw/plugin-sdk/fetch-runtime before handing requests to shared queues.
Use its run method to restore that captured scope in a queued continuation.
After asynchronous preparation, call effect.initiate(() => provider.send(...))
and run the existing synchronous assertion inside that callback. The owner
releases the interval when the provider call is issued, before its response.
Every retry or separately submitted chunk calls initiate again to prepare a
fresh use. A fully prepared synchronous batch, such as Twitch's SDK calls or
IRC's raw lines, acquires one use before its first handoff and submits all parts
inside that callback. Never acquire multiple held uses upfront: the owner's
FIFO waits for the preceding use to release. Library work after the SDK handoff
belongs to the already initiated operation.
Preserve preparation refusals unchanged when nothing was sent. Once a part is
accepted, later refusals, transport failures, and delivery-observer failures
must preserve all accepted receipts in a partial-delivery error, including when
the caller omits its progress callback. Wait for an initiated batch to settle
before reporting its outcome. Apply provider-specific failure normalization only
after entering the SDK or transport call.
The initiation boundary is the call into the provider SDK, CLI runner, or native transport. OpenClaw finishes its asynchronous preparation before that call and does not hold the interval while awaiting a provider response. Provider libraries can still queue, authenticate, encrypt, connect, or retry internally after the handoff; those windows are part of the initiated operation. Application retries and separately submitted chunks each acquire a new use.
| Bundled transport | Initiation handoff | Work after handoff |
|---|---|---|
| A2A, Google Chat, Nextcloud Talk, Tlon, and other shared guarded HTTP callers | Fetch after DNS/proxy preparation | Fetch implementation and networking |
| Buzz and Nostr | Relay publish or send |
Relay connection promise, WebSocket buffering, and network I/O |
| ClickClack, Zalo, Zalo Personal | Each fetch callback after dispatch preparation | Fetch implementation and networking |
| Discord | Fetch after REST scheduler waits | Fetch implementation and networking |
| Feishu | HTTP client method after proxy and dispatch preparation | Axios transforms, interceptors, redirects, and networking |
| iMessage local CLI | CLI runner call after media/target preparation | Spawn broker, CLI execution, bridge, and Messages delivery |
| iMessage RPC | Write to the selected process's stdin | Pipe buffering, local/SSH CLI, bridge, and Messages delivery |
| IRC | Precomputed raw PRIVMSG batch; explicit outbound JOIN | Socket buffering and networking |
| LINE | Each message fetch after authorization | Undici and networking |
| Matrix | Fetch after DNS, crypto, and dispatch preparation; each redirect reacquires | Fetch implementation and networking |
| Mattermost and SMS | Shared guarded fetch, or the injected fetch callback | Fetch implementation and networking |
| Microsoft Teams | Teams Common middleware calls the SDK's next request operation | SDK token/config preparation, interceptors, Axios, and networking |
| QA channel and Synology Chat | Node HTTP request and body submission | Socket connection, buffering, and networking |
| Reef | Each relay fetch | Fetch implementation and networking |
| Signal HTTP/container | Node HTTP request/body submission or REST fetch | HTTP transport and signal-cli processing |
| Signal Unix socket | Socket write after connecting | Socket buffering and signal-cli processing |
| Slack | SDK fetch callback after queue waits | Fetch implementation and networking |
| Telegram | Each Undici dispatcher submission; injected fetch callback | Undici or injected implementation |
| Twitch | Precomputed batch of synchronous ChatClient.say calls |
Twurple rate-limit queue, IRC client, and networking |
Baileys sendMessage after FIFO, or sendPresenceUpdate; both recheck the socket |
Baileys media/session preparation, encryption, and networking |
Matrix's client-owned crypto maintenance uses its client lifecycle rather than
retaining a completed message invocation. Its message fetches still acquire their
own current use. Independent owners can restore an explicitly captured scope with
withEffectAuthority; an absent scope does not provide message authority.
The host binds the callback to the selected registration and its active lifecycle. Local message tools and Gateway agent requests retain the originating run and turn authority. Opted-in bundled reads use the same lifetime fence while keeping their existing provider-owned admission rules. A bundled artifact or an omitted scoped registration cannot supply that authority; delegated execution requires the active registered instance. The host rejects stale action results and errors after either caller or plugin authority is revoked. A completed action also closes its captured callbacks. The fence prevents subsequent requests; it cannot undo a request already sent to the provider. No configuration switch or plugin-supplied trust field can mint this authority.
Slack attachment downloads retain the originating read authority through URL refresh, binary transfer, media-store publication, image processing, and final host completion. The existing media artifact is kept only when the read succeeds. If completion is rejected, cleanup removes only files created by that operation; preexisting files, replacements, and shared files are preserved. The source abort signal also reaches the binary transfer where the caller supplies one.
Scheduled channel administration
ChannelMessageActionAdapter exposes the optional
writeAuthorityActions?: readonly ChannelMessageActionName[] declaration through
openclaw/plugin-sdk/channel-contract. It identifies write actions whose transport
preserves the host's live request authority. Advertising an action through
describeMessageTool or declaring read support does not establish that contract.
The host separately selects eligible actions and requires an active bundled or
loader-verified official registration. A bundled artifact fallback or a plugin's
own trust claim cannot supply registration authority. Discord declares
writeAuthorityActions: ["channel-edit", "delete", "edit", "pin", "unpin"].
Other action names do not gain scheduled access from this declaration.
Scheduled channel-edit, including its existing channel and thread edit variants,
accepts trusted operator job authority or the account job's authenticated native
requester. The declaration cannot promote an
account-mode job to operator authority or replace authenticated requester identity
and current sender permission checks.
For native account edits, the host supplies its validated requesterAccountId
and requesterSenderId with senderIsOwner: false. There is no current inbound
conversation to put in toolContext. The adapter uses these host-provided facts
for its normal current requester-permission checks; model arguments and the
presence of a handoff callback cannot supply a requester identity. The host keeps
the saved native requester separate from an earlier complete-tool-surface read
origin. Discovery can use both facts to present configured actions, but the native
requester does not establish read access. Jobs without usable native facts receive
reauthorization guidance before the provider is called.
Scheduled edit, delete, pin, and unpin support both trusted operator jobs and
account jobs. An account job must use its recorded creator account and a known
creator origin; external origins also bind it to the recorded provider. Its delivery
destination does not supply authority. These actions also require
the adapter's existing providerOwnedReadGates declaration and retain its target
checks. Account jobs use delegated target policy; trusted jobs use operator target
policy. The job's current execution policy and toolsAllow, account restrictions,
enabled actions, and provider permissions still apply.
The host evaluates current tool policy when each new scheduled message invocation is admitted, including global, agent, profile, and selected model-provider policy. Configuration changes govern the next invocation; they do not retroactively change the configuration of an admitted operation. Revoking or narrowing the job itself, canceling its run, or ending caller or plugin authority still blocks later provider requests and retries within that operation.
The host admits channel-name resolution before directory requests and retains the selected registration through the write. Its preparation read scope closes before the write starts, so a read completion check cannot discard an accepted mutation result.
An opted-in adapter must honor the existing
ChannelMessageActionContext.assertDirectAdapterHandoff callback:
- Retain the exact host-provided callback through asynchronous preparation, permission and target lookups, rate-limit queues, and retries.
- Invoke it synchronously after awaited preparation and immediately before every actual provider request, including lookup requests and each retry attempt.
- If it throws, stop that request. Do not suppress the rejection, replace the callback, or put the rejected operation into replayable recovery.
- Let a submitted request settle and preserve its outcome, including a confirmed mutation when authority expires while awaiting the response. Expired authority blocks later requests; it must not cause an accepted mutation to be replayed.
This optional field keeps older adapters source-compatible. An omitted or empty declaration leaves newly enabled scheduled actions denied. Existing bundled provider-owned interactive paths keep their admission rules. To support the new installed-plugin path, upgrade OpenClaw and the plugin, implement the request and retry checks above, declare only the covered actions, and load the updated registration. Existing direct-operator and interactive actions retain their admission rules. Upgrading the plugin does not grant additional authority to an existing job.
Advanced topics
threading.topLevelReplyToMode: fixed, account-scoped, or custom reply modes
describeMessageTool and action discovery
inferTargetChatType, looksLikeId, reservedLiterals, resolveTarget
TTS, STT, media, subagent via api.runtime
Shared inbound event lifecycle: ingest, resolve, record, dispatch, finalize
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
Where each section moved
Every section of the single-page version now lives on this page or on one of the seven child pages below. The anchors from the single-page version still resolve here.
Channel message adapter
Channel message adapter — The message adapter surface: live and finalizer capabilities, progress visibility, commentary delivery, and native TTS voice delivery.
- Message adapter
- Progress visibility acceptance
- Quiet progress presentation
- Quiet acknowledgement and coalesced progress
- Commentary delivery ownership
- TTS voice delivery
Durable channel ingress
Durable channel ingress — The ingress resolver, durable queue and replay dedupe, transport retention classes, at-least-once side effects, and the reload and restart contract.
- Inbound ingress (experimental)
- Durable ingress and replay dedupe
- Transport classes and retention
- At-least-once side effects
- Dynamic policy publication
- Account-scoped restart contract
Channel status and media
Channel status and media — Channel runtime status signals plus media limits, hosted media stores, inbound media facts, and native payload shaping.
Channel sessions and bindings
Channel sessions and bindings — Session conversation grammar, conversation route ownership, and account-scoped conversation binding support.
- Session conversation grammar
- Conversation route ownership
- Account-scoped conversation binding support
Channel approvals
Channel approvals — Approval capabilities, approval auth, payload lifecycle and setup guidance, native approval delivery, and the narrower approval runtime subpaths.
- Approvals and channel capabilities
- Approval auth
- Payload lifecycle and setup guidance
- Native approval delivery
- Narrower approval runtime subpaths
Channel setup and config
Channel setup and config — Setup subpaths, account schemas and inheritance, and the other narrow channel subpaths for config, inbound, targets, and threading.
Channel mention policy
Channel mention policy — Plugin-owned mention evidence gathering plus the shared inbound mention policy evaluation.