Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.openclaw.ai/llms.txt

Use this file to discover all available pages before exploring further.

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. assembleFinalizedMsgContext 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.

Admission kinds

The kernel does not throw when a turn is gated. It returns a ChannelTurnAdmission:
KindWhen
dispatchTurn is admitted. Agent turn runs and the visible reply path is exercised.
observeOnlyTurn runs end-to-end but the delivery adapter sends nothing visible. Used for broadcast observer agents and other passive multi-agent flows.
handledA platform event was consumed locally (lifecycle, reaction, button, modal). Kernel skips dispatch.
dropSkip 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.
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:
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<TRaw>. The adapter has callbacks for ingest, optional classify, optional preflight, mandatory resolveTurn, and optional onFinalize.
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.
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.
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

FieldPurpose
idStable message id used for dedupe and logs
timestampOptional epoch ms
rawTextBody as received from the platform
textForAgentOptional cleaned body for the agent (mention strip, typing trim)
textForCommandsOptional body used for /command parsing
rawOptional pass-through reference for adapter callbacks that need the original

ChannelEventClass

FieldPurpose
kindmessage, command, interaction, reaction, lifecycle, unknown
canStartAgentTurnIf false the kernel returns { kind: "handled" }
requiresImmediateAckHint for adapters that need to ACK before dispatch

SenderFacts

FieldPurpose
idStable platform sender id
nameDisplay name
usernameHandle if distinct from name
tagDiscord-style discriminator or platform tag
rolesRole ids, used for member-role allowlist matching
isBotTrue when the sender is a known bot (kernel uses for dropping)
isSelfTrue when the sender is the configured agent itself
displayLabelPre-rendered label for envelope text

ConversationFacts

FieldPurpose
kinddirect, group, or channel
idConversation id used for routing
labelHuman label for the envelope
spaceIdOptional outer space identifier (Slack workspace, Matrix homeserver)
parentIdOuter conversation id when this is a thread
threadIdThread id when this message is inside a thread
nativeChannelIdPlatform-native channel id when different from the routing id
routePeerPeer used for resolveAgentRoute lookup

RouteFacts

FieldPurpose
agentIdAgent that should handle this turn
accountIdOptional override (multi-account channels)
routeSessionKeySession key used for routing
dispatchSessionKeySession key used at dispatch when different from route key
persistedSessionKeySession key written to persisted session metadata
parentSessionKeyParent for branched/threaded sessions
modelParentSessionKeyModel-side parent for branched sessions
mainSessionKeyMain DM owner pin for direct conversations
createIfMissingAllow record step to create a missing session row

ReplyPlanFacts

FieldPurpose
toLogical reply target written into context To
originatingToOriginating context target (OriginatingTo)
nativeChannelIdPlatform-native channel id for delivery
replyTargetFinal visible-reply destination if it differs from to
deliveryTargetLower-level delivery override
replyToIdQuoted/anchored message id
replyToIdFullFull-form quoted id when the platform has both
messageThreadIdThread id at delivery time
threadParentIdParent message id of the thread
sourceReplyDeliveryModethread, 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.
FieldPurpose
dmDM allow/pairing/deny decision and allowFrom list
groupGroup policy, route allow, sender allow, allowlist, mention requirement
commandsCommand authorization across configured authorizers
mentionsWhether mention detection is possible and whether the agent was mentioned

MessageFacts

FieldPurpose
bodyFinal envelope body (formatted)
rawBodyRaw inbound body
bodyForAgentBody the agent sees
commandBodyBody used for command parsing
envelopeFromPre-rendered sender label for the envelope
senderLabelOptional override for the rendered sender
previewShort redacted preview for logs
inboundHistoryRecent 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:
type ChannelTurnAdapter<TRaw> = {
  ingest(raw: TRaw): Promise<NormalizedTurnInput | null> | NormalizedTurnInput | null;
  classify?(input: NormalizedTurnInput): Promise<ChannelEventClass> | ChannelEventClass;
  preflight?(
    input: NormalizedTurnInput,
    eventClass: ChannelEventClass,
  ): Promise<PreflightFacts | ChannelTurnAdmission | null | undefined>;
  resolveTurn(
    input: NormalizedTurnInput,
    eventClass: ChannelEventClass,
    preflight: PreflightFacts,
  ): Promise<ChannelTurnResolved> | ChannelTurnResolved;
  onFinalize?(result: ChannelTurnResult): Promise<void> | 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:
type ChannelTurnDeliveryAdapter = {
  deliver(payload: ReplyPayload, info: ChannelDeliveryInfo): Promise<ChannelDeliveryResult | void>;
  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:
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:
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.