openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
382 lines (315 loc) • 21.6 kB
Markdown
---
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