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