* feat(sdk): always persist media facts and ship facts-first replacements for legacy Media* surfaces
PR 1 of the media legacy retirement program (audit-frozen, 4 PRs).
- Every media-bearing user turn now persists normalized __openclaw.media
facts unconditionally while continuing to emit the legacy top-level
Media* projection byte-identically (dual-write bridge; the conditional
shouldPersistStructuredMediaEntries gate now always includes media).
- New replacement APIs, shipped before any removal: typed hook media
facts (media[], originalMedia[], mediaStagingPending) on message
events; {{AttachmentPath}}/{{AttachmentUrl}}/{{AttachmentContentType}}/
{{AttachmentDir}}/{{AttachmentIndex}} template variables; focused
openclaw/plugin-sdk/media-local-roots subpath split out of the
deprecated agent-media-payload facade.
- Every legacy surface carries @deprecated naming its replacement, under
one named compatibility record media-legacy-projection with the
operator-approved removeAfter 2026-10-01 (two release trains; deletion
additionally gates on a clean published-plugin artifact sweep).
- Generic transcript append invariant documented; SDK migration, hooks,
and configuration docs updated to the facts-first path.
Writer golden matrix proves legacy bytes and model prompt bytes are
unchanged while nested facts become unconditional. 2,189 broad media
tests green; SDK api-baseline regenerated on fresh-env Testbox.
* feat(sdk): register media-local-roots subpath exports and deprecation metadata
Completes PR 1: package export map for openclaw/plugin-sdk/media-local-roots
plus the deprecated-subpath inventory and doc metadata entries for the
media-legacy-projection record.
* chore(sdk): track media-local-roots entrypoint and deprecated-export budgets
* fix(sdk): keep deprecated MSTeams buildMediaPayload re-export through the compat window
Deleting shipped runtime-api re-exports belongs to retirement PR 4 after
the media-legacy-projection window; PR 1 only deprecates. Also formats
the migration-guide schedule table.
* docs: regenerate docs map for media migration additions
8.1 KiB
summary, title, read_when
| summary | title | read_when | |||
|---|---|---|---|---|---|
| Inbound event helpers for channel plugins: context building, shared runner orchestration, session record, and prepared reply dispatch | Channel inbound API |
|
Channel receive paths follow one flow:
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
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 throughchannelContext, which plugin hooks see asctx.channelContext. AugmentPluginHookChannelSenderContextorPluginHookChannelChatContextfrom this subpath for channel-specific fields.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 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:
const media = toInboundMediaFacts([
{ path: saved.path, url: nativeUrl, contentType: saved.contentType, messageId },
]);
const ctx = finalizeInboundContext({ Body: caption, media });
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. 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:
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:
| 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. |
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.
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/provider code; channel plugins use inbound,
message, delivery, and reply terms.