Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save kevinslin/4282d3b6f0ddedd0d351307e8fc36272 to your computer and use it in GitHub Desktop.

Select an option

Save kevinslin/4282d3b6f0ddedd0d351307e8fc36272 to your computer and use it in GitHub Desktop.
Approval Forwarding And Native Delivery Flow
created 2026-05-24
updated 2026-05-26
last_updated_session codex/current
source_commit 68145258676f
schema_route code-core/flow/approval-forwarding-native-delivery
tags
canonical

Approval Forwarding And Native Delivery Flow

Overview

This flow documents how OpenClaw forwards an exec or plugin approval request to a chat channel, how that shared forwarding path intersects with channel-native approval delivery, and what decides whether the shared fallback message is suppressed.

The most important ownership boundary is:

  • Gateway approval records are the source of truth for pending, resolved, and expired approvals.
  • The shared approval forwarder is a generic chat-delivery path driven by top-level approvals.exec and approvals.plugin.
  • Native channel delivery is a separate runtime path driven by a channel approvalCapability.nativeRuntime.
  • delivery.shouldSuppressForwardingFallback only removes duplicate targets from the shared forwarder. It does not deliver native prompts by itself.
  • Local source-reply /approve prompt suppression is another separate path: it decides whether the exec tool-result payload should be emitted back to the initiating chat after native delivery already owns that surface.
  • /approve <id> <decision> remains the text resolver/manual fallback. It is not the same thing as shared fallback delivery.

Entry Points

Approval forwarding starts after an approval request has already been accepted and registered with the gateway approval manager.

  • src/gateway/server-methods/exec-approval.ts:exec.approval.request
  • src/gateway/server-methods/plugin-approval.ts:plugin.approval.request
  • src/infra/exec-approval-forwarder.ts:createApprovalHandlers

For model-initiated shell commands, the exec approval request is produced by the bash-tools exec host before the shared forwarder or native runtime gets a chance to deliver anything:

  • src/agents/bash-tools.exec-host-gateway.ts
  • src/agents/bash-tools.exec-host-node.ts
  • src/agents/bash-tools.exec-approval-request.ts
  • src/agents/bash-tools.exec-host-shared.ts

Supporting paths covered in this flow include src/gateway/server-methods/approval-shared.ts, src/infra/approval-handler-runtime.ts, src/infra/exec-approval-channel-runtime.ts, src/infra/approval-native-runtime.ts, and src/auto-reply/reply/commands-approve.ts. Local source-reply suppression is covered by src/auto-reply/reply/dispatch-from-config.ts and src/channels/plugins/exec-approval-local.ts.

Terms

Approval Record

An in-memory gateway record owned by the approval manager. It has an id, a request payload, creation/expiry timestamps, and eventually a decision.

Exec approval ids are normal approval ids. Plugin approval ids are server-generated and prefixed with plugin: so /approve routing can choose the plugin resolver deterministically.

Shared Fallback Message

A message sent by src/infra/exec-approval-forwarder.ts through sendDurableMessageBatch. It is configured by top-level approvals.exec or approvals.plugin.

The message is "shared" because the same forwarder handles all configured message channels. It is "fallback" because it is not the channel's native approval runtime. It may still use a channel-specific renderer from approvalCapability.render; if no renderer is present, the forwarder builds a generic text payload such as:

Reply with: /approve <id> allow-once|allow-always|deny

Native Delivery

A channel-owned approval runtime that listens to gateway approval events and delivers channel-specific UI: buttons, reactions, DMs, origin-channel messages, message updates, deletion, or cleanup. It is configured through approvalCapability.nativeRuntime and approvalCapability.native.

Shared Forwarding Suppression

Shared forwarding suppression is a per-target decision made only by the shared forwarder. If a channel returns true from delivery.shouldSuppressForwardingFallback(...), that one shared fallback target is removed before sendDurableMessageBatch runs.

Shared forwarding suppression does not:

  • approve the request;
  • send the native prompt;
  • hide the approval from Control UI, TUI, or other operator clients;
  • disable /approve;
  • suppress different channels or different targets.

Local Source-Reply Prompt Suppression

Local source-reply prompt suppression is the exec-only decision made when the agent loop receives the bash-tools approval-pending tool result. The reply dispatcher asks the source channel's outbound hook whether to drop that local /approve prompt payload before it is emitted back into the originating chat.

Sequence Diagram

graph TD
  ModelExec["Model calls exec tool"] --> BashTools["bash-tools exec host checks policy"]
  BashTools --> ExecRequest["exec.approval.request"]
  PluginRequest["plugin.approval.request"] --> Register["Gateway registers approval record"]
  ExecRequest --> Register
  Register --> Broadcast["Broadcast exec/plugin.approval.requested to operator clients"]
  Register --> SharedStart["Call shared approval forwarder"]
  Register --> NativeEvent["Native approval runtimes receive gateway event"]
  Register -->|exec only| ExecToolResult["bash-tools returns approval-pending tool result"]

  subgraph SharedForwarder["Shared approval forwarder"]
    SharedStart --> FamilyConfig{"approvals.exec/plugin enabled and filters match?"}
    FamilyConfig -->|no| NoSharedTargets["No shared forwarding targets"]
    FamilyConfig -->|yes| ResolveTargets["Resolve session, targets, or both"]
    ResolveTargets --> DedupeTargets["Dedupe channel/to/accountId/threadId"]
    DedupeTargets --> SuppressEach["For each target: shouldSuppressForwardingFallback?"]
    SuppressEach -->|true| DropTarget["Drop that shared fallback target"]
    SuppressEach -->|false| KeepTarget["Keep shared fallback target"]
    KeepTarget --> RenderFallback["Use channel renderer or generic fallback text"]
    RenderFallback --> SendBatch["sendDurableMessageBatch"]
    DropTarget --> EmptyAfterFilter{"Any shared targets left?"}
    EmptyAfterFilter -->|no| SharedFalse["Forwarder returns false"]
    EmptyAfterFilter -->|yes| RenderFallback
  end

  subgraph NativeRuntime["Channel native approval runtime"]
    NativeEvent --> EventKind{"Runtime handles this event kind?"}
    EventKind -->|no| NativeIgnore["Native runtime ignores request"]
    EventKind -->|yes| NativeConfigured{"Runtime configured and shouldHandle(request)?"}
    NativeConfigured -->|no| NativeSkip["No native prompt"]
    NativeConfigured -->|yes| BuildNative["Build native approval view/payload"]
    BuildNative --> PlanNative["Resolve origin and approver-DM targets"]
    PlanNative --> DeliverNative["Channel native transport delivers prompt"]
    DeliverNative --> BindNative["Bind native buttons/reactions/message ids"]
  end

  subgraph SourceReply["Local source-reply exec prompt path"]
    ExecToolResult --> ToolResultHandler["dispatchReplyFromConfig onToolResult"]
    ToolResultHandler --> LocalSuppressor["shouldSuppressLocalExecApprovalPrompt builds approval-pending hint"]
    LocalSuppressor --> ChannelSuppressor["channel outbound shouldSuppressLocalPayloadPrompt"]
    ChannelSuppressor --> SourceSuppressed{"channel hook suppresses local prompt?"}
    SourceSuppressed -->|yes| DropSourcePrompt["drop local /approve prompt payload"]
    SourceSuppressed -->|no| EmitSourcePrompt
  end

  Broadcast --> NoRouteCheck{"Any operator client, shared delivery, or turn-source route?"}
  SharedFalse --> NoRouteCheck
  SendBatch --> NoRouteCheck
  NoSharedTargets --> NoRouteCheck
  EmitSourcePrompt --> WaitDecision
  DropSourcePrompt --> WaitDecision
  NoRouteCheck -->|no| ExpireNoRoute["Expire approval as no-approval-route"]
  NoRouteCheck -->|yes| WaitDecision["Wait for exec/plugin approval decision"]
Loading

Execution Trace

1. Register And Broadcast Approval

1.1 Exec requests register an approval record

  • src/gateway/server-methods/exec-approval.ts:exec.approval.request
record = manager.create(request, timeoutMs)
decisionPromise = manager.register(record, timeoutMs)
requestEvent = { id: record.id, request: record.request, createdAtMs, expiresAtMs }

handlePendingApprovalRequest({
  requestEventName: "exec.approval.requested",
  approvalKind: "exec",
  deliverRequest: () => forwarder.handleRequested(requestEvent),
})

1.2 Plugin requests register a prefixed approval record

  • src/gateway/server-methods/plugin-approval.ts:plugin.approval.request
record = manager.create(request, timeoutMs, `plugin:${randomUUID()}`)
decisionPromise = manager.register(record, timeoutMs)
requestEvent = { id: record.id, request: record.request, createdAtMs, expiresAtMs }

handlePendingApprovalRequest({
  requestEventName: "plugin.approval.requested",
  approvalKind: "plugin",
  deliverRequest: () => forwarder.handlePluginApprovalRequested(requestEvent),
})

2. Resolve Shared Forwarding Targets

2.1 Family config and filters decide whether shared forwarding applies

  • src/infra/exec-approval-forwarder.ts:shouldForwardRoute
if (!config?.enabled) return false

return matchesApprovalRequestFilters({
  request: routeRequest,
  agentFilter: config.agentFilter,
  sessionFilter: config.sessionFilter,
  fallbackAgentIdFromSessionKey: true,
})

2.2 Mode resolves session and explicit targets

  • src/infra/exec-approval-forwarder.ts:resolveForwardTargets
if (mode === "session" || mode === "both") {
  addDedupe(resolveSessionTarget(routeRequest), { source: "session" })
}

if (mode === "targets" || mode === "both") {
  for (target of config.targets ?? []) {
    addDedupe(target, { source: "target" })
  }
}

3. Suppress Duplicate Shared Fallback Targets

3.1 The forwarder asks the channel hook for each target

  • src/infra/exec-approval-forwarder.ts:shouldSkipForwardingFallback
channel = normalizeMessageChannel(target.channel)
adapter = resolveChannelApprovalAdapter(getLoadedChannelPlugin(channel))

return adapter?.delivery?.shouldSuppressForwardingFallback?.({
  cfg,
  approvalKind,
  target,
  request: buildSyntheticApprovalRequest(routeRequest),
}) ?? false

3.2 The common native helper suppresses only channel-local native targets

  • src/plugin-sdk/approval-delivery-helpers.ts:createApproverRestrictedNativeApprovalCapability
if (normalizeMessageChannel(input.target.channel) !== configuredChannel) {
  return false
}

if (requireMatchingTurnSourceChannel && turnSourceChannel !== configuredChannel) {
  return false
}

return isNativeDeliveryEnabled({ cfg: input.cfg, accountId })

4. Send Remaining Shared Fallback Messages

4.1 Renderer-specific payloads are preferred over generic text

  • src/infra/exec-approval-forwarder.ts:buildApprovalRenderPayload
adapterPayload = resolveRenderer(channelApprovalAdapter)?.(renderParams)

return adapterPayload ?? buildFallback()

4.2 Durable channel delivery receives only unsuppressed targets

  • src/infra/exec-approval-forwarder.ts:deliverToTargets
sendDurableMessageBatch({
  cfg,
  channel,
  to: target.to,
  accountId: target.accountId,
  threadId: target.threadId,
  payloads: [payload],
})

5. Native Runtime Delivers Separately

5.1 Native runtime subscribes to configured event families

  • src/infra/exec-approval-channel-runtime.ts:createExecApprovalChannelRuntime
eventKinds = new Set(adapter.eventKinds ?? ["exec"])

if (event === "exec.approval.requested" && eventKinds.has("exec")) {
  handleRequested(payload)
}
if (event === "plugin.approval.requested" && eventKinds.has("plugin")) {
  handleRequested(payload)
}

5.2 Native delivery plan resolves origin and approver-DM surfaces

  • src/infra/approval-native-runtime.ts:deliverRequested
pendingContent = buildPendingContent({ request, approvalKind, nowMs })
deliveryResult = deliverApprovalRequestViaChannelNativePlan({
  cfg,
  accountId,
  approvalKind,
  request,
  adapter: nativeAdapter,
  prepareTarget,
  deliverTarget,
})

6. Resolve And Fan Out Final State

6.1 Manual /approve and native actions resolve the same gateway record

  • src/auto-reply/reply/commands-approve.ts:handleApproveCommand
method = approvalId.startsWith("plugin:")
  ? "plugin.approval.resolve"
  : "exec.approval.resolve"

resolveApprovalOverGateway({ approvalId, decision, resolveMethod: method })

6.2 Follow-up messages go only to targets that received pending prompts

  • src/infra/exec-approval-forwarder.ts:handleResolved
entry = pending.get(resolved.id)
targets = entry?.targets ?? resolveForwardTargetsFromResolvedEvent()

deliverToTargets({
  targets,
  buildPayload: target => buildResolvedPayload({ resolved, target }),
})

Bash-Tools Exec Approval Producer Flow

The shared forwarder and native runtime are not the producers of exec approval requests. For normal agent turns, the producer is the model-visible exec tool, implemented by the bash-tools exec host.

1. The Model Calls exec

When a user asks an agent to run a shell command, the model calls the exec tool. That tool is implemented by the bash-tools stack, even when the initiating surface is a chat channel such as Signal, WhatsApp, Slack, or Telegram.

The relevant host path depends on where the command will run:

  • gateway-host exec: src/agents/bash-tools.exec-host-gateway.ts;
  • node-host exec: src/agents/bash-tools.exec-host-node.ts.

This is why seeing bash-tools in an approval trace does not mean the channel fallback path ran. It means the agent attempted a shell command and exec policy required approval.

2. Bash Tools Registers The Gateway Approval Before Returning

When exec policy requires approval, bash-tools creates an approval id and calls exec.approval.request through src/agents/bash-tools.exec-approval-request.ts.

That registration is intentionally two-phase: the gateway approval record must exist before the tool returns approval-pending, otherwise a fast /approve reply or native reaction could arrive before the gateway can resolve it.

The registered request carries route metadata such as:

  • agentId;
  • sessionKey;
  • turnSourceChannel;
  • turnSourceTo;
  • turnSourceAccountId;
  • turnSourceThreadId.

Those fields are what allow the shared forwarder and native runtime to derive session-origin delivery targets later.

3. Gateway Registration Fans Out To Shared And Native Consumers

After exec.approval.request registers the record, the normal gateway approval flow begins:

bash-tools exec host
  -> exec.approval.request
  -> gateway approval manager record
  -> exec.approval.requested event
  -> shared approval forwarder
  -> channel native approval runtimes

At this point, shared fallback delivery and native delivery are still parallel consumers of the same record. Bash-tools does not choose whether a Slack, WhatsApp, Signal, or Telegram prompt is native or shared. It only creates the pending exec approval and records the initiating route.

4. Bash Tools Also Returns An Agent-Facing Pending Tool Result

After registration, bash-tools returns an approval-pending tool result to the agent loop.

  • src/agents/bash-tools.exec-host-shared.ts:buildExecApprovalPendingToolResult
  • src/agents/bash-tools.exec-runtime.ts:buildApprovalPendingMessage

That tool result is not the shared forwarding fallback. It is the model/tool result for the blocked exec call. It includes manual approval text such as:

Reply with: /approve <id> allow-once|allow-always|deny

The agent runner can also turn that tool result into a deterministic source reply through src/agents/pi-embedded-subscribe.handlers.tools.ts. That local source-reply path is separate from:

  • shared fallback forwarding through src/infra/exec-approval-forwarder.ts;
  • native channel delivery through src/infra/approval-native-runtime.ts.

5. Source Reply May Emit Or Suppress The Local Approve Prompt

delivery.shouldSuppressForwardingFallback(...) suppresses only shared forwarder targets. It does not suppress the bash-tools approval-pending tool result, and it does not suppress deterministic source replies generated from that tool result.

Therefore a native channel can still duplicate an approval prompt if all of these are true:

  1. the model calls exec;
  2. bash-tools returns approval-pending;
  3. native delivery succeeds in the originating chat;
  4. the local tool-result/source-reply path also emits the pending approval payload.

This duplicate is not a shared fallback suppression failure. It is a local exec-tool-result delivery failure. The fix belongs in the source-reply/tool result delivery layer or in a channel outbound prompt-suppression hook. Core passes the exec approval payload plus a nativeRouteActive hint; each channel hook decides whether those facts, its config, filters, account, session, and route-safety rules are enough to hide the local prompt.

The current source-reply suppressor path is:

  • src/auto-reply/reply/dispatch-from-config.ts:resolveToolDeliveryPayload;
  • src/channels/plugins/exec-approval-local.ts:shouldSuppressLocalExecApprovalPrompt;
  • the current channel's outbound.shouldSuppressLocalPayloadPrompt(...) hook, for example Signal, Telegram, or Discord.
resolveToolDeliveryPayload(payload)
  if shouldSuppressLocalExecApprovalPrompt({ channel, cfg, accountId, payload })
    return null
  if shouldSendToolSummaries()
    return payload
  if payload.channelData.execApproval
    return payload

shouldSuppressLocalExecApprovalPrompt({ channel, cfg, accountId, payload })
  return channel.outbound.shouldSuppressLocalPayloadPrompt({
    cfg,
    accountId,
    payload,
    hint: {
      kind: "approval-pending",
      approvalKind: "exec",
      nativeRouteActive: hasActiveApprovalNativeRouteRuntime({
        channel,
        accountId,
        approvalKind: "exec",
      }),
    },
  })

If the channel hook returns true, the source-reply path drops the local /approve prompt payload. If the hook returns false, the payload remains visible even if shared forwarding fallback was suppressed elsewhere.

6. Synthetic Approval Tests Do Not Exercise Bash Tools

Synthetic commands such as claw-debug send-approval --type exec <channel> exercise the gateway approval/native delivery path directly. They do not make the model call exec, so they do not produce a bash-tools approval-pending tool result.

That means a synthetic native approval test can prove that native delivery, reaction binding, and /approve resolution work, but it does not prove that a real inbound agent-turn exec approval avoids duplicate local source prompts.

Detailed Shared Forwarding Flow

1. Gateway Registers The Request

Exec and plugin approvals both call handlePendingApprovalRequest after the record is registered.

For exec approvals:

record := manager.create(request, timeoutMs)
manager.register(record, timeoutMs)
requestEvent := { id, request, createdAtMs, expiresAtMs }
handlePendingApprovalRequest({
  requestEventName: "exec.approval.requested",
  approvalKind: "exec",
  deliverRequest: () => forwarder.handleRequested(requestEvent),
})

For plugin approvals:

record := manager.create(request, timeoutMs, `plugin:${randomUUID()}`)
manager.register(record, timeoutMs)
requestEvent := { id, request, createdAtMs, expiresAtMs }
handlePendingApprovalRequest({
  requestEventName: "plugin.approval.requested",
  approvalKind: "plugin",
  deliverRequest: () => forwarder.handlePluginApprovalRequested(requestEvent),
})

The gateway also broadcasts the approval event to connected operator clients such as Control UI, TUI, native approval runtimes, and other approval-capable clients.

2. The Shared Forwarder Chooses The Approval Family

The forwarder has separate strategies for exec and plugin requests:

  • exec reads cfg.approvals?.exec;
  • plugin reads cfg.approvals?.plugin.

The two families are independent. Enabling approvals.exec does not enable plugin forwarding, and enabling approvals.plugin does not enable exec forwarding.

Each strategy extracts route fields from the request:

  • agentId;
  • sessionKey;
  • turnSourceChannel;
  • turnSourceTo;
  • turnSourceAccountId;
  • turnSourceThreadId.

These fields are used for filters and for session-target resolution.

3. The Forwarder Applies Family Filters

The shared forwarder only considers targets if the family is enabled and the request matches agentFilter and sessionFilter.

shouldForwardRoute({ config, routeRequest })
  if !config.enabled
    return false
  return matchesApprovalRequestFilters({
    request: routeRequest,
    agentFilter: config.agentFilter,
    sessionFilter: config.sessionFilter,
    fallbackAgentIdFromSessionKey: true,
  })

If this returns false, the shared forwarder has no shared fallback target for that approval family.

4. The Forwarder Resolves Concrete Targets

Top-level approval forwarding supports three modes:

  • session: derive a destination from the approval's turn-source/session metadata.
  • targets: use only configured approvals.<family>.targets.
  • both: include the session-derived target and configured targets.

mode defaults to session when omitted.

Target identity is:

{
  channel,
  to,
  accountId?,
  threadId?,
  source: "session" | "target",
}

The forwarder dedupes targets by channel route key before suppression. If session and targets name the same channel/to/account/thread, only one shared fallback target remains.

5. The Forwarder Asks Each Target Channel About Suppression

For each candidate target, the forwarder calls:

shouldSkipForwardingFallback({
  approvalKind,
  target,
  cfg,
  routeRequest,
})

That function:

  1. normalizes target.channel;
  2. loads the channel plugin;
  3. resolves approvalCapability;
  4. builds a synthetic approval request from the route fields;
  5. calls adapter.delivery.shouldSuppressForwardingFallback(...);
  6. treats missing adapters or missing hooks as false.

Only targets whose hook returns true are removed.

6. The Forwarder Sends The Remaining Shared Fallback Messages

For every target not suppressed, the forwarder builds a pending payload:

  1. ask the channel renderer:
    • adapter.render.exec.buildPendingPayload, or
    • adapter.render.plugin.buildPendingPayload;
  2. if the renderer returns null or is absent, build the generic fallback message;
  3. call beforeDeliverPayload with an approval-pending hint;
  4. call sendDurableMessageBatch with channel, to, accountId, threadId, and the payload.

The shared forwarder also tracks the pending shared target list so it can send resolved and expired follow-up messages to the same fallback targets.

Native Delivery Flow

Native delivery is not called by the shared forwarder. It runs from gateway approval events.

1. Channel Startup Registers A Native Runtime

A channel with native approval support exposes:

  • approvalCapability.nativeRuntime: how to handle gateway events and build native pending/resolved/expired payloads.
  • approvalCapability.native: how to describe native delivery capabilities and resolve native targets.

During channel startup, startChannelApprovalHandlerBootstrap can create a native handler when both are available and the channel has runtime context.

2. Native Runtime Subscribes To Event Kinds

createExecApprovalChannelRuntime listens for:

  • exec.approval.requested;
  • plugin.approval.requested;
  • exec.approval.resolved;
  • plugin.approval.resolved.

The runtime only handles event families included in eventKinds. If eventKinds is omitted, the default is ["exec"]. A runtime that should handle plugin approvals must explicitly include "plugin".

3. Native Runtime Checks Eligibility

Before delivering, the native runtime calls:

nativeRuntime.availability.isConfigured(context)
nativeRuntime.availability.shouldHandle({ ...context, request })

Typical checks include:

  • channel/account enabled;
  • approval family route can reach this channel;
  • configured approvers exist;
  • request matches channel account;
  • request matches approval kind;
  • target origin or approver DM can be resolved.

4. Native Runtime Plans Delivery Targets

The native delivery planner asks approvalCapability.native for capabilities:

describeDeliveryCapabilities({
  cfg,
  accountId,
  approvalKind,
  request,
})

If enabled, it may resolve:

  • origin target: the chat/thread/contact where the request came from;
  • approver-DM targets: one or more private approver destinations.

preferredSurface decides whether origin, approver DMs, or both are planned. Targets are deduped by native target key.

5. Native Transport Delivers And Binds UI

The channel runtime builds the native pending content, prepares each target, delivers it through channel transport, and binds native interaction state.

Examples of bound native state:

  • Slack button action ids and message timestamps;
  • Telegram inline callback data and message ids;
  • WhatsApp or Signal reaction bindings;
  • Matrix message/event bindings.

When the approval resolves or expires, the runtime updates, clears, deletes, or unbinds native entries according to channel behavior.

Suppression Flow

Suppression exists only to avoid duplicate prompts when shared forwarding and native delivery would reach the same user-visible destination.

Generic Helper Suppression

Most native channels use createApproverRestrictedNativeApprovalCapability. Its suppression hook follows this shape:

shouldSuppressForwardingFallback(input)
  if normalizeMessageChannel(input.target.channel) != configuredChannel
    return false

  if requireMatchingTurnSourceChannel
    if normalizeMessageChannel(input.request.request.turnSourceChannel) != configuredChannel
      return false

  accountId := resolveSuppressionAccountId(input) ?? input.target.accountId
  return isNativeDeliveryEnabled({ cfg: input.cfg, accountId })

This means suppression is channel-local and account-aware. It is not a global "native is enabled somewhere" switch.

Exact Target Suppression

Some channels need stricter matching because native delivery is not described by one broad execApprovals.enabled switch.

WhatsApp, for example, computes the forwarding target, checks whether the target is eligible for the approval family, then compares it against the actual native origin target and approver-DM targets. It suppresses only if the shared fallback target matches one of the native targets.

That stricter pattern is the safer default for auth-only or reaction-based channels, because it prevents a generic target from disappearing unless the native runtime can really deliver to the same contact/chat.

Kind-Aware Suppression

Suppression must be approval-kind aware. A channel that can natively deliver exec approvals should not suppress plugin fallback delivery unless the native runtime also handles plugin approval events and can route the plugin request.

The historical Slack bug was this shape:

approvals.plugin.mode = both
Slack exec-native approvals enabled
shared plugin fallback target = Slack
suppression said "native Slack is enabled"
Slack native runtime did not actually handle plugin approvals
plugin prompt vanished

The durable rule is:

  • suppression must consider approvalKind;
  • native runtime eventKinds must include that kind;
  • native shouldHandle/capability checks must agree that the request can be delivered natively;
  • plugin ids must keep the plugin: prefix through native button/reaction handling.

Duplicate Prompt Cases

Native delivery can duplicate shared fallback delivery when both paths point at the same route.

Session-Origin Duplicate

Configuration:

approvals: {
  exec: { enabled: true, mode: "session" }
}

Request:

turnSourceChannel = "telegram"
turnSourceTo = "<topic-or-chat>"
turnSourceAccountId = "main"

If Telegram native delivery also posts in that origin chat/topic, the user sees:

  1. a native Telegram approval prompt with inline controls;
  2. a shared fallback prompt in the same chat saying /approve <id> ....

The fallback target should be suppressed for that exact Telegram account/topic.

Explicit Target Duplicate

Configuration:

approvals: {
  plugin: {
    enabled: true,
    mode: "targets",
    targets: [{ channel: "whatsapp", to: "+15551234567", accountId: "main" }]
  }
}

If WhatsApp native delivery resolves the approver DM target to the same +15551234567 on account main, the shared fallback target duplicates the native prompt. It should be suppressed.

Both-Mode Duplicate

Configuration:

approvals: {
  exec: {
    enabled: true,
    mode: "both",
    targets: [{ channel: "slack", to: "U123", accountId: "work" }]
  }
}

If the session route also resolves to Slack U123 on account work, the shared forwarder dedupes those two shared targets first. If Slack native delivery will also DM U123, the remaining shared target should be suppressed.

Non-Duplicate Cross-Channel Route

Configuration:

approvals: {
  exec: {
    enabled: true,
    mode: "targets",
    targets: [{ channel: "slack", to: "UOPS", accountId: "work" }]
  }
}

Request originates in Telegram and Telegram native delivery DMs the Telegram approver. The Slack operations target is not a duplicate. Suppressing it would remove an intentional out-of-band route.

Non-Duplicate Different Thread Or Account

Two routes on the same provider are not necessarily the same target:

  • Telegram account personal versus Telegram account work;
  • Slack channel message versus Slack DM;
  • Matrix room without a thread versus Matrix thread root event;
  • Discord channel versus Discord thread.

Suppression should compare the channel route identity, including account and thread/topic/root-message fields when the channel supports them.

Manual /approve Intersections

Manual /approve is independent of whether a shared fallback message was sent.

The command path:

  1. parses /approve <id> <decision>;
  2. asks the channel approvalCapability whether this actor may approve;
  3. falls back to normal command sender authorization when no explicit channel approval authorization is configured;
  4. routes plugin: ids to plugin.approval.resolve;
  5. routes other ids to exec.approval.resolve, with plugin fallback only when both authorizations allow it and the exec id is not found.

Therefore:

  • suppressing a shared fallback message does not disable /approve;
  • native button/reaction approval and /approve resolve the same gateway record;
  • authorization to see a prompt is not the same as authorization to resolve it.

No-Route Behavior

handlePendingApprovalRequest treats a route as available if at least one of these is true:

  • an operator approval client can see the record;
  • shared forwarding delivered or accepted at least one target;
  • a turn-source route exists for the approval kind.

If none are true, the gateway expires the approval as no-approval-route.

This is why suppression must be accurate. If a channel suppresses all shared fallback targets but its native runtime is not actually running or eligible, the shared forwarder returns false and no chat fallback message is sent. A separate operator client may still keep the request alive, but the intended chat route has been lost.

Channel Patterns

Helper-Based Native Channels

Slack, Discord, Telegram, and Matrix use the approver-restricted helper shape with channel-specific additions.

Useful properties:

  • authorizeActorAction: checks whether an actor may resolve an approval.
  • getActionAvailabilityState: tells same-chat /approve whether explicit approval authorization exists.
  • delivery.shouldSuppressForwardingFallback: removes duplicate shared fallback targets.
  • native.describeDeliveryCapabilities: tells the native planner what surfaces are available.
  • nativeRuntime.eventKinds: tells the native event listener whether this runtime handles exec, plugin, or both.

Exact-Route Reaction Channels

WhatsApp and Signal-style reaction flows need exact native-target matching. Their suppression should prove that the shared target matches the native origin or approver DM target before returning true.

This matters because auth-only/reaction surfaces often have no rich button contract. Suppression should follow the concrete message/contact binding rather than a broad "channel configured" boolean.

Implementation Checklist

When changing approval forwarding or adding a channel-native approval client:

  1. Verify the channel declares the right eventKinds.
  2. Verify shouldHandle agrees with the event kinds and approval family.
  3. Verify shouldSuppressForwardingFallback checks approvalKind.
  4. Verify suppression is account-aware.
  5. Verify suppression is target-aware when the channel can produce multiple native routes.
  6. Verify shared forwarding still works for different channels and explicit ops targets.
  7. Verify /approve still resolves manually when the user has authorization.
  8. Verify resolved and expired updates are delivered only to targets that actually received a pending prompt.
  9. Verify real inbound exec turns either keep or suppress the local source-reply /approve prompt according to the channel's native-route state.

Notes

  • Shared fallback delivery and native delivery are parallel consumers of the same gateway approval record. A shared fallback suppression bug should be debugged as routing loss, not as approval-manager state loss.
  • shouldSuppressForwardingFallback receives a synthetic request built from route metadata. If a channel needs full request details for target matching, it should resolve them from the real event in native shouldHandle and keep shared suppression conservative.
  • Approval target identity includes channel, to, optional accountId, and optional threadId. Channels with provider-native topics or roots must map those values consistently in both shared forwarding and native delivery.
  • For plugin approvals, preserving the plugin: id prefix through native button/reaction payloads is part of routing correctness.
  • Local source-reply /approve prompt suppression is exec-only because it consumes the bash-tools approval-pending tool-result payload. Plugin approvals do not pass through that local exec tool-result path.

Observability

Useful checks when a prompt does not appear or appears twice:

  • Confirm the approval event family: exec.approval.requested versus plugin.approval.requested.
  • Check whether the shared forwarder accepted the request. If all shared targets are suppressed, handleRequested can return false without calling sendDurableMessageBatch.
  • Check whether the native runtime is connected as an approval client and whether its eventKinds includes the request kind.
  • Check whether native shouldHandle returned false because account, approver, session, target, or approval-kind gates did not match.
  • Check whether shouldSuppressLocalExecApprovalPrompt dropped an exec tool-result/source-reply payload after the source channel evaluated the nativeRouteActive hint and its channel-specific checks.
  • Check whether the user is authorized to resolve the request. Delivery to a chat does not by itself authorize every viewer to approve.
  • For duplicate prompts, compare the shared target and native target by channel, destination, account, and thread/topic/root id.

Related docs

  • .mem/main/pkg/claw/ref/approval.md: approval config and command reference.
  • .mem/main/pkg/claw/flow/telegram-approval-fallback-suppression.md: Telegram-specific fallback suppression trace.
  • .mem/main/pkg/claw/flow/whatsapp-emoji-approval.md: WhatsApp emoji approval flow.
  • docs/tools/exec-approvals.md: public exec approval policy and flow docs.
  • docs/tools/slash-commands.md: /approve command reference.

Manual Notes

  • Shared fallback: approvals.exec/plugin targets go through src/infra/exec-approval-forwarder.ts, where shouldSuppressForwardingFallback filters fallback targets before sendDurableMessageBatch.
  • Native delivery: approval events go through src/infra/exec-approval-channel-runtime.ts and src/infra/approval-native-runtime.ts. That path does not ask shouldSuppressForwardingFallback before sending.
    • src/infra/approval-native-delivery.ts dedupes planned native targets by native target key.

Changelog

  • 2026-05-24: Created the cross-channel approval forwarding and native delivery flow doc.
  • 2026-05-24: Added how bash-tools exec approval requests enter the shared forwarding/native delivery flow and why their local pending tool result is a separate duplicate-prompt surface.
  • 2026-05-24: Added the bash-tools exec call and local pending tool-result surface to the Mermaid sequence diagram.
  • 2026-05-26 10:28: Added the local source-reply /approve prompt suppression branch to the execution flow and Mermaid diagram. (codex/current)
  • 2026-05-26 10:28: Split shared fallback suppression from local source-reply prompt suppression and corrected the diagram to show the channel hook as the decision point. (codex/current)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment