UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

382 lines (315 loc) • 21.6 kB
--- summary: "Inbound event helpers for channel plugins: context building, shared runner orchestration, session record, and prepared reply dispatch" title: "Channel inbound API" doc-schema-version: 1 read_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 receive paths follow one flow: ```text platform event -> inbound facts/context -> agent reply -> message delivery ``` Use `openclaw/plugin-sdk/channel-inbound` for inbound event normalization, formatting, roots, and orchestration. Use `openclaw/plugin-sdk/channel-outbound` for native send, receipt, durable delivery, and live preview behavior. ## Core helpers ```ts import { buildChannelInboundEventContext, runChannelInboundEvent, dispatchChannelInboundReply, } from "openclaw/plugin-sdk/channel-inbound"; ``` - `buildChannelInboundEventContext(...)`: projects normalized channel facts into the prompt/session context. Pass channel-owned sender/chat metadata through `channelContext`, which plugin hooks see as `ctx.channelContext`. Augment `PluginHookChannelSenderContext` or `PluginHookChannelChatContext` from this subpath for channel-specific fields. This public standalone builder is non-authoritative and cannot mint participant evidence. Bundled production receive paths use the host-injected registered `runtime.channel.inbound.buildContext` and pass the exact [channel ingress](/plugins/sdk-channel-ingress) resolver result as `channelIngress`. Resolve that result with `contextBinding` after final route selection. Core accepts it once only when the same active plugin record, lifecycle epoch, agent, session, message, event, and admission scope still match; receive paths must not rebuild participant provenance from context fields. Only a named, source-proven unsupported path passes `channelIngress: "unsupported"`. - `runChannelInboundEvent(...)`: runs ingest, classify, preflight, resolve, record, dispatch, and finalize for one inbound platform event. - `dispatchChannelInboundReply(...)`: records and dispatches an already assembled inbound reply with a delivery adapter. For replies, channel plugins decode the platform reference and hydrate accessible parent messages. Pass the reference as `reply.replyToId` even when the parent cannot be fetched, and pass available text and sender facts as `supplemental.quote`. Core applies the configured context visibility policy and renders the reply relationship for the model. Quoted bot text is context for the current message; it does not independently admit a bot-authored turn. Self-authored quote text is preserved by default. Self-authored quote media is skipped by default; callers can explicitly set `suppressSelfQuoteBody: true` or `suppressSelfQuoteMedia: false` when resolving supplemental media. Native command adapters must authorize the sender before preparing a configured binding. `resolveCommandAuthorization(...)` from `openclaw/plugin-sdk/command-auth-native` returns an optional `assertOwnerCurrent` callback carrying the host's admitted owner check. It cannot grant ownership; the callback is absent when no owner check was bound. Capture the authorization result before awaited preparation, combine its callback with current channel and command-policy checks, and pass the resulting callback as `assertActive` to `ensureConfiguredBindingRouteReady`. ACP preparation checks it before later backend effects, including queued controls, handle reopening, and session replacement. Accepted control and close results still settle after revocation; revocation blocks the next effect. The optional callback preserves existing callers that do not carry channel-request authority. For intentional skips, `logInboundDrop({ log, channel, reason, target?, onceKey?, hint? })` formats a diagnostic through the supplied logger. Use a default-level logger and an actionable `hint` for mention-gated groups. Set `onceKey` to an account/conversation scope, such as `JSON.stringify([accountId, conversationId])`, to suppress repeats for that channel and reason. The process-local cache retains 512 recently used keys; evicted keys can log again. Omit `onceKey` for per-message logging. Keep message bodies and sender details out of default-level diagnostics. For a group-setting hint, use `resolveChannelGroupsConfigPath({ cfg, channel, accountId, groups })` from `openclaw/plugin-sdk/channel-policy`. Pass the exact groups map selected by the channel owner, without cloning it: `resolveChannelGroups(cfg, channel, accountId)` for shared group-policy resolution, or the resolved account's map for a plugin with its own inheritance rules. This identifies the authored root or account map without replacing inherited wildcard or per-group policies. For media-only inbound events, keep 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: ```ts // saved, nativeUrl, messageId, and caption are plugin-supplied values from the // platform event you just normalized. const media = toInboundMediaFacts([ { path: saved.path, url: nativeUrl, contentType: saved.contentType, messageId }, ]); const ctx = runtime.channel.reply.finalizeInboundContext({ Body: caption, media }); ``` `toInboundMediaFacts` is exported from this subpath. `finalizeInboundContext` is not: it is reached through the injected plugin runtime as `runtime.channel.reply.finalizeInboundContext`. Array position is attachment identity. Per-fact `transcribed`, `messageId`, and `workspaceDir` replace the legacy parallel index/workspace fields. The `MediaPath`, `MediaPaths`, `MediaUrl`, `MediaUrls`, `MediaType`, `MediaTypes`, `MediaTranscribedIndexes`, `MediaWorkspaceDir`, and `MediaStaged` context fields, plus `buildChannelInboundMediaPayload(...)`, remain available only as deprecated compatibility. The compatibility registry deprecated them on 2026-07-24 with a `removeAfter` date of 2026-10-01; see the [removal timeline](/plugins/sdk-migration/removal-timeline). New plugins should not construct or read them. Bundled/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: ```ts 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. ## Debouncing `createInboundDebouncer` from `openclaw/plugin-sdk/channel-inbound-debounce` releases a keyed queue when the flush's `admission` promise settles and keeps its `completion` promise available to `drain()`. New `onFlush` callbacks should use the supplied `createFlush` helper or return those two promises explicitly. Plugins built against the stable `v2026.7.x` releases can still return a Promise or thenable. The host waits for that result for both admission and completion, preserving same-key ordering and rejection handling. No removal date has been announced for this runtime compatibility path. ## Platform-selected history windows When the platform owns recent history, pass the selected entries as `message.inboundHistory` and set `sessionTranscript: { historyLimit, historyKind: "recent" }`. The host renders that configured window without merging canonical transcript rows back into it. Without `"recent"`, existing transcript enrichment and the legacy defensive 20-entry prompt cap remain unchanged. The channel owns bounded fetching, current account/conversation permissions, reset boundaries, and current-message exclusion. A history failure must not silently restore stale cached content. Keep the selected snapshot consistent between formatted context and structured history. For plaintext context, `buildHistoryContext(...)` and `buildHistoryContextFromEntries(...)` from `openclaw/plugin-sdk/reply-history` also accept `historyKind: "recent"`. Their default `"pending"` framing is unchanged; use these helpers instead of inventing command-sensitive markers. ## Agent group dispatch The shared inbound dispatcher coordinates qualified `broadcast` entries before the ordinary single-agent call. Pass the real channel, account, conversation, thread, and root message identity through the inbound context. Core owns participant selection, agent/session rebinding, bounded sibling-final digests, continuation identities, and synchronous `maxTurns` reservations. Channel plugins own admission facts and platform encoding; do not implement another participant loop in a plugin. `MsgContext.GroupThread` carries prepared participant mention facts as a data-only contract. Shared context types do not depend on mention matching or channel registries; the admission resolver owns mention matching before dispatch. A turn is one participant run started by the coordinator. Its previews, chunks, and message-tool sends can produce multiple physical messages. Do not buffer or count deliveries to enforce `maxTurns`. The group budget is process-local and non-resumable. Configured ACP bindings retain exclusive ownership of their route. See [Broadcast groups](/channels/broadcast-groups) for the operator config and [Inbound mention policy](/plugins/sdk-channel-plugins#inbound-mention-policy) for participant-aware admission. ## Internal turn sources `MsgContext.InternalTurnSource` identifies an internal wake: `"heartbeat"`, `"cron"`, or `"exec"`. Leave it unset for ordinary channel messages. It keeps internal turns from resetting sessions or replacing the conversation binding; it does not grant execution authority or replace `InputProvenance`. Keep `Provider` and `Surface` for transport identity, and keep the reply route in `OriginatingChannel` and `OriginatingTo`. An internal wake may have no transport or explicit reply target. Do not put a wake label in those channel fields. For existing SDK callers, inbound finalization and session-recording entrypoints translate legacy `Provider` values `"heartbeat"`, `"cron-event"`, and `"exec-event"` into `InternalTurnSource`. They remove those labels from channel fields while preserving a real reply route. New callers should set the typed source directly. ## Receive acknowledgment policy `createMessageReceiveContext(...)`, exported from `openclaw/plugin-sdk/channel-outbound`, tracks acknowledgment state for one inbound event. Its `ackPolicy` selects the stage accepted by `shouldAckAfter(...)`: | Policy | Acknowledgment boundary | | ---------------------- | ------------------------------------------------------------------------- | | `after_receive_record` | After durable inbound metadata is recorded for deduplication and routing. | | `after_agent_dispatch` | After the agent run is dispatched. | | `after_durable_send` | After durable outbound delivery commits. | | `manual` | No stage qualifies automatically; the caller decides when to acknowledge. | The helper defaults to `after_receive_record`, not `manual`. Callers still perform the work and invoke `ack()` or `nack(error)`; selecting a policy does not itself persist, dispatch, or send a message. ## Delivery settlement contract `ChannelInboundTurnPlan.delivery` owns the native send for each logical reply payload. On the routed API, core runs `reply_payload_sending`, calls `preparePayload`, and then assigns exactly one `message_sending` owner: - a declared `durable` branch runs the hook inside shared durable delivery; - a direct `deliver` branch runs the hook in core before the native adapter; - an exceptional provider funnel can use `deliverWithProviderMessageSending` when it must choose durable delivery or native finalization inside that funnel. Do not apply `message_sending` again inside a normal `deliver` callback. Use the provider-owned callback only when the branch cannot be declared before entering the provider funnel; it is mutually exclusive with `deliver` and `durable`. Existing direct and durable plans keep using `ChannelInboundTurnPlan`; explicitly type the exceptional funnel as `ChannelInboundTurnPlan<"provider_message_sending">`. Caller-assembled `dispatchChannelInboundReply(...)` remains the compatibility boundary and keeps its caller-provided dispatcher ownership. Low-level `createReplyDispatcher(...)` callbacks from `openclaw/plugin-sdk/reply-runtime` may return `ambiguous: true` when a send may have completed but its outcome is unconfirmed. Core records existing `unknown` custody and `failedAfterSend` receipt counts, suppressing duplicate fallbacks without attesting observed delivery. A settled `suppression` with reason `channel_transform` records `suppressed` custody. The receipt's `anyVisibleDelivered` remains conservative: it includes uncertain sends. A proven no-send can still belong to the durable recovery queue. Core retains that retry owner and records `failedBeforeSend` without issuing a caption or final fallback; it does not count the failed attempt as observed delivery. `preparePayload` may return `null` when channel policy intentionally suppresses the logical payload. Core records a typed non-visible result and skips durable selection, `message_sending`, and native delivery, so a later modifying hook cannot resurrect content the channel rejected. Core also owns terminal `message_sent` observation when the adapter opts in. Keep these responsibilities separate so one payload cannot produce duplicate modifier or terminal events. The delivery result fields have these meanings: | Field | Contract | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `content` | Provider-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` / `receipt` | Actual provider identities for the visible send. Prefer a `MessageReceipt`; core uses its primary provider id for `message_sent`. | | `visibleReplySent` | Set to `false` only when the provider produced no visible preview or final message. Core does not emit a successful `message_sent` for that result. | | `suppression` | Typed intentional no-send reason after a modifying hook or payload policy settles. Hook cancellation can also include `cancelReason` and metadata. Core never calls the direct native adapter for a core-owned suppression. | | `finalization` | A 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. `onDelivered` also receives settled suppressed results. A suppressed result has `visibleReplySent: false`, does not emit `message_sent`, and does not count as a visible queued reply. This lets plugins distinguish hook cancellation from provider failure without inventing a native message identity. By default, routed turns record inbound metadata against `ctxPayload.SessionKey ?? route.sessionKey`. Set `record.sessionKey` only when a native command intentionally executes in one command session while updating a different provider-routed target session. The override affects inbound metadata, transcript-context merge, and record-stage diagnostics; it does not change dispatch routing or hook correlation. An explicit override must be non-empty and contain no surrounding whitespace. The shared `recordInboundSession` recorder joins its metadata writer before returning, so dispatch cannot race creation of the session store. If the agent database is still awaiting startup inspection, recording rejects before dispatch so the channel transport can retry the inbound message. Other metadata write failures still reach `onRecordError` without failing the turn. The promise passed to `trackSessionMetaTask` includes asynchronous error reporting; reporting and automatic session maintenance remain outside foreground completion. 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: ```ts import { createChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound"; throw createChannelPartialDeliveryError(cause, { visibleReplySent: true, content: finalizedVisibleText, receipt, }); ``` Errors created by this helper retain the original failure in `cause`. After `isChannelPartialDeliveryError(error)`, `error.cause` is `unknown`; structural envelopes may omit it. 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](/plugins/sdk-channel-outbound) for new preview paths. ## Migration `runtime.channel.turn` is a deprecated compatibility alias for shipped plugins compiled before the inbound rename. It is the same object as `runtime.channel.inbound`, including its runtime-bound `dispatch` helper. New and migrated plugins should 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. `turn` and `runPreparedReply` are carried by the `plugin-runtime-api-compat-aliases` compatibility record, whose earliest removal review date is 2026-10-01. That date is a review date and not a scheduled removal: the alias stays until every enumerated surface is proven to have no bundled or published reader. 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. ## Related - [Channel ingress API](/plugins/sdk-channel-ingress) — the resolver whose result this page consumes as `channelIngress` - [Channel outbound API](/plugins/sdk-channel-outbound) — the send side of the same channel plugin - [Building channel plugins](/plugins/sdk-channel-plugins) — the full channel plugin walkthrough