Files
openclaw-openclaw/docs/concepts/message-lifecycle-refactor.md
Peter Steinberger f7d7148cf0 docs: rewrite published docs grounded in current source (#100142)
Source-grounded rewrite of 529 published docs pages with per-unit information-loss verification: 1,713 factual corrections cited to src/**, generated surfaces regenerated, frontmatter titles preserved for i18n, release notes pages untouched. All docs gates green.

Closes #100141
2026-07-05 00:32:47 -04:00

15 KiB

summary, read_when, title
summary read_when title
Status of the durable message receive/send lifecycle: what shipped, what changed from the original design, and what remains open
Refactoring channel send or receive behavior
Changing channel inbound, reply dispatch, outbound queue, preview streaming, or plugin SDK message APIs
Designing a new channel plugin that needs durable sends, receipts, previews, edits, or retries
Message lifecycle refactor
This page originated as a forward-looking design proposal. The core of that design has since shipped in `src/channels/message/*` and the public `openclaw/plugin-sdk/channel-outbound` / `channel-inbound` subpaths. For the current API, use [Channel outbound API](/plugins/sdk-channel-outbound) and [Channel inbound API](/plugins/sdk-channel-inbound). This page tracks what shipped, where the implementation diverged from the original sketch, and what is still open.

Why this refactor happened

The channel stack grew from several local fixes: separate inbound helpers per maturity level (runtime.channel.inbound.run for simple adapters, runtime.channel.inbound.runPreparedReply for rich ones), legacy reply-dispatch helpers (dispatchInboundReplyWithBase, recordInboundSessionAndDispatchReply), channel-specific preview streaming, and final-delivery durability bolted onto existing reply-payload paths. That shape produced too many public concepts and too many places where delivery semantics could drift.

The reliability gap that forced the redesign:

Telegram polling update acked
  -> assistant final text exists
  -> process restarts before sendMessage succeeds
  -> final response is lost

Target invariant: once core decides a visible outbound message should exist, the send intent must be durable before the platform call is attempted, and the platform receipt must be committed after success. That gives at-least-once recovery by default. Exactly-once behavior only exists where an adapter proves native idempotency or reconciles an unknown-after-send attempt against platform state before replay.

What shipped

The internal domain lives in src/channels/message/*:

File Owns
types.ts Adapter, send-context, receipt, and durable-intent type contracts
send.ts withDurableMessageSendContext / sendDurableMessageBatch — the durable send context
receive.ts createMessageReceiveContext — inbound ack-policy state machine
live.ts Live preview state and finalize-in-place-or-fall-back logic
state.ts classifyDurableSendRecoveryState — recovery classification after interruption
receipt.ts Normalizes platform send results into MessageReceipt
capabilities.ts Derives required durable-final capabilities from a payload
contracts.ts Contract-proof verification for declared adapter capabilities
adapter.ts defineChannelMessageAdapter
outbound-bridge.ts createChannelMessageAdapterFromOutbound — wraps legacy sendText/sendMedia/sendPayload/sendPoll functions
ingress-queue.ts createChannelIngressQueue — durable inbound event queue
durable-receive.ts createDurableInboundReceiveJournal — accept/pending/complete/release journal for inbound dedupe
inbound-reply-dispatch.ts dispatchChannelInboundReply and legacy-named wrappers
reply-pipeline.ts createChannelReplyPipeline, reply-prefix and typing-callback helpers

Public surface: openclaw/plugin-sdk/channel-outbound (send/receipt/durable/live/reply-pipeline helpers) and openclaw/plugin-sdk/channel-inbound (inbound context, runChannelInboundEvent, dispatchChannelInboundReply). See those pages for adapter examples, current type names, and migration notes — they are the source of truth for the API shape, not the sketches below.

Send context

withDurableMessageSendContext gives channel code render, previewUpdate, send, edit, delete, commit, and fail steps around one outbound message. sendDurableMessageBatch is the common-case wrapper: render, send, then commit on sent/suppressed or fail on error.

sendDurableMessageBatch returns one discriminated result:

Status Meaning
sent At least one visible platform message was delivered
suppressed No platform message should be treated as missing (hook-cancelled, dry-run, etc.)
partial_failed At least one message delivered before a later payload or side effect failed
failed No platform receipt was produced

Durability is one of required, best_effort, or disabled (MessageDurabilityPolicy in src/channels/message/types.ts). required fails closed when the durable intent cannot be written; best_effort falls through to a direct send when persistence is unavailable; disabled keeps the pre-refactor direct-send behavior. Legacy compatibility helpers default to disabled and do not infer required just because a channel has a generic outbound adapter.

The boundary that stays dangerous: after the platform call succeeds and before the receipt commits. If the process dies there, core cannot know whether the platform message exists unless the adapter declares reconcileUnknownSend. That hook classifies an interrupted send as sent, not_sent, or unresolved; only not_sent permits replay. Channels without reconciliation fall back to unknown_after_send state (src/channels/message/state.ts, src/infra/outbound/delivery-queue-recovery.ts) and may choose at-least-once replay only if duplicate visible messages are an acceptable, documented tradeoff for that channel.

Receive context

createMessageReceiveContext tracks ack/nack state per inbound event with an idempotent ack() and explicit nack(error). The ack policy (ChannelMessageReceiveAckPolicy) is one of:

Policy Acks when
after_receive_record Core persisted enough inbound metadata to dedupe/route a redelivery
after_agent_dispatch The agent run has been dispatched
after_durable_send The durable outbound send for this turn committed
manual Caller controls ack timing explicitly (the default for adapters that do not declare a policy)

Telegram polling uses this to persist a safe-completed update watermark (safeCompletedUpdateId in extensions/telegram/src/bot-update-tracker.ts): grammY still observes every update as it enters the middleware chain, but OpenClaw only advances the persisted restart watermark past updates that finished dispatch, so failed or still-pending updates replay after a restart. Telegram's upstream getUpdates offset is still owned by grammY; a fully durable polling source that controls platform-level redelivery beyond this watermark is not built (see Open questions).

Live preview

src/channels/message/live.ts models preview/edit/finalize as one lifecycle: createLiveMessageState, markLiveMessagePreviewUpdated, markLiveMessageFinalized, markLiveMessageCancelled, and deliverFinalizableLivePreviewAdapter (build a final edit from a draft, apply it, and fall back to a normal send when the edit is not possible or fails). LiveMessageState.phase is idle | previewing | finalizing | finalized | cancelled; canFinalizeInPlace gates whether a preview can become the final message via edit instead of a fresh send.

Durable receipts

MessageReceipt (src/channels/message/types.ts) normalizes one or more platform message ids from a single logical send into platformMessageIds plus per-part parts (kind, index, thread id, reply-to id). A primary id is kept for threading and later edits. This is what makes multi-part deliveries (text plus media, chunked text, card fallback) replayable and de-duplicatable after a restart.

Public SDK reduction

The refactor absorbed or deprecated: reply-runtime, reply-dispatch-runtime, reply-reference, reply-chunking, reply-payload helpers exposed as public API, inbound-reply-dispatch, channel-reply-pipeline, and most public uses of outbound-runtime. src/plugin-sdk/channel-message.ts is now a @deprecated re-export barrel pointing at channel-outbound / channel-inbound; channel.turn runtime aliases were removed and the old /plugins/sdk-channel-turn doc page redirects to Channel inbound API. New plugin code should target channel-outbound and channel-inbound directly.

Where the implementation diverged from the original design

The design sketch below never shipped as literally described. Record kept for historical accuracy; do not treat these type names as current API.

  • No MessageOrigin / shouldDropOpenClawEcho. The original plan called for a source: "openclaw" origin tag on gateway-failure messages plus a shared predicate that drops tagged bot-authored echoes in shared rooms before allowBots authorization. That type and predicate do not exist in the codebase. allowBots itself is a real per-channel config key (Slack, Discord, Google Chat, and others), but the origin-tagging mechanism that was meant to protect it was never built. Gateway-failure echo suppression in bot-enabled rooms remains an open gap, not a shipped guarantee.
  • No unified core.messages.receive/send/live/state namespace. The shipped functions live directly in src/channels/message/* (withDurableMessageSendContext, createMessageReceiveContext, createLiveMessageState, classifyDurableSendRecoveryState) rather than behind a core.messages.* facade.
  • No generic ChannelMessage / MessageTarget / MessageRelation normalized message type. Core still passes concrete reply payloads (ReplyPayload) and channel-specific contexts through the send adapters rather than one platform-neutral message shape with a kind: "reply" | "followup" | "broadcast" | "system" relation.
  • Ack policy names differ from the sketch. Shipped: after_receive_record | after_agent_dispatch | after_durable_send | manual. The original sketch used immediate | after-record | after-durable-send | manual with a webhook-timeout reason field; that shape was not built.
  • DurableFinalDeliveryRequirementMap capability keys replaced the sketched MessageCapabilities object. Capabilities are flat boolean flags (text, media, poll, payload, silent, replyTo, thread, nativeQuote, messageSendingHooks, batch, reconcileUnknownSend, afterSendSuccess, afterCommit) verified through verifyDurableFinalCapabilityProofs rather than a nested text.chunking / attachments.voice style structure.

Concrete migration hazards (still relevant)

These channel-specific side effects predate the refactor and must keep working through the new send paths. They are not hypothetical: each is implemented and load-bearing today.

  • iMessage (extensions/imessage/src/monitor/echo-cache.ts, persisted-echo-cache.ts): the monitor records sent messages in an echo cache after a successful send. Durable final sends must still populate that cache, or OpenClaw can re-ingest its own replies as inbound user messages.
  • Tlon (extensions/tlon/src/monitor/index.ts): appends an optional model signature and records participated threads after group replies. Durable delivery must not bypass those effects.
  • Discord and other prepared dispatchers already own direct delivery and preview behavior. A channel is not durable end-to-end until its prepared dispatcher explicitly routes finals through the send context; do not assume coverage from the generic adapter alone.
  • Telegram silent fallback delivery must deliver the whole projected payload array, not just the first payload, after chunking/fallback projection.
  • LINE, Zalo, Nostr, and similar helper paths can have reply-token handling, media proxying, sent-message caches, or callback-only targets. They stay on channel-owned delivery until those semantics are represented by the send adapter and covered by tests.
  • Direct-DM helpers can have a reply callback that is the only correct transport target. Generic outbound must not guess a target from raw platform fields and skip that callback.

Failure classification

Adapters classify transport failures into DeliveryFailureKind-style closed categories (transient, rate limit, auth, permission, not found, invalid payload, conflict, cancelled, unknown). Core policy:

  • Retry transient and rate-limit failures.
  • Do not retry invalid-payload failures unless a render fallback exists.
  • Do not retry auth or permission failures until configuration changes.
  • On not-found, let live finalization fall back from edit to a fresh send when the channel declares that safe.
  • On conflict, use receipt/idempotency state to decide whether the message already exists.
  • Any error after the platform call may have succeeded but before receipt commit becomes unknown_after_send unless the adapter proves the platform operation did not happen.

Open questions

  • Whether Telegram should eventually replace the grammY (1.43.0) polling runner with a fully durable polling source that controls platform-level redelivery, not only OpenClaw's persisted restart watermark (safeCompletedUpdateId).
  • Whether live preview state should live in the same record as the final send intent or in a sibling live-state store.
  • Whether gateway-failure echo suppression in shared bot-enabled rooms needs the originally planned origin-tagging mechanism, a simpler per-channel contract, or is out of scope.
  • Which channels have native origin/metadata support for cross-bot echo suppression versus needing a persisted outbound registry.