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 through channelContext, visible to plugin hooks as ctx.channelContext. Add channel-specific fields by extending PluginHookChannelSenderContext or PluginHookChannelChatContext from 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 registered runtime.channel.inbound.buildContext, forwarding the exact resolver output as channelIngress. After final route selection, apply contextBinding to 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 gets channelIngress: "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 durable branch executes the hook inside shared durable delivery;
  • a direct deliver branch executes the hook in core before the native adapter;
  • an exceptional provider funnel may use deliverWithProviderMessageSending when 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:

FieldContract
contentThe 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 / receiptThe 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.
visibleReplySentAssign 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.
suppressionA 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.
finalizationA 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.

1,321 words · updated Aug 17, 2026