Channel Ingress API: Inbound Message Authorization

Learn about the experimental channel ingress API for controlling inbound channel events. Core handles generic policy while plugins manage platform-specific details.

Read this when

  • Building or migrating a messaging channel plugin
  • Changing DM or group allowlists, route gates, command auth, event auth, or mention activation
  • Reviewing channel ingress redaction or SDK compatibility boundaries

Channel ingress defines the experimental boundary that controls access for inbound channel events. Plugins are responsible for platform-specific facts and side effects, while core handles generic policy: allowlists for DMs and groups, DM entries in the pairing store, route gates, command gates, event authentication, mention activation, redacted diagnostics, and admission.

For receive paths, use openclaw/plugin-sdk/channel-ingress-runtime.

Runtime resolver

import {
  defineStableChannelIngressIdentity,
  resolveChannelMessageIngress,
} from "openclaw/plugin-sdk/channel-ingress-runtime";

const identity = defineStableChannelIngressIdentity({
  key: "platform-user-id",
  normalize: normalizePlatformUserId,
  sensitivity: "pii",
});

const result = await resolveChannelMessageIngress({
  channelId: "my-channel",
  accountId,
  identity,
  subject: { stableId: platformUserId },
  conversation: { kind: isGroup ? "group" : "direct", id: conversationId },
  contextBinding: {
    agentId: agentRoute.agentId,
    sessionKey: agentRoute.sessionKey,
    messageId,
    inboundEventKind: "user_request",
  },
  event: { kind: "message", authMode: "inbound", mayPair: !isGroup },
  policy: {
    dmPolicy: config.dmPolicy,
    groupPolicy: config.groupPolicy,
    groupAllowFromFallbackToAllowFrom: true,
  },
  allowFrom: config.allowFrom,
  groupAllowFrom: config.groupAllowFrom,
  accessGroups: cfg.accessGroups,
  route,
  readStoreAllowFrom,
  command: hasControlCommand ? { allowTextCommands: true, hasControlCommand } : undefined,
});

const ctx = runtime.channel.inbound.buildContext({
  // Pass the exact host result; do not rebuild participant evidence from
  // SenderId, From, session keys, routes, rooms, or message metadata.
  channelIngress: result,
  // ...normalized channel facts
});

Avoid precomputing effective allowlists, command owners, or command groups. The resolver derives these from raw allowlists, store callbacks, route descriptors, access groups, policy, and conversation kind.

When a result is destined for a host context, resolve it after the channel's route owner picks the final agent and session. contextBinding locks those details with the stable transport message id (if available) and the final inbound event kind. Decision-only checks can skip this, but such a result lacks valid execution provenance and cannot be passed as channelIngress. If a channel batches multiple admitted messages, provide their exact results in source order; the finalized context message id points to the last source result.

Result

Bundled plugins should consume modern projections directly:

FieldMeaning
ingressordered gate decision and admission
senderAccesssender/conversation authorization only
routeAccessroute and route-sender projection
commandAccesscommand authorization; requested: false when no command gate ran
activationAccessmention/activation result

Event authorization remains available on the ordered ingress.graph and the decisive ingress.reasonCode; no separate event projection is emitted.

Deprecated third-party SDK helpers may internally rebuild older shapes. New bundled receive paths should not convert modern results back into local DTOs.

When execution-identity audit collection is active, a trusted active native plugin serves as the authoritative in-process producer of its remote participant fact. The host-injected registered runtime binds the resolver result to the exact plugin record and registry lifecycle epoch, then validates its complete available conversation, route, agent, session, message, event, and participant scope during a one-shot context handoff. The public standalone builder remains non-authoritative and cannot mint participant evidence. Queue collection retains attribution only when every contribution has valid evidence for the same participant; mixed, missing, stale, or unminted evidence is unknown. The carrier is opaque, bounded, one-shot, and diagnostic only. Plugins cannot mint participant evidence from caller-chosen sender, account, room, route, session, message, or transport fields. The SDK intentionally exposes no record, epoch, owner capability, participant-evidence constructor, or evidence copier. A structurally similar result, stale record, reused result, or scope-changed context does not gain host authority.

boundary-verified means core verified that the participant fact crossed this trusted active registered native-plugin boundary with the exact record, epoch, scope, and one-shot handoff. It does not mean core independently queried the remote service; only the channel plugin can observe that transport fact.

The audit states are distinct:

  • supported: the authoritative ingress resolver ran. Its exact result can yield a present invoker and enforced or attribution-only coverage.
  • unknown: a supported handoff was missing, stale, fake, reused, mixed, or otherwise failed host validation. Unknown never means allowed.
  • unsupported: a named path has no Phase 0 authoritative integration and explicitly passes channelIngress: "unsupported". Unsupported never means allowed and is not a shortcut for incomplete wiring.

Access groups

accessGroup:<name> entries stay redacted. Core resolves static message.senders groups itself and calls resolveAccessGroupMembership only for dynamic groups that require a platform lookup. Missing, unsupported, and failed groups fail closed.

Event modes

authModeMeaning
inboundnormal inbound sender gates
commandcommand gates for callbacks or scoped buttons
origin-subjectactor must match the original message subject
route-onlyroute gates only for route-scoped trusted events
noneplugin-owned internal events bypass shared auth

Use mayPair: false for reactions, buttons, callbacks, and native commands.

Routes and activation

Use route descriptors for room, topic, guild, thread, or nested route policy:

route: {
  id: "room",
  allowed: roomAllowed,
  enabled: roomEnabled,
  senderPolicy: "replace",
  senderAllowFrom: roomAllowFrom,
  blockReason: "room_sender_not_allowlisted",
}

Use channelIngressRoutes(...) when a plugin has several optional route descriptors; it filters disabled branches while keeping route facts generic and ordered by each descriptor's precedence.

Mention gating is an activation gate. A mention miss returns admission: "skip" so the turn kernel does not process an observe-only turn. Most channels should leave activation after sender and command gates. Public chat surfaces that must quiet non-mentioned traffic before sender allowlist noise can opt into activation.order: "before-sender" when text-command bypass is disabled. Channels with implicit activation, such as replies in bot threads, resolve channels.defaults.implicitMentions plus channel and account overrides with resolveChannelImplicitMentions(...), then pass the result as activation.implicitMentions. The projected activationAccess.shouldBypassMention reports when command or implicit activation bypassed an explicit mention.

Redaction

Raw sender values and raw allowlist entries are resolver input only. They must not appear in resolved state, decisions, diagnostics, snapshots, or compatibility facts. Use opaque subject ids, entry ids, route ids, and diagnostic ids.

Verification

pnpm test src/channels/message-access/message-access.test.ts src/plugin-sdk/channel-ingress-runtime.test.ts
pnpm plugin-sdk:api:diff --base "$(git merge-base origin/main HEAD)" --head HEAD
968 words · updated Aug 17, 2026