Channel Outbound API: Lifecycle, Adapters, and Durable Sends
Reference for channel plugin developers on outbound message lifecycle, including adapters, receipts, durable sends, live preview, and reply pipeline helpers. Covers queueing, monitoring, and drain utilities.
Read this when
- You are building or refactoring a messaging channel plugin send path
- You need durable final reply delivery, receipts, live preview finalization, or receive acknowledgement policy
- You are migrating from channel-message or legacy reply dispatch helpers
Channel plugins make outbound message behavior available through openclaw/plugin-sdk/channel-outbound. For receive, context, and dispatch orchestration, use openclaw/plugin-sdk/channel-inbound.
Queueing, durability, the durable ingress monitor and drain (createChannelIngressMonitor, createChannelIngressDrain, and openChannelIngressDrain), the generic retry policy, the turn-adoption lifecycle (turnAdoptionLifecycle / bindIngressLifecycleToReplyOptions), hooks, receipts, and the shared message tool all live in Core. Native send, edit, and delete calls, target normalization, platform threading, selected quotes, notification flags, account state, ingress inspection and payload encoding, lane keys, non-retryable predicates, optional supersede authorization, and platform-specific side effects belong to the plugin.
Durable ingress monitors
When a channel must persist accepted transport events before dispatch, reach for createChannelIngressMonitor(...). It builds a channel ingress queue and drain on top of the shared admission, polling, pruning, delivery, and shutdown lifecycle. Only when the transport has a fundamentally different admission or pump contract should you use the lower-level createChannelIngressDrain(...).
These options are required:
| Option | Contract |
|---|---|
queue | Either a ChannelIngressQueue or a lazy factory that opens the account-scoped queue. |
inspect(raw, context) | Provides the stable eventId and serialized laneKey, or null when the event is ignored. Facts captured at claim time must line up with the persisted id and lane. |
payload | Gives the payload version plus body serialization and deserialization. The standard { version, rawEvent } string envelope is handled by storage: "raw-event", or you can supply custom encode and decode callbacks for a channel-specific shape. Invalid versions or changed identity are flagged by createClaimError. |
deliver(raw, lifecycle, claim) | Delivers one decoded event and gets the full adoption lifecycle back. It can return completed, deferred, failed-retryable, or nothing. |
pollIntervalMs | Plans recovery and drain polls while the monitor runs. |
retention | Provides the prune cadence, completed and failed TTL, and entry caps. |
Admissions are serialized by the monitor so append backoff cannot flip a lane's order. The default bounded append delays sit at 0, 100, and 300 ms; when exhausted, the transport callback is rejected rather than dispatching an event that never became durable. At claim time the versioned payload is decoded, inspect runs again, and any id or lane mismatch is rejected before delivery.
deliver takes onAdopted, onDeferred, onAdoptionFinalizing, onFailed, onCancelled, onAbandoned, and abortSignal. Delivery errors go through onFailed, explicit pre-adoption cancellation that must keep retry accounting intact uses onCancelled, and onAbandoned is for when a non-adopted turn should still consume a retry attempt. Returning without an explicit handoff marks a terminal no-dispatch event as adopted. admission is always exclusive. A deferred handoff holds the claim, while shutdown or abort leaves unadopted work retryable. Delivery and claim settlement are tracked independently by the monitor, since adoption can tombstone a row before the channel's delivery promise comes back.
Optional settings cover custom append delays, a drain option block for advanced drain ordering, concurrency, and retry policy, an external abortSignal, a clock, pump error reporting, a stopped-error factory, and admission policy. The returned monitor exposes admit, ensureQueueAvailable, start, pause, stop, waitForIdle, isRunning, and isStopped. When plugin-owned migration or preparation must run after the queue opens but before the drain starts, use the idempotent ensureQueueAvailable() check. stop settles accepted admissions first, then aborts and disposes the drain, waits for the pump and active deliveries, and disposes again to close the lazy-creation race.
Keep transport-specific redaction, raw-envelope validation, non-retryable classification, and the persisted payload shape inside the plugin. Webhook transports should acknowledge only after admit resolves; non-replay transports should surface durable append exhaustion instead of silently dispatching.
Adapter
A single message adapter covers most plugins:
import {
defineChannelMessageAdapter,
createMessageReceiptFromOutboundResults,
} from "openclaw/plugin-sdk/channel-outbound";
export const demoMessageAdapter = defineChannelMessageAdapter({
id: "demo",
durableFinal: {
capabilities: {
text: true,
replyTo: true,
thread: true,
messageSendingHooks: true,
},
},
send: {
text: async ({ cfg, to, text, accountId, replyToId, threadId, signal }) => {
const sent = await sendDemoMessage({
cfg,
to,
text,
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
threadId: threadId == null ? undefined : String(threadId),
signal,
});
return {
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: "demo", messageId: sent.id, conversationId: to }],
kind: "text",
threadId: threadId == null ? undefined : String(threadId),
replyToId: replyToId ?? undefined,
}),
};
},
},
});
Only list capabilities that the native transport genuinely preserves. For every declared send, receipt, live-preview, and receive-ack capability, provide coverage through the contract helpers exported from this subpath.
Outbound echo suppression
If a platform can redeliver the plugin's own outbound message as inbound, invoke recordOutboundMessageIdentity(...) with the channel, account, conversation, and a stable platform message or source identity. The shared inbound turn path filters out matching identities for a bounded 30-second window before session recording or agent dispatch; a source identity can be reserved prior to send or refreshed when a channel route is removed to close delivery races. isRecentOutboundMessageIdentity(...) exposes the same query for channel diagnostics and tests. Do not maintain a parallel channel-local TTL cache for the same stable identity.
Plain-text sanitization
Use sanitizeForPlainText(...) when an outbound adapter needs to transform the supported HTML formatting tags into lightweight text markup. The default keeps the existing chat-style bold and strikethrough markers. Pass { style: "markdown" } only when the channel reparses the result as Markdown:
import { sanitizeForPlainText } from "openclaw/plugin-sdk/channel-outbound";
const chatText = sanitizeForPlainText(text);
const markdownText = sanitizeForPlainText(text, { style: "markdown" });
The Markdown style uses **bold** and ~~strikethrough~~; italic and inline code keep _italic_ and backtick markers in both styles. Select the style at the channel boundary instead of rewriting marker text after sanitization.
Delivery Evidence
A MessageReceipt records the result returned by a channel adapter. Concrete platform message identifiers show that the platform send path accepted the message; they do not prove that a recipient's device displayed or read it. Receipts without platform message identifiers are local receipt metadata only. Channels with read receipts or device-delivery state should track those facts through a separate channel-specific path.
If a channel adapter can prove that retrying a failure cannot duplicate a recipient-visible send and no finalization-capable call began, throw new PlatformMessageNotDispatchedError("...", { cause: error }) from openclaw/plugin-sdk/error-runtime. Core can then clear stale send-attempt evidence and safely retry the queued intent. Only the adapter that owns the final dispatch boundary may make this assertion. Never use the marker after a finalization/send call begins or returns an ambiguous result; false marking can duplicate messages.
Existing outbound adapters
If the channel already has a compatible outbound adapter, derive the message adapter instead of duplicating send code:
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-outbound";
export const messageAdapter = createChannelMessageAdapterFromOutbound({
id: "demo",
outbound,
durableFinal: {
capabilities: {
text: true,
media: true,
},
},
});
Durable sends
Runtime send helpers also live on channel-outbound:
sendDurableMessageBatch(...)withDurableMessageSendContext(...)deliverInboundReplyWithMessageSendContext(...)- draft streaming/progress helpers such as
resolveChannelDraftStreamingChunking(...)
sendDurableMessageBatch(...) returns one explicit outcome:
| Outcome | Meaning |
|---|---|
sent | at least one visible platform message was accepted by the platform send path |
suppressed | no platform message should be treated as missing |
partial_failed | at least one platform message was accepted before a later payload or side effect failed |
failed | no platform receipt was produced |
Use payloadOutcomes when a batch mixes sent, suppressed, and failed payloads. Do not infer hook cancellation from an empty legacy direct-delivery result.
When a transport creates a thread during its first successful send, the outbound adapter may implement adoptTargetFromDelivery(...). Return the typed thread ID from the platform receipt and core carries it into later payloads, pins, and post-delivery hooks in that durable batch. Core never replaces an explicit caller thread, and it does not infer adoption from receipt.threadId without the adapter opt-in.
Automatic unknown-send reconciliation
Set message.durableFinal.automaticUnknownSendReconciliation only when the plugin can reconcile an ambiguous provider send from persisted, post-policy state without rerunning modifying hooks or regenerating provider payloads. Core considers this opt-in after hooks and cancellation, and only for exactly one accepted prepared payload. Multi-payload batches do not opt in automatically.
The adapter must also advertise capabilities.reconcileUnknownSend: true and provide reconcileUnknownSend(...). Use reconcileUnknownSendKinds to name the concrete transport branches the plugin can prove, such as text or media. If the kind map is present, the selected branch must be true. Omitting the map means the callback claims every selected branch, so prefer an explicit map for new plugins.
The callback must use provider-owned idempotency or authoritative readback to return sent with the actual provider receipt, not_sent only when a fresh send is provably safe, or unresolved when neither outcome can be proven. When reconciliation is explicitly required, unsupported prepared shapes fail before provider I/O. During recovery, missing, incomplete, or mismatched provider proof must fail closed rather than replaying content that could already be visible.
If reconciliation needs provider-owned persisted evidence, implement afterUnknownSendTerminal(...). Core calls it after the ambiguous queue row has authoritatively moved to failed, including retry-budget exhaustion. Use it to remove provider-owned plans or payloads that are no longer needed. Cleanup is best effort and must be idempotent; a failure is logged without making the terminal queue row replayable again.
Deferred delivery admission
Use message.durableFinal.admitDeferredDelivery(...) when a resolved account cannot safely accept core-managed outbound or deferred delivery. Core calls this hook synchronously before live outbound work, including paths that skip queue persistence, and again before replaying a recovered intent. The context includes cfg, channel, to, accountId, and a phase of live or recovery.
Return { status: "allowed" } to continue. Return { status: "permanent_rejection", reason } when the delivery must not be persisted, sent directly, or replayed. A live rejection fails before queue creation, message hooks, or platform work. A recovery rejection marks the queued record failed and skips reconciliation and replay. Omitting the hook means allowed.
The hook is a synchronous admission decision, not a send path. Read only already-loaded config or runtime state; do not perform network, filesystem, or other asynchronous I/O. Contract tests should exercise both phases and both result variants through ChannelMessageDurableFinalAdapter from openclaw/plugin-sdk/channel-outbound.
Compatibility dispatch
Assemble inbound reply dispatch through dispatchChannelInboundReply(...) from channel-inbound. Keep platform delivery in the delivery adapter; use channel-outbound for message adapters, durable sends, receipts, live preview, and reply pipeline options.