Files
openclaw/docs/plugins/sdk-channel-outbound.md
Peter Steinberger c7e7ac2728 refactor: remove expired plugin compatibility surfaces (#111451)
* docs(secrets): remove retired web credential paths

* refactor(web): remove retired provider compatibility paths

* refactor(providers): delete retired compatibility routes

* refactor(secrets): remove retired credential aliases

* refactor(plugin-sdk): delete retired compatibility surfaces

* docs(plugin-sdk): remove retired migration guidance

* chore(plugin-sdk): refresh rebased surface budgets

* chore(plugin-sdk): refresh API removal baseline

* refactor(compat): migrate retired internal callers

* chore(plugin-sdk): refresh current-main baselines

* test(config): migrate plugin-owned secret assertions

* test(gateway): narrow plugin secret refs

* fix(plugin-sdk): preserve private boundary type identity

* chore(compat): remove stale sweep references

* chore(lint): lower max-lines budget

* refactor(secrets): remove unused web helper

* build(plugin-sdk): drop removed compat entries

* chore(plugin-sdk): refresh rebased API baseline

* chore(plugin-sdk): use Linux API baseline hash

* fix(plugin-sdk): preserve private bundled build entries

* fix(plugin-sdk): package private runtime facades

* fix(plugins): preserve external credential contracts
2026-07-19 11:04:48 -07:00

12 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
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 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, start, pause, stop, waitForIdle, isRunning, and isStopped. 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.

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.