14 KiB
summary, title, read_when
| summary | title | read_when | |||
|---|---|---|---|---|---|
| Outbound message lifecycle API for channel plugins: adapters, receipts, durable sends, live preview, and reply pipeline helpers | Channel outbound API |
|
Channel plugins expose outbound message behavior from
openclaw/plugin-sdk/channel-outbound. Use
openclaw/plugin-sdk/channel-inbound for receive/context/dispatch
orchestration.
Core owns queueing, durability, the durable ingress monitor and drain
(createChannelIngressMonitor, createChannelIngressDrain, and
openChannelIngressDrain), generic retry policy, turn-adoption lifecycle
(turnAdoptionLifecycle / bindIngressLifecycleToReplyOptions), hooks,
receipts, and the shared message tool. The plugin owns native
send/edit/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.
Durable ingress monitors
Use createChannelIngressMonitor(...) when a channel must persist accepted
transport events before dispatch. It composes a channel ingress queue and drain
with the shared admission, polling, pruning, delivery, and shutdown lifecycle.
Use the lower-level createChannelIngressDrain(...) only when the transport
owns a materially different admission or pump contract.
The required options are:
| Option | Contract |
|---|---|
queue |
A ChannelIngressQueue, or a lazy factory that opens the account-scoped queue. |
inspect(raw, context) |
Returns the stable eventId and serialized laneKey, or null for an ignored event. Claim-time facts must match the persisted id and lane. |
payload |
Supplies the payload version plus body serialization/deserialization. Use storage: "raw-event" for the standard { version, rawEvent } string envelope, or provide custom encode/decode callbacks for an existing channel-specific shape. createClaimError classifies invalid versions or changed identity. |
deliver(raw, lifecycle, claim) |
Dispatches one decoded event and receives the complete adoption lifecycle. It may return completed, deferred, failed-retryable, or nothing. |
pollIntervalMs |
Schedules recovery/drain polls while the monitor is running. |
retention |
Supplies the prune cadence and completed/failed TTL and entry caps. |
The monitor serializes admissions so append backoff cannot invert a lane. The
default bounded append delays are 0, 100, and 300 ms; exhaustion rejects
the transport callback instead of dispatching an event that was not made
durable. At claim time it decodes the versioned payload, re-runs inspect, and
rejects an id or lane mismatch before delivery.
deliver receives onAdopted, onDeferred, onAdoptionFinalizing,
onAbandoned, and abortSignal. Returning without an explicit handoff marks a
terminal no-dispatch event adopted. admission is always exclusive. A
deferred handoff keeps the claim held, while shutdown or abort leaves unadopted
work retryable. The monitor tracks delivery independently from claim settlement
because adoption can tombstone a row before the channel's delivery promise
returns.
Optional settings include custom append delays, a drain option block for
advanced drain ordering/concurrency/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. Use the idempotent
ensureQueueAvailable() check when plugin-owned migration or preparation must
run after the queue opens but before the drain starts. stop first settles
accepted admissions, 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 persisted payload shape in the plugin. Webhook transports
should acknowledge only after admit resolves; non-replay transports should
surface durable append exhaustion rather than silently dispatching.
Adapter
Most plugins define one message adapter:
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 declare capabilities the native transport actually preserves. Cover each declared send, receipt, live-preview, and receive-ack capability with the contract helpers exported from this subpath.
Outbound echo suppression
When a platform may redeliver the plugin's own outbound message as inbound, call recordOutboundMessageIdentity(...) with the channel, account, conversation, and a stable platform message or source identity. The shared inbound turn path drops matching identities for a bounded 30-second window before session recording or agent dispatch; a source identity may be reserved before 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 convert 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.
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.