From 61ac94ced777a19425bbc613b676800865af7c84 Mon Sep 17 00:00:00 2001 From: "openclaw-docs-sync[bot]" Date: Thu, 30 Apr 2026 00:33:57 +0000 Subject: [PATCH] chore(sync): mirror docs from openclaw/openclaw@1ead1b2d181fec6d95c76e027434fa25d35a1093 --- .openclaw-sync/source.json | 4 +- docs/.generated/config-baseline.sha256 | 4 +- docs/docs.json | 16 + docs/plugins/sdk-channel-plugins.md | 3 + docs/plugins/sdk-channel-turn.md | 393 +++++++++++++++++++++++++ 5 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 docs/plugins/sdk-channel-turn.md diff --git a/.openclaw-sync/source.json b/.openclaw-sync/source.json index 7e94901a0..4ce1c6a90 100644 --- a/.openclaw-sync/source.json +++ b/.openclaw-sync/source.json @@ -1,5 +1,5 @@ { "repository": "openclaw/openclaw", - "sha": "30a2b3049ae04c695e6e65308196c55189c78d25", - "syncedAt": "2026-04-30T00:26:46.012Z" + "sha": "1ead1b2d181fec6d95c76e027434fa25d35a1093", + "syncedAt": "2026-04-30T00:32:06.160Z" } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 32b2e7519..c93d66d7b 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -dbb3d39ddeb8a9ce03459d69774db7e457d33f47c585e0f8c7130b14b92cfcff config-baseline.json -2197788a6a1e677fb34a4971ac4f254116c243753452397824e08431f8740aab config-baseline.core.json +b095536aeeb273b029a7a96cd8406cbcbc315dfd67dc45f469782ce1ccbc9e08 config-baseline.json +ab9a004ec78ed51e646be29eb10aa6700de1d47fee77331a85ca5e2cd15b6e93 config-baseline.core.json fab66aa304db5697e87259165ad261006719eb6e6cdbd25f957fcba2b7b324e9 config-baseline.channel.json c4231c2194206547af8ad94342dc00aadb734f43cb49cc79d4c46bdbb80c3f95 config-baseline.plugin.json diff --git a/docs/docs.json b/docs/docs.json index 954694659..7b0c6366f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1226,6 +1226,7 @@ "plugins/sdk-subpaths", "plugins/sdk-entrypoints", "plugins/sdk-runtime", + "plugins/sdk-channel-turn", "plugins/sdk-agent-harness", "plugins/sdk-setup", "plugins/sdk-testing", @@ -2700,6 +2701,7 @@ "zh-TW/plugins/sdk-subpaths", "zh-TW/plugins/sdk-entrypoints", "zh-TW/plugins/sdk-runtime", + "zh-TW/plugins/sdk-channel-turn", "zh-TW/plugins/sdk-agent-harness", "zh-TW/plugins/sdk-setup", "zh-TW/plugins/sdk-testing", @@ -3559,6 +3561,7 @@ "ja-JP/plugins/sdk-subpaths", "ja-JP/plugins/sdk-entrypoints", "ja-JP/plugins/sdk-runtime", + "ja-JP/plugins/sdk-channel-turn", "ja-JP/plugins/sdk-agent-harness", "ja-JP/plugins/sdk-setup", "ja-JP/plugins/sdk-testing", @@ -4418,6 +4421,7 @@ "es/plugins/sdk-subpaths", "es/plugins/sdk-entrypoints", "es/plugins/sdk-runtime", + "es/plugins/sdk-channel-turn", "es/plugins/sdk-agent-harness", "es/plugins/sdk-setup", "es/plugins/sdk-testing", @@ -5277,6 +5281,7 @@ "pt-BR/plugins/sdk-subpaths", "pt-BR/plugins/sdk-entrypoints", "pt-BR/plugins/sdk-runtime", + "pt-BR/plugins/sdk-channel-turn", "pt-BR/plugins/sdk-agent-harness", "pt-BR/plugins/sdk-setup", "pt-BR/plugins/sdk-testing", @@ -6136,6 +6141,7 @@ "ko/plugins/sdk-subpaths", "ko/plugins/sdk-entrypoints", "ko/plugins/sdk-runtime", + "ko/plugins/sdk-channel-turn", "ko/plugins/sdk-agent-harness", "ko/plugins/sdk-setup", "ko/plugins/sdk-testing", @@ -6995,6 +7001,7 @@ "de/plugins/sdk-subpaths", "de/plugins/sdk-entrypoints", "de/plugins/sdk-runtime", + "de/plugins/sdk-channel-turn", "de/plugins/sdk-agent-harness", "de/plugins/sdk-setup", "de/plugins/sdk-testing", @@ -7854,6 +7861,7 @@ "fr/plugins/sdk-subpaths", "fr/plugins/sdk-entrypoints", "fr/plugins/sdk-runtime", + "fr/plugins/sdk-channel-turn", "fr/plugins/sdk-agent-harness", "fr/plugins/sdk-setup", "fr/plugins/sdk-testing", @@ -8713,6 +8721,7 @@ "ar/plugins/sdk-subpaths", "ar/plugins/sdk-entrypoints", "ar/plugins/sdk-runtime", + "ar/plugins/sdk-channel-turn", "ar/plugins/sdk-agent-harness", "ar/plugins/sdk-setup", "ar/plugins/sdk-testing", @@ -9572,6 +9581,7 @@ "it/plugins/sdk-subpaths", "it/plugins/sdk-entrypoints", "it/plugins/sdk-runtime", + "it/plugins/sdk-channel-turn", "it/plugins/sdk-agent-harness", "it/plugins/sdk-setup", "it/plugins/sdk-testing", @@ -10431,6 +10441,7 @@ "vi/plugins/sdk-subpaths", "vi/plugins/sdk-entrypoints", "vi/plugins/sdk-runtime", + "vi/plugins/sdk-channel-turn", "vi/plugins/sdk-agent-harness", "vi/plugins/sdk-setup", "vi/plugins/sdk-testing", @@ -11290,6 +11301,7 @@ "nl/plugins/sdk-subpaths", "nl/plugins/sdk-entrypoints", "nl/plugins/sdk-runtime", + "nl/plugins/sdk-channel-turn", "nl/plugins/sdk-agent-harness", "nl/plugins/sdk-setup", "nl/plugins/sdk-testing", @@ -12149,6 +12161,7 @@ "tr/plugins/sdk-subpaths", "tr/plugins/sdk-entrypoints", "tr/plugins/sdk-runtime", + "tr/plugins/sdk-channel-turn", "tr/plugins/sdk-agent-harness", "tr/plugins/sdk-setup", "tr/plugins/sdk-testing", @@ -13008,6 +13021,7 @@ "uk/plugins/sdk-subpaths", "uk/plugins/sdk-entrypoints", "uk/plugins/sdk-runtime", + "uk/plugins/sdk-channel-turn", "uk/plugins/sdk-agent-harness", "uk/plugins/sdk-setup", "uk/plugins/sdk-testing", @@ -13867,6 +13881,7 @@ "id/plugins/sdk-subpaths", "id/plugins/sdk-entrypoints", "id/plugins/sdk-runtime", + "id/plugins/sdk-channel-turn", "id/plugins/sdk-agent-harness", "id/plugins/sdk-setup", "id/plugins/sdk-testing", @@ -14726,6 +14741,7 @@ "pl/plugins/sdk-subpaths", "pl/plugins/sdk-entrypoints", "pl/plugins/sdk-runtime", + "pl/plugins/sdk-channel-turn", "pl/plugins/sdk-agent-harness", "pl/plugins/sdk-setup", "pl/plugins/sdk-testing", diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 0d7678198..f645d2203 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -681,6 +681,9 @@ Write colocated tests in `src/channel.test.ts`: TTS, STT, media, subagent via api.runtime + + Shared inbound turn lifecycle: ingest, resolve, record, dispatch, finalize + diff --git a/docs/plugins/sdk-channel-turn.md b/docs/plugins/sdk-channel-turn.md new file mode 100644 index 000000000..97cca37cd --- /dev/null +++ b/docs/plugins/sdk-channel-turn.md @@ -0,0 +1,393 @@ +--- +summary: "runtime.channel.turn -- the shared inbound turn kernel that bundled and third-party channel plugins use to record, dispatch, and finalize agent turns" +title: "Channel turn kernel" +sidebarTitle: "Channel turn" +read_when: + - You are building a channel plugin and want the shared inbound turn lifecycle + - You are migrating a channel monitor off hand-rolled record/dispatch glue + - You need to understand admission, ingest, classify, preflight, resolve, record, dispatch, and finalize stages +--- + +The channel turn kernel is the shared inbound state machine that turns a normalized platform event into an agent turn. Channel plugins provide the platform facts and the delivery callback. Core owns the orchestration: ingest, classify, preflight, resolve, authorize, assemble, record, dispatch, and finalize. + +Use this when your plugin is on the inbound message hot path. For non-message events (slash commands, modals, button interactions, lifecycle events, reactions, voice state), keep them plugin-local. The kernel only owns events that may become an agent text turn. + + + The kernel is reached through the injected plugin runtime as `runtime.channel.turn.*`. The plugin runtime type is exported from `openclaw/plugin-sdk/core`, so third-party native plugins can use these entry points the same way bundled channel plugins do. + + +## Why a shared kernel + +Channel plugins repeat the same inbound flow: normalize, route, gate, build a context, record session metadata, dispatch the agent turn, finalize delivery state. Without a shared kernel, a change to mention gating, tool-only visible replies, session metadata, pending history, or dispatch finalization has to be applied per channel. + +The kernel keeps four concepts deliberately separate: + +- `ConversationFacts`: where the message came from +- `RouteFacts`: which agent and session should process it +- `ReplyPlanFacts`: where visible replies should go +- `MessageFacts`: what body and supplemental context the agent should see + +Slack DMs, Telegram topics, Matrix threads, and Feishu topic sessions all distinguish these in practice. Treating them as one identifier causes drift over time. + +## Stage lifecycle + +The kernel runs the same fixed pipeline regardless of channel: + +1. `ingest` -- adapter converts a raw platform event into `NormalizedTurnInput` +2. `classify` -- adapter declares whether this event can start an agent turn +3. `preflight` -- adapter does dedupe, self-echo, hydration, debounce, decryption, partial fact prefill +4. `resolve` -- adapter returns a fully assembled turn (route, reply plan, message, delivery) +5. `authorize` -- DM, group, mention, and command policy applied to the assembled facts +6. `assemble` -- `FinalizedMsgContext` built from the facts via `buildContext` +7. `record` -- inbound session metadata and last route persisted +8. `dispatch` -- agent turn executed through the buffered block dispatcher +9. `finalize` -- adapter `onFinalize` runs even on dispatch error + +Each stage emits a structured log event when a `log` callback is supplied. See [Observability](#observability). + +## Admission kinds + +The kernel does not throw when a turn is gated. It returns a `ChannelTurnAdmission`: + +| Kind | When | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `dispatch` | Turn is admitted. Agent turn runs and the visible reply path is exercised. | +| `observeOnly` | Turn runs end-to-end but the delivery adapter sends nothing visible. Used for broadcast observer agents and other passive multi-agent flows. | +| `handled` | A platform event was consumed locally (lifecycle, reaction, button, modal). Kernel skips dispatch. | +| `drop` | Skip path. Optionally `recordHistory: true` keeps the message in pending group history so a future mention has context. | + +Admission can come from `classify` (event class said it cannot start a turn), from `preflight` (dedupe, self-echo, missing mention with history record), or from `resolveTurn` itself. + +## Entry points + +The runtime exposes three preferred entry points so adapters can opt in at the level that matches the channel. + +```typescript +runtime.channel.turn.run(...) // adapter-driven full pipeline +runtime.channel.turn.runPrepared(...) // channel owns dispatch; kernel runs record + finalize +runtime.channel.turn.buildContext(...) // pure facts to FinalizedMsgContext mapping +``` + +Two older runtime helpers remain available for Plugin SDK compatibility: + +```typescript +runtime.channel.turn.runResolved(...) // deprecated compatibility alias; prefer run +runtime.channel.turn.dispatchAssembled(...) // deprecated compatibility alias; prefer run or runPrepared +``` + +### run + +Use when your channel can express its inbound flow as a `ChannelTurnAdapter`. The adapter has callbacks for `ingest`, optional `classify`, optional `preflight`, mandatory `resolveTurn`, and optional `onFinalize`. + +```typescript +await runtime.channel.turn.run({ + channel: "tlon", + accountId, + raw: platformEvent, + adapter: { + ingest(raw) { + return { + id: raw.messageId, + timestamp: raw.timestamp, + rawText: raw.body, + textForAgent: raw.body, + }; + }, + classify(input) { + return { kind: "message", canStartAgentTurn: input.rawText.length > 0 }; + }, + async preflight(input, eventClass) { + if (await isDuplicate(input.id)) { + return { admission: { kind: "drop", reason: "dedupe" } }; + } + return {}; + }, + resolveTurn(input) { + return buildAssembledTurn(input); + }, + onFinalize(result) { + clearPendingGroupHistory(result); + }, + }, +}); +``` + +`run` is the right shape when the channel has small adapter logic and benefits from owning the lifecycle through hooks. + +### runPrepared + +Use when the channel has a complex local dispatcher with previews, retries, edits, or thread bootstrap that must stay channel-owned. The kernel still records the inbound session before dispatch and surfaces a uniform `DispatchedChannelTurnResult`. + +```typescript +const { dispatchResult } = await runtime.channel.turn.runPrepared({ + channel: "matrix", + accountId, + routeSessionKey, + storePath, + ctxPayload, + recordInboundSession, + record: { + onRecordError, + updateLastRoute, + }, + onPreDispatchFailure: async (err) => { + await stopStatusReactions(); + }, + runDispatch: async () => { + return await runMatrixOwnedDispatcher(); + }, +}); +``` + +Rich channels (Matrix, Mattermost, Microsoft Teams, Feishu, QQ Bot) use `runPrepared` because their dispatcher orchestrates platform-specific behavior the kernel must not learn about. + +### buildContext + +A pure function that maps fact bundles into `FinalizedMsgContext`. Use it when your channel hand-rolls part of the pipeline but wants consistent context shape. + +```typescript +const ctxPayload = runtime.channel.turn.buildContext({ + channel: "googlechat", + accountId, + messageId, + timestamp, + from, + sender, + conversation, + route, + reply, + message, + access, + media, + supplemental, +}); +``` + +`buildContext` is also useful inside `resolveTurn` callbacks when assembling a turn for `run`. + + + Deprecated SDK helpers such as `dispatchInboundReplyWithBase` still bridge through an assembled-turn helper. New plugin code should use `run` or `runPrepared`. + + +## Fact types + +The facts the kernel consumes from your adapter are platform-agnostic. Translate platform objects into these shapes before handing them to the kernel. + +### NormalizedTurnInput + +| Field | Purpose | +| ----------------- | ---------------------------------------------------------------------------- | +| `id` | Stable message id used for dedupe and logs | +| `timestamp` | Optional epoch ms | +| `rawText` | Body as received from the platform | +| `textForAgent` | Optional cleaned body for the agent (mention strip, typing trim) | +| `textForCommands` | Optional body used for `/command` parsing | +| `raw` | Optional pass-through reference for adapter callbacks that need the original | + +### ChannelEventClass + +| Field | Purpose | +| ---------------------- | ----------------------------------------------------------------------- | +| `kind` | `message`, `command`, `interaction`, `reaction`, `lifecycle`, `unknown` | +| `canStartAgentTurn` | If false the kernel returns `{ kind: "handled" }` | +| `requiresImmediateAck` | Hint for adapters that need to ACK before dispatch | + +### SenderFacts + +| Field | Purpose | +| -------------- | -------------------------------------------------------------- | +| `id` | Stable platform sender id | +| `name` | Display name | +| `username` | Handle if distinct from `name` | +| `tag` | Discord-style discriminator or platform tag | +| `roles` | Role ids, used for member-role allowlist matching | +| `isBot` | True when the sender is a known bot (kernel uses for dropping) | +| `isSelf` | True when the sender is the configured agent itself | +| `displayLabel` | Pre-rendered label for envelope text | + +### ConversationFacts + +| Field | Purpose | +| ----------------- | -------------------------------------------------------------------- | +| `kind` | `direct`, `group`, or `channel` | +| `id` | Conversation id used for routing | +| `label` | Human label for the envelope | +| `spaceId` | Optional outer space identifier (Slack workspace, Matrix homeserver) | +| `parentId` | Outer conversation id when this is a thread | +| `threadId` | Thread id when this message is inside a thread | +| `nativeChannelId` | Platform-native channel id when different from the routing id | +| `routePeer` | Peer used for `resolveAgentRoute` lookup | + +### RouteFacts + +| Field | Purpose | +| ----------------------- | ---------------------------------------------------------- | +| `agentId` | Agent that should handle this turn | +| `accountId` | Optional override (multi-account channels) | +| `routeSessionKey` | Session key used for routing | +| `dispatchSessionKey` | Session key used at dispatch when different from route key | +| `persistedSessionKey` | Session key written to persisted session metadata | +| `parentSessionKey` | Parent for branched/threaded sessions | +| `modelParentSessionKey` | Model-side parent for branched sessions | +| `mainSessionKey` | Main DM owner pin for direct conversations | +| `createIfMissing` | Allow record step to create a missing session row | + +### ReplyPlanFacts + +| Field | Purpose | +| ------------------------- | ------------------------------------------------------- | +| `to` | Logical reply target written into context `To` | +| `originatingTo` | Originating context target (`OriginatingTo`) | +| `nativeChannelId` | Platform-native channel id for delivery | +| `replyTarget` | Final visible-reply destination if it differs from `to` | +| `deliveryTarget` | Lower-level delivery override | +| `replyToId` | Quoted/anchored message id | +| `replyToIdFull` | Full-form quoted id when the platform has both | +| `messageThreadId` | Thread id at delivery time | +| `threadParentId` | Parent message id of the thread | +| `sourceReplyDeliveryMode` | `thread`, `reply`, `channel`, `direct`, or `none` | + +### AccessFacts + +`AccessFacts` carries the booleans the authorize stage needs. Identity matching stays in the channel: the kernel only consumes the result. + +| Field | Purpose | +| ---------- | ------------------------------------------------------------------------- | +| `dm` | DM allow/pairing/deny decision and `allowFrom` list | +| `group` | Group policy, route allow, sender allow, allowlist, mention requirement | +| `commands` | Command authorization across configured authorizers | +| `mentions` | Whether mention detection is possible and whether the agent was mentioned | + +### MessageFacts + +| Field | Purpose | +| ---------------- | -------------------------------------------------------------- | +| `body` | Final envelope body (formatted) | +| `rawBody` | Raw inbound body | +| `bodyForAgent` | Body the agent sees | +| `commandBody` | Body used for command parsing | +| `envelopeFrom` | Pre-rendered sender label for the envelope | +| `senderLabel` | Optional override for the rendered sender | +| `preview` | Short redacted preview for logs | +| `inboundHistory` | Recent inbound history entries when the channel keeps a buffer | + +### SupplementalContextFacts + +Supplemental context covers quote, forwarded, and thread-bootstrap context. The kernel applies the configured `contextVisibility` policy. The channel adapter only provides facts and `senderAllowed` flags so cross-channel policy stays consistent. + +### InboundMediaFacts + +Media is fact-shaped. Platform download, auth, SSRF policy, CDN rules, and decryption stay channel-local. The kernel maps facts into `MediaPath`, `MediaUrl`, `MediaType`, `MediaPaths`, `MediaUrls`, `MediaTypes`, and `MediaTranscribedIndexes`. + +## Adapter contract + +For full `run`, the adapter shape is: + +```typescript +type ChannelTurnAdapter = { + ingest(raw: TRaw): Promise | NormalizedTurnInput | null; + classify?(input: NormalizedTurnInput): Promise | ChannelEventClass; + preflight?( + input: NormalizedTurnInput, + eventClass: ChannelEventClass, + ): Promise; + resolveTurn( + input: NormalizedTurnInput, + eventClass: ChannelEventClass, + preflight: PreflightFacts, + ): Promise | ChannelTurnResolved; + onFinalize?(result: ChannelTurnResult): Promise | void; +}; +``` + +`resolveTurn` returns a `ChannelTurnResolved`, which is an `AssembledChannelTurn` with an optional admission kind. Returning `{ admission: { kind: "observeOnly" } }` runs the turn without producing visible output. The adapter still owns the delivery callback; it just becomes a no-op for that turn. + +`onFinalize` runs on every result, including dispatch errors. Use it to clear pending group history, remove ack reactions, stop status indicators, and flush local state. + +## Delivery adapter + +The kernel does not call the platform directly. The channel hands the kernel a `ChannelTurnDeliveryAdapter`: + +```typescript +type ChannelTurnDeliveryAdapter = { + deliver(payload: ReplyPayload, info: ChannelDeliveryInfo): Promise; + onError?(err: unknown, info: { kind: string }): void; +}; + +type ChannelDeliveryResult = { + messageIds?: string[]; + threadId?: string; + replyToId?: string; + visibleReplySent?: boolean; +}; +``` + +`deliver` is called once per buffered reply chunk. Return platform message ids when the channel has them so the dispatcher can preserve thread anchors and edit later chunks. For observe-only turns, return `{ visibleReplySent: false }` or use `createNoopChannelTurnDeliveryAdapter()`. + +## Record options + +The record stage wraps `recordInboundSession`. Most channels can use the defaults. Override via `record`: + +```typescript +record: { + groupResolution, + createIfMissing: true, + updateLastRoute, + onRecordError: (err) => log.warn("record failed", err), + trackSessionMetaTask: (task) => pendingTasks.push(task), +} +``` + +The dispatcher waits for the record stage. If record throws, the kernel runs `onPreDispatchFailure` (when provided to `runPrepared`) and rethrows. + +## Observability + +Each stage emits a structured event when a `log` callback is supplied: + +```typescript +await runtime.channel.turn.run({ + channel: "twitch", + accountId, + raw, + adapter, + log: (event) => { + runtime.log?.debug?.(`turn.${event.stage}:${event.event}`, { + channel: event.channel, + accountId: event.accountId, + messageId: event.messageId, + sessionKey: event.sessionKey, + admission: event.admission, + reason: event.reason, + }); + }, +}); +``` + +Logged stages: `ingest`, `classify`, `preflight`, `resolve`, `authorize`, `assemble`, `record`, `dispatch`, `finalize`. Avoid logging raw bodies; use `MessageFacts.preview` for short redacted previews. + +## What stays channel-local + +The kernel owns orchestration. The channel still owns: + +- Platform transports (gateway, REST, websocket, polling, webhooks) +- Identity resolution and display-name matching +- Native commands, slash commands, autocomplete, modals, buttons, voice state +- Card, modal, and adaptive-card rendering +- Media auth, CDN rules, encrypted media, transcription +- Edit, reaction, redaction, and presence APIs +- Backfill and platform-side history fetch +- Pairing flows that require platform-specific verification + +If two channels start needing the same helper for one of these, extract a shared SDK helper instead of pushing it into the kernel. + +## Stability + +`runtime.channel.turn.*` is part of the public plugin runtime surface. The fact types (`SenderFacts`, `ConversationFacts`, `RouteFacts`, `ReplyPlanFacts`, `AccessFacts`, `MessageFacts`, `SupplementalContextFacts`, `InboundMediaFacts`) and admission shapes (`ChannelTurnAdmission`, `ChannelEventClass`) are reachable through `PluginRuntime` from `openclaw/plugin-sdk/core`. + +Backward compatibility rules apply: new fact fields are additive, admission kinds are not renamed, and the entry point names stay stable. New channel needs that require a non-additive change must go through the plugin SDK migration process. + +## Related + +- [Building channel plugins](/plugins/sdk-channel-plugins) for the broader channel plugin contract +- [Plugin runtime helpers](/plugins/sdk-runtime) for other `runtime.*` surfaces +- [Plugin internals](/plugins/architecture-internals) for load pipeline and registry mechanics