Channel Inbound API: Event Helpers for Plugin SDK

This page explains inbound event helpers for channel plugins, including context building, shared runner orchestration, session recording, and prepared reply dispatch. It is intended for developers building channel plugins using the Neura Market 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 follow a single receive flow:

platform event -> inbound facts/context -> agent reply -> message delivery

Use openclaw/plugin-sdk/channel-inbound to normalize inbound events, format them, set roots, and orchestrate processing. Use openclaw/plugin-sdk/channel-outbound for native sending, receipt handling, durable delivery, and live preview behavior.

Core helpers

import {
  buildChannelInboundEventContext,
  runChannelInboundEvent,
  dispatchChannelInboundReply,
} from "openclaw/plugin-sdk/channel-inbound";
  • buildChannelInboundEventContext(...): projects normalized channel facts into the prompt and session context. Pass channel-owned sender and chat metadata through channelContext, which plugin hooks see as ctx.channelContext. Augment PluginHookChannelSenderContext or PluginHookChannelChatContext from this subpath for channel-specific fields.
  • runChannelInboundEvent(...): runs ingest, classify, preflight, resolve, record, dispatch, and finalize for a single inbound platform event.
  • dispatchChannelInboundReply(...): records and dispatches an already assembled inbound reply using a delivery adapter.

For media-only inbound events, leave the message body and command text empty and pass one ChannelInboundMediaInput fact per native attachment. When an ambient history line or another text-only carrier must describe those facts, use formatMediaPlaceholderText(media). It classifies each fact from kind, MIME type, then path or URL extension. Undownloaded native attachments should still contribute one type-only fact each. Do not use the formatter to synthesize the primary inbound body.

Normalize plugin-owned attachment records with toInboundMediaFacts(...), then pass the resulting ordered array through the context's media field:

const media = toInboundMediaFacts([
  { path: saved.path, url: nativeUrl, contentType: saved.contentType, messageId },
]);

const ctx = finalizeInboundContext({ Body: caption, media });

Array position defines attachment identity. Per-fact transcribed, messageId, and workspaceDir replace the legacy parallel index and workspace fields. The MediaPath, MediaPaths, MediaUrl, MediaUrls, MediaType, MediaTypes, MediaTranscribedIndexes, MediaWorkspaceDir, and MediaStaged context fields, plus buildChannelInboundMediaPayload(...), remain available only as deprecated compatibility. New plugins should not construct or read them.

Bundled or native channels that already receive the injected plugin runtime object can call the same helpers under runtime.channel.inbound.* instead of importing this subpath directly:

await runtime.channel.inbound.run({
  channel: "demo",
  accountId,
  raw: platformEvent,
  adapter: {
    ingest: normalizePlatformEvent,
    resolveTurn: resolveInboundReply,
  },
});

Assemble dispatchChannelInboundReply(...) inputs for compatibility dispatchers that keep platform delivery in the delivery adapter. New send paths should use message adapters and durable message helpers from channel-outbound instead.

Delivery settlement contract

ChannelInboundTurnPlan.delivery owns the native send for each logical reply payload. Core owns outbound hook ordering and, when the adapter opts in, terminal message_sent observation. Keep those responsibilities separate so one payload cannot produce duplicate terminal events.

The delivery result fields have these meanings:

FieldContract
contentProvider-accepted visible text for the logical payload after native formatting or finalization. Omit it to use the prepared payload text for terminal observation. Media-only sends can omit it.
messageIds / receiptActual provider identities for the visible send. Prefer a MessageReceipt; core uses its primary provider id for message_sent.
visibleReplySentSet to false only when the provider produced no visible preview or final message. Core does not emit a successful message_sent for that result.
finalizationA promise for delayed native settlement of the same logical payload, such as closing or editing an in-place streaming card. Its resolved fields override the immediate result before terminal observation and onDelivered.

Set the delivery adapter's observeMessageSent option to true when core should emit the canonical plugin and internal message_sent events for this adapter's non-durable sends. Do not return this option from deliver, and do not emit those events in the plugin too. Durable sends already emit through the shared outbound owner and are not duplicated.

Return one result per logical payload. finalization is not a second send and must not rerun reply_payload_sending or message_sending. As soon as deliver returns, core observes the finalization promise's rejection so it cannot become unhandled. Core still awaits the original promise after reply dispatch settles. It then emits at most one terminal observation per payload with the finalized content and provider id. onDelivered, when present, receives the settled result after that observation.

Reject deliver or finalization when native delivery fails. 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, preserve 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 keeps 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. Use the finalizable live-preview helpers from Channel outbound API for new preview paths.

Migration

runtime.channel.turn.* runtime aliases 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 or provider code. Channel plugins use inbound, message, delivery, and reply terms.