Channel Inbound API for Plugin SDK: Event Helpers
Learn how to build inbound event contexts, orchestrate shared runners, and dispatch prepared replies for channel plugins. This guide is for developers integrating channel inbound events using the OpenClaw plugin SDK.
Read this when
- You are building or refactoring a messaging channel plugin receive path
- You need shared inbound context construction, session recording, or prepared reply dispatch
- You are migrating old channel turn helpers to inbound/message APIs
Channel inbound events are processed through a single receive flow:
platform event -> inbound facts/context -> agent reply -> message delivery
For normalizing inbound events, along with formatting, roots, and orchestration, turn to openclaw/plugin-sdk/channel-inbound. Native send, receipt, durable delivery, and live preview behavior belong to openclaw/plugin-sdk/channel-outbound.
Core helpers
import {
buildChannelInboundEventContext,
runChannelInboundEvent,
dispatchChannelInboundReply,
} from "openclaw/plugin-sdk/channel-inbound";
buildChannelInboundEventContext(...): takes normalized channel facts and places them into the prompt or session context. Channel-owned sender and chat metadata flows throughchannelContext, visible to plugin hooks asctx.channelContext. Add channel-specific fields by extendingPluginHookChannelSenderContextorPluginHookChannelChatContextfrom this subpath. This public standalone builder carries no authority and cannot generate participant evidence. Production receive paths that ship bundled rely on the host-injected registeredruntime.channel.inbound.buildContext, forwarding the exact resolver output aschannelIngress. After final route selection, applycontextBindingto that result. Core accepts it only once, provided the same active plugin record, lifecycle epoch, agent, session, message, event, and admission scope still match; rebuilding participant provenance from context fields is off-limits for receive paths. Only a named, source-proven unsupported path getschannelIngress: "unsupported".runChannelInboundEvent(...): handles ingest, classify, preflight, resolve, record, dispatch, and finalize for a single inbound platform event.dispatchChannelInboundReply(...): takes an already assembled inbound reply and records and dispatches it through a delivery adapter.
For inbound events that carry only media, leave the message body and command text empty, supplying one ChannelInboundMediaInput fact per native attachment. When an ambient history line or another text-only carrier must describe those facts, rely on formatMediaPlaceholderText(media). It classifies each fact from kind, MIME type, then path or URL extension; native attachments that were not downloaded should still each contribute a type-only fact. The formatter must not be used to fabricate the primary inbound body.
Normalize plugin-owned attachment records with toInboundMediaFacts(...), then feed the resulting ordered array into the context's media field:
const media = toInboundMediaFacts([
{ path: saved.path, url: nativeUrl, contentType: saved.contentType, messageId },
]);
const ctx = finalizeInboundContext({ Body: caption, media });
Attachment identity is defined by array position. Per-fact transcribed, messageId, and workspaceDir take over from the legacy parallel index and workspace fields. The MediaPath, MediaPaths, MediaUrl, MediaUrls, MediaType, MediaTypes, MediaTranscribedIndexes, MediaWorkspaceDir, and MediaStaged context fields, plus buildChannelInboundMediaPayload(...), stay available purely as deprecated compatibility. New plugins should neither build nor read them.
Bundled or native channels that already get the injected plugin runtime object can reach the same helpers via runtime.channel.inbound.* instead of importing this subpath directly:
await runtime.channel.inbound.run({
channel: "demo",
accountId,
raw: platformEvent,
adapter: {
ingest: normalizePlatformEvent,
resolveTurn: resolveInboundReply,
},
});
For compatibility dispatchers that keep platform delivery inside the delivery adapter, assemble dispatchChannelInboundReply(...) inputs. New send paths should prefer message adapters and durable message helpers from channel-outbound.
Delivery settlement contract
Each logical reply payload gets its native send from ChannelInboundTurnPlan.delivery. On the routed API, core runs reply_payload_sending, invokes preparePayload, then designates exactly one message_sending owner:
- a declared
durablebranch executes the hook inside shared durable delivery; - a direct
deliverbranch executes the hook in core before the native adapter; - an exceptional provider funnel may use
deliverWithProviderMessageSendingwhen it must pick durable delivery or native finalization within that funnel.
Inside a normal deliver callback, do not apply message_sending a second time. The provider-owned callback is only for branches that cannot be declared before the provider funnel begins; it excludes deliver and durable. Existing direct and durable plans continue with ChannelInboundTurnPlan; the exceptional funnel must be explicitly typed as ChannelInboundTurnPlan<"provider_message_sending">. Caller-assembled dispatchChannelInboundReply(...) stays the compatibility boundary, preserving caller-provided dispatcher ownership.
When channel policy deliberately suppresses the logical payload, preparePayload may return null. Core logs a typed non-visible result, skips durable selection, message_sending, and native delivery, so a later modifying hook cannot bring back content the channel rejected.
Core also owns terminal message_sent observation when the adapter opts in. These responsibilities must stay separate so a single payload never produces duplicate modifier or terminal events.
The delivery result fields carry these meanings:
| Field | Contract |
|---|---|
content | The human-readable content the provider accepts for the logical payload, after any native formatting or finalization has run. Leave it out and the prepared payload text is used for terminal observation. Media-only sends may also omit it. |
messageIds / receipt | The real provider identities attached to the visible send. A MessageReceipt is the preferred choice; core falls back to its primary provider id for message_sent. |
visibleReplySent | Assign false only when the provider ended up with no visible preview or final message. Core will not emit a successful message_sent for that outcome. |
suppression | A typed, deliberate no-send reason once a modifying hook or payload policy has settled. Hook cancellation may additionally carry cancelReason and metadata. For a core-owned suppression, the direct native adapter is never invoked. |
finalization | A promise that defers native settlement of the same logical payload, for example closing or editing an in-place streaming card. Its resolved fields take precedence over the immediate result before terminal observation and onDelivered. |
When core should emit the canonical plugin and internal message_sent events for this
adapter's non-durable sends, set the delivery adapter's observeMessageSent option to true.
Do not return this option from deliver, and do not emit those events in the plugin as well.
Durable sends already flow through the shared outbound owner, so no duplication occurs.
Each logical payload gets exactly one result back. finalization is not a second send and
must not rerun reply_payload_sending or message_sending. Once
deliver returns, core watches the finalization promise's rejection so it
cannot go unhandled; the original promise is still awaited after reply
dispatch finishes. After that, at most one terminal observation per payload
is emitted, carrying the finalized content and provider id. onDelivered, if present,
receives the settled result following that observation.
Suppressed results also reach onDelivered. A suppressed result
carries visibleReplySent: false, produces no message_sent, and is not counted
as a visible queued reply. Plugins can therefore tell hook cancellation apart
from provider failure without fabricating a native message identity.
By default, routed turns log inbound metadata against
ctxPayload.SessionKey ?? route.sessionKey. Set record.sessionKey only when a
native command deliberately runs in one command session while updating a
different provider-routed target session. The override touches inbound metadata,
transcript-context merge, and record-stage diagnostics; dispatch
routing and hook correlation stay unchanged. An explicit override must be non-empty and free of
surrounding whitespace.
When native delivery fails, reject deliver or finalization. If no provider
send was attempted, throw PlatformMessageNotDispatchedError from
openclaw/plugin-sdk/error-runtime; core suppresses a false message_sent
event. If a native send became visible before a later operation failed,
carry the visible subset on the error:
import { createChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound";
throw createChannelPartialDeliveryError(cause, {
visibleReplySent: true,
content: finalizedVisibleText,
receipt,
});
Core emits a failed terminal observation with that provider-visible content and
identity, then leaves the delivery failed so callers do not mistake partial
success for a clean send. Do not report visibleReplySent: false after any
preview, draft, attachment, or final message became visible.
When reply_payload_sending or message_sending is registered, those hooks
must settle before anything provider-visible is created, because either hook
can rewrite or cancel the logical payload. An eager native preview would leak
pre-rewrite content or leave a cancelled draft behind. Buffer preview content
until the accepted payload reaches deliver; compatibility dispatchers that
start previews earlier must suppress that eager preview while either hook is
registered. For new preview paths, use the finalizable live-preview helpers from
Channel outbound API.
Migration
Runtime aliases for runtime.channel.turn.* were removed. Use:
runtime.channel.inbound.run(...)for raw inbound events.runtime.channel.inbound.dispatchReply(...)for assembled reply contexts.runtime.channel.inbound.buildContext(...)for inbound context payloads.runtime.channel.inbound.runPreparedReply(...), deprecated, only for channel-owned prepared dispatch paths that already assemble their own dispatch closure.
New plugin code should not introduce turn-named channel APIs. Keep model or
agent turn vocabulary inside agent/provider code; channel plugins use inbound,
message, delivery, and reply terms.