Skip to content

feat(channels): add suppressOutbound config for listen-only mode#28761

Open
amitmiran137 wants to merge 38 commits intoopenclaw:mainfrom
amitmiran137:feat_listern_only
Open

feat(channels): add suppressOutbound config for listen-only mode#28761
amitmiran137 wants to merge 38 commits intoopenclaw:mainfrom
amitmiran137:feat_listern_only

Conversation

@amitmiran137
Copy link
Copy Markdown

@amitmiran137 amitmiran137 commented Feb 27, 2026

Summary

Adds a suppressOutbound boolean config flag that blocks all outbound communication for a given channel or account while keeping inbound message processing fully functional. This enables safe "listen-only" deployments where the gateway ingests and processes messages without sending anything back.

Resolves #22791

Motivation

Users running OpenClaw alongside existing support agents or in monitoring/audit scenarios need a way to observe conversations without the gateway sending any replies, reactions, typing indicators, or status updates. Previously, the only option was to disconnect the channel entirely, losing inbound visibility.

Configuration

The flag can be set at the account level (highest priority) or the channel level:

# Channel-level — suppresses ALL accounts on that channel
channels:
  telegram:
    suppressOutbound: true
# Account-level — suppresses only the specified account
whatsapp:
  accounts:
    - id: main-account
      suppressOutbound: true

Resolution order: account-level wins over channel-level when both are set.

Per-account control (multi-instance setups)

In deployments with multiple numbers or bot accounts on the same channel, each account can be independently configured. For example, to monitor your personal WhatsApp number while keeping the agent number active:

whatsapp:
  accounts:
    - id: "+1555000PERSONAL"
      suppressOutbound: true      # listen-only: reads messages, no replies
    - id: "+1555000AGENT"
      suppressOutbound: false     # fully active: sends replies normally

The same pattern works for any multi-instance channel:

# Telegram: suppress a personal bot, keep the support bot active
telegram:
  accounts:
    - id: personal-bot
      suppressOutbound: true
    - id: support-bot
      suppressOutbound: false

# Discord: suppress a monitoring bot, keep the main bot active
discord:
  accounts:
    - id: monitor-bot
      suppressOutbound: true
    - id: main-bot
      suppressOutbound: false

# Signal: suppress a personal number
signal:
  accounts:
    - id: "+1555000PERSONAL"
      suppressOutbound: true

You can also set channel-level suppression as the default and then override specific accounts:

channels:
  whatsapp:
    suppressOutbound: true        # default: all WhatsApp accounts are listen-only

whatsapp:
  accounts:
    - id: "+1555000AGENT"
      suppressOutbound: false     # override: this one account can reply

What is blocked vs. allowed

Category Blocked (outbound) Allowed (inbound)
Delivery Reply messages, media, documents Receiving messages
Reactions Ack reactions, status reactions (thinking/done/error) Reading reactions
Typing Typing indicators, composing updates
Streaming Draft previews, native streaming, reasoning previews
Pairing Pairing reply messages Pairing request state is still recorded
Presence WhatsApp connect "available" presence update
Read receipts WhatsApp + Signal read receipts (direct and daemon)
Agent tools Outbound tool actions (send, reply, react) Read-only tool actions (search, read, list)
Gateway API send/poll delivery returns explicit error All other API endpoints
Native commands Slash/native command replies, auth messages, menus Command invocation is still processed
Delivery queue Queued entries deferred (no retry count, no drop) Entries preserved for delivery when lifted
Thread tracking Suppressed replies do not record thread participation Prevents unsolicited replies after unblock

Channel coverage

Feature WhatsApp Telegram Discord Slack Signal iMessage
Reply delivery
Pairing replies suppressed
Pairing state preserved
Ack reactions
Status reactions
Typing indicators
Component typing (interactions)
Answer draft/streaming previews
Reasoning draft previews
Connect presence ("available")
Read receipts (direct)
Read receipts (daemon mode)
Auto-reply delivery
Native/slash command replies
Slash respond() paths (all)
Slash empty-prompt ack
Auth/error command messages
Command menu sends
Model picker replies
DM component auth/pairing
Delivery queue deferral
Agent tool action gating
Thread participation safety
Config type (suppressOutbound)
Zod schema validation

= guard implemented. = not applicable for that channel.

Architecture

Core helper

src/infra/outbound/suppress-outbound.ts — single isOutboundSuppressed({ cfg, channel, accountId }) function used by all guard sites. Resolution: account-level → channel-level → false. Account lookup is case-insensitive.

Guard layers

  1. Transport leveldeliverOutboundPayloads throws when suppressed (preserves pending delivery queue entries for retry once suppression is lifted).
  2. Gateway APIsend/poll handlers return INVALID_REQUEST early.
  3. Agent actionsrunMessageAction blocks outbound tool calls but exempts read-only actions via SUPPRESS_EXEMPT_ACTIONS.
  4. Channel dispatch — each channel's reply delivery, pairing, streaming, typing, reaction, and read receipt paths are individually guarded.
  5. Daemon-level — Signal daemon is spawned with sendReadReceipts: false when suppressed, preventing read receipts at the process level.
  6. Native commands — Discord native/slash command replies, Slack slash command responses (including all respond() paths), and Telegram auth/error/menu messages are all guarded.
  7. Delivery queue recoveryrecoverPendingDeliveries recognizes [suppressOutbound] errors as deferred, skipping retry count increments and preserving entries for delivery when suppression is lifted.

Config & types

  • Zod schemas: suppressOutbound: z.boolean().optional() added to all account schemas and ReplyRuntimeConfigSchemaShape.
  • TypeScript types: suppressOutbound?: boolean added to CommonChannelMessagingConfig, WhatsAppSharedConfig, TelegramAccountConfig, DiscordAccountConfig, SlackAccountConfig, SignalAccountConfig (via common), IMessageAccountConfig, MSTeamsConfig, GoogleChatConfig.

Tests

Test file Coverage
suppress-outbound.test.ts Core resolution logic (account vs channel precedence, case-insensitive lookup)
deliver.test.ts Transport-level throw on suppression, queue entry preservation
send.test.ts Gateway send/poll rejection with INVALID_REQUEST
message-action-runner.test.ts Agent tool action gating, SUPPRESS_EXEMPT_ACTIONS bypass
access-control.test.ts WhatsApp pairing state preserved while reply blocked
ack-reaction.test.ts WhatsApp ack reaction blocked
process-message.inbound-contract.test.ts WhatsApp auto-reply and composing blocked
delivery.suppress-outbound.test.ts Telegram reply delivery blocked/allowed
dm-access.suppress-outbound.test.ts Telegram pairing suppressed, state preserved
bot-message-context.suppress-outbound.test.ts Telegram ack reaction and status controller null when suppressed
bot-message-dispatch.test.ts Telegram draft streaming and status reactions disabled when suppressed
reply-delivery.suppress-outbound.test.ts Discord reply delivery blocked/allowed
message-handler.process.test.ts Discord ack reactions, status reactions, and draft streaming disabled when suppressed
replies.suppress-outbound.test.ts Slack reply delivery blocked/allowed with delivered return value, slash command responses blocked/allowed
prepare.test.ts Slack ack reaction promise null when suppressed
event-handler.suppress-outbound.test.ts Signal read receipts, typing indicators, and pairing replies blocked/allowed
deliver.suppress-outbound.test.ts iMessage reply delivery blocked/allowed

Files changed

Click to expand file list

Config & types

  • src/config/types.channel-messaging-common.ts — add suppressOutbound to common config
  • src/config/types.discord.ts — add suppressOutbound to Discord config
  • src/config/types.googlechat.ts — add suppressOutbound to Google Chat config
  • src/config/types.imessage.ts — add suppressOutbound to iMessage config
  • src/config/types.msteams.ts — add suppressOutbound to MS Teams config
  • src/config/types.slack.ts — add suppressOutbound to Slack config
  • src/config/types.telegram.ts — add suppressOutbound to Telegram config
  • src/config/types.whatsapp.ts — add suppressOutbound to WhatsApp config
  • src/config/zod-schema.core.ts — add suppressOutbound to reply runtime schema
  • src/config/zod-schema.providers-core.ts — add suppressOutbound to all account schemas
  • src/config/zod-schema.providers-whatsapp.ts — add suppressOutbound to WhatsApp schema

Core infra

  • src/infra/outbound/suppress-outbound.ts — central isOutboundSuppressed() helper (new)
  • src/infra/outbound/deliver.ts — throw on suppression in deliverOutboundPayloads
  • src/infra/outbound/delivery-queue.ts — defer suppressed entries during recovery (no retry increment)
  • src/infra/outbound/message-action-runner.ts — agent tool action gating with exempt list
  • src/gateway/server-methods/send.ts — gateway send/poll rejection

WhatsApp

  • src/web/inbound/access-control.ts — pairing reply guard, state preservation
  • src/web/auto-reply/monitor/process-message.ts — auto-reply and composing guard
  • src/web/auto-reply/monitor/ack-reaction.ts — ack reaction guard
  • src/web/inbound/monitor.ts — connect presence + read receipt guard

Telegram

  • src/telegram/bot/delivery.ts — reply delivery guard
  • src/telegram/dm-access.ts — pairing reply guard
  • src/telegram/bot-message-context.ts — ack reaction and status reaction guard
  • src/telegram/bot-message-dispatch.ts — typing, status reactions, answer/reasoning streaming guard
  • src/telegram/bot-handlers.ts — pass cfg to enforceTelegramDmAccess
  • src/telegram/bot-native-commands.ts — auth/error/menu command reply guard

Discord

  • src/discord/monitor/reply-delivery.ts — reply delivery guard
  • src/discord/monitor/message-handler.preflight.ts — pairing reply guard
  • src/discord/monitor/message-handler.process.ts — typing, status reactions, draft streaming guard
  • src/discord/monitor/agent-components.ts — component typing guard, DM auth/pairing reply guard
  • src/discord/monitor/native-command.ts — native/slash command reply guard, command menu guard, model picker guard

Slack

  • src/slack/monitor/replies.ts — reply delivery + slash command response guard
  • src/slack/monitor/message-handler/dispatch.ts — typing, streaming guard
  • src/slack/monitor/message-handler/prepare.ts — ack reaction guard + DM pairing reply guard
  • src/slack/monitor/slash.ts — suppress all respond() paths + pass cfg/accountId to slash reply delivery

Signal

  • src/signal/monitor.ts — reply delivery guard, daemon read receipt suppression
  • src/signal/monitor/event-handler.ts — pairing reply, typing, read receipt guard

iMessage

  • src/imessage/monitor/deliver.ts — reply delivery guard
  • src/imessage/monitor/monitor-provider.ts — pairing reply guard

@openclaw-barnacle openclaw-barnacle bot added channel: whatsapp-web Channel integration: whatsapp-web gateway Gateway runtime size: M labels Feb 27, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 27, 2026

Greptile Summary

Adds two new configuration options for listen-only/suppress-outbound channel mode:

What works well:

  • suppressOutbound config successfully blocks all outbound delivery at the transport layer (tests in deliver.test.ts and send.test.ts verify this)
  • Proper precedence: account-level suppressOutbound correctly overrides channel-level config
  • listen-only group policy added to type system with schema validation across all providers
  • Comprehensive test coverage (23 new/updated tests)
  • Gateway poll sends correctly blocked when suppressed

Key issue:

  • The listenOnly flag is set in access-control results but not used anywhere to suppress outbound. This creates a gap between what the PR description implies (groupPolicy: "listen-only" suppressing groups but not DMs) and what actually happens (only suppressOutbound: true blocks outbound, and it blocks everything).

Impact:
Users following the "Group policy approach" example will set groupPolicy: "listen-only" expecting group messages to be suppressed while DMs remain active, but this won't work without also setting suppressOutbound: true (which blocks both groups and DMs).

Confidence Score: 3/5

  • Safe to merge with clarification needed on feature completeness
  • The suppressOutbound feature is correctly implemented and well-tested, but the listen-only group policy only sets a flag without actually suppressing outbound for groups. This creates a gap between the PR description's implied behavior and actual functionality. The code quality is solid with no bugs, but users may be confused by the config examples that suggest groupPolicy: "listen-only" alone will work.
  • src/web/inbound/access-control.ts - the listenOnly flag needs to be connected to outbound suppression logic, or the PR description should be updated to clarify that both groupPolicy and suppressOutbound configs are needed

Last reviewed commit: ea013c6

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

16 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ea013c6804

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@openclaw-barnacle openclaw-barnacle bot added channel: discord Channel integration: discord channel: matrix Channel integration: matrix channel: msteams Channel integration: msteams channel: signal Channel integration: signal channel: slack Channel integration: slack channel: telegram Channel integration: telegram channel: zalouser Channel integration: zalouser channel: feishu Channel integration: feishu channel: irc size: L size: M and removed size: M channel: discord Channel integration: discord channel: matrix Channel integration: matrix channel: msteams Channel integration: msteams channel: signal Channel integration: signal channel: slack Channel integration: slack channel: telegram Channel integration: telegram channel: whatsapp-web Channel integration: whatsapp-web channel: zalouser Channel integration: zalouser channel: feishu Channel integration: feishu channel: irc size: L labels Feb 27, 2026
@amitmiran137 amitmiran137 changed the title feat(channels): add listen-only mode and suppressOutbound config feat(channels): add suppressOutbound config for listen-only mode Feb 27, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a46630922a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +32 to +36
if (params.accountId) {
const accountSuppressed = resolveAccountEntry(
providerConfig.accounts,
params.accountId,
)?.suppressOutbound;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve effective account before checking suppressOutbound

This check only applies account-level suppression when accountId is explicitly provided, so requests that omit accountId can bypass accounts.<id>.suppressOutbound even though downstream channel send paths resolve to a default account (for example, API/CLI sends that rely on default account fallback). In that configuration, a listen-only account can still send outbound messages unless channel-level suppression is also set, which breaks the advertised account-level override behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 01f68970e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1238 to +1242
const discordCommandSuppressed = isOutboundSuppressed({ cfg, channel: "discord", accountId });

const respond = async (content: string, options?: { ephemeral?: boolean }) => {
if (discordCommandSuppressed) {
logVerbose("[suppressOutbound] Blocked Discord native-command respond");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress Discord picker/button interaction responses too

This guard only applies inside dispatchDiscordCommandInteraction, but native-command component handlers still emit outbound writes directly (for example handleDiscordModelPickerInteraction uses interaction.update(...)/interaction.followUp(...) when users click model-picker controls). In channels.discord.suppressOutbound=true setups, clicking an existing picker message still sends Discord responses, so listen-only mode is bypassed for this interaction flow.

Useful? React with 👍 / 👎.

Comment on lines +294 to +302
const slashSuppressed = isOutboundSuppressed({
cfg,
channel: "slack",
accountId: account.accountId,
});
const respond: typeof p.respond = slashSuppressed
? async () => {
logVerbose("[suppressOutbound] Blocked Slack slash respond");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply slash suppression outside handleSlashCommand path

The new slashSuppressed gating only wraps handleSlashCommand responses, but slash arg-action handling still sends ephemeral replies before entering that helper (for stale/foreign button payloads via respondFn/chat.postEphemeral). With channels.slack.suppressOutbound=true, those arg-menu error paths still produce outbound Slack messages, so suppression is incomplete for interactive slash workflows.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2c66cc368c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +790 to +794
if (
!dryRun &&
!SUPPRESS_EXEMPT_ACTIONS.has(action) &&
isOutboundSuppressed({ cfg, channel, accountId })
) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail fast on suppressOutbound before attachment hydration

The suppression guard is evaluated only after hydrateAttachmentParamsForAction(...) and target resolution run, so suppressed send/sendAttachment actions still execute expensive side effects (including loadWebMedia fetches in src/infra/outbound/message-action-params.ts) before being rejected. In listen-only deployments this means requests that are guaranteed to fail can still read local files or make outbound HTTP/media fetches, adding avoidable latency and network egress despite suppressOutbound being enabled.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8d5121783a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 1472 to +1475
if (pickerCommandContext) {
if (discordCommandSuppressed) {
logVerbose("[suppressOutbound] Blocked Discord model picker");
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard model-picker component handlers under suppressOutbound

This new guard only blocks the initial command path, but channels.discord.suppressOutbound=true is still bypassed when users interact with existing /model or command-arg components: handleDiscordModelPickerInteraction and handleDiscordCommandArgInteraction in the same file continue to call interaction.update(...)/interaction.followUp(...) without any suppression check, so outbound Discord writes still occur in listen-only mode. Fresh evidence is that these component handlers are invoked directly from the fallback button/select classes and do not pass through this guarded branch.

Useful? React with 👍 / 👎.

@openclaw-barnacle
Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle bot added the stale Marked as stale due to inactivity label Apr 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: discord Channel integration: discord channel: imessage Channel integration: imessage channel: signal Channel integration: signal channel: slack Channel integration: slack channel: telegram Channel integration: telegram channel: whatsapp-web Channel integration: whatsapp-web gateway Gateway runtime size: XL stale Marked as stale due to inactivity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: Listen-only / suppress-outbound channel mode for WhatsApp

2 participants