UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

368 lines (304 loc) • 20.8 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. ## 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. 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