| 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 |
|
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.execandapprovals.plugin. - Native channel delivery is a separate runtime path driven by a channel
approvalCapability.nativeRuntime. delivery.shouldSuppressForwardingFallbackonly removes duplicate targets from the shared forwarder. It does not deliver native prompts by itself.- Local source-reply
/approveprompt 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.
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.
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.
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
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 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 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.
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"]
- 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),
})- 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),
})- src/infra/exec-approval-forwarder.ts:shouldForwardRoute
if (!config?.enabled) return false
return matchesApprovalRequestFilters({
request: routeRequest,
agentFilter: config.agentFilter,
sessionFilter: config.sessionFilter,
fallbackAgentIdFromSessionKey: true,
})- 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" })
}
}- 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- 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 })- src/infra/exec-approval-forwarder.ts:buildApprovalRenderPayload
adapterPayload = resolveRenderer(channelApprovalAdapter)?.(renderParams)
return adapterPayload ?? buildFallback()- src/infra/exec-approval-forwarder.ts:deliverToTargets
sendDurableMessageBatch({
cfg,
channel,
to: target.to,
accountId: target.accountId,
threadId: target.threadId,
payloads: [payload],
})- 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)
}- src/infra/approval-native-runtime.ts:deliverRequested
pendingContent = buildPendingContent({ request, approvalKind, nowMs })
deliveryResult = deliverApprovalRequestViaChannelNativePlan({
cfg,
accountId,
approvalKind,
request,
adapter: nativeAdapter,
prepareTarget,
deliverTarget,
})- src/auto-reply/reply/commands-approve.ts:handleApproveCommand
method = approvalId.startsWith("plugin:")
? "plugin.approval.resolve"
: "exec.approval.resolve"
resolveApprovalOverGateway({ approvalId, decision, resolveMethod: method })- src/infra/exec-approval-forwarder.ts:handleResolved
entry = pending.get(resolved.id)
targets = entry?.targets ?? resolveForwardTargetsFromResolvedEvent()
deliverToTargets({
targets,
buildPayload: target => buildResolvedPayload({ resolved, target }),
})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.
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.
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.
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.
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.
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:
- the model calls
exec; - bash-tools returns
approval-pending; - native delivery succeeds in the originating chat;
- 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.
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.
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.
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.
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.
Top-level approval forwarding supports three modes:
session: derive a destination from the approval's turn-source/session metadata.targets: use only configuredapprovals.<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.
For each candidate target, the forwarder calls:
shouldSkipForwardingFallback({
approvalKind,
target,
cfg,
routeRequest,
})That function:
- normalizes
target.channel; - loads the channel plugin;
- resolves
approvalCapability; - builds a synthetic approval request from the route fields;
- calls
adapter.delivery.shouldSuppressForwardingFallback(...); - treats missing adapters or missing hooks as
false.
Only targets whose hook returns true are removed.
For every target not suppressed, the forwarder builds a pending payload:
- ask the channel renderer:
adapter.render.exec.buildPendingPayload, oradapter.render.plugin.buildPendingPayload;
- if the renderer returns null or is absent, build the generic fallback message;
- call
beforeDeliverPayloadwith an approval-pending hint; - call
sendDurableMessageBatchwithchannel,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 is not called by the shared forwarder. It runs from gateway approval events.
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.
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".
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.
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.
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 exists only to avoid duplicate prompts when shared forwarding and native delivery would reach the same user-visible destination.
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.
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.
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
eventKindsmust 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.
Native delivery can duplicate shared fallback delivery when both paths point at the same route.
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:
- a native Telegram approval prompt with inline controls;
- a shared fallback prompt in the same chat saying
/approve <id> ....
The fallback target should be suppressed for that exact Telegram account/topic.
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.
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.
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.
Two routes on the same provider are not necessarily the same target:
- Telegram account
personalversus Telegram accountwork; - 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 is independent of whether a shared fallback message was sent.
The command path:
- parses
/approve <id> <decision>; - asks the channel
approvalCapabilitywhether this actor may approve; - falls back to normal command sender authorization when no explicit channel approval authorization is configured;
- routes
plugin:ids toplugin.approval.resolve; - 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
/approveresolve the same gateway record; - authorization to see a prompt is not the same as authorization to resolve it.
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.
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/approvewhether 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.
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.
When changing approval forwarding or adding a channel-native approval client:
- Verify the channel declares the right
eventKinds. - Verify
shouldHandleagrees with the event kinds and approval family. - Verify
shouldSuppressForwardingFallbackchecksapprovalKind. - Verify suppression is account-aware.
- Verify suppression is target-aware when the channel can produce multiple native routes.
- Verify shared forwarding still works for different channels and explicit ops targets.
- Verify
/approvestill resolves manually when the user has authorization. - Verify resolved and expired updates are delivered only to targets that actually received a pending prompt.
- Verify real inbound exec turns either keep or suppress the local
source-reply
/approveprompt according to the channel's native-route state.
- 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.
shouldSuppressForwardingFallbackreceives 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 nativeshouldHandleand keep shared suppression conservative.- Approval target identity includes
channel,to, optionalaccountId, and optionalthreadId. 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
/approveprompt suppression is exec-only because it consumes the bash-toolsapproval-pendingtool-result payload. Plugin approvals do not pass through that local exec tool-result path.
Useful checks when a prompt does not appear or appears twice:
- Confirm the approval event family:
exec.approval.requestedversusplugin.approval.requested. - Check whether the shared forwarder accepted the request. If all shared
targets are suppressed,
handleRequestedcan return false without callingsendDurableMessageBatch. - Check whether the native runtime is connected as an approval client and
whether its
eventKindsincludes the request kind. - Check whether native
shouldHandlereturned false because account, approver, session, target, or approval-kind gates did not match. - Check whether
shouldSuppressLocalExecApprovalPromptdropped an exec tool-result/source-reply payload after the source channel evaluated thenativeRouteActivehint 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.
.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:/approvecommand reference.
- 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.
- 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
/approveprompt 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)