Summary
In self-chat mode (selfChatMode: true), messages sent via agent tools (e.g., the message tool during heartbeat/cron jobs) bypass the echo tracker (createEchoTracker). When WhatsApp echoes these messages back as inbound events, they pass access control and trigger a new agent turn, causing double-reply loops.
Environment
- OpenClaw 2026.2.23
- WhatsApp channel, self-chat mode (same phone number for user and bot)
- Cron jobs with
delivery.mode: "announce"
Steps to reproduce
- Configure self-chat mode:
channels.whatsapp.selfChatMode: true with allowFrom containing the bot's own phone number
- Set up a cron job (e.g., hourly email check) with
sessionTarget: "isolated" and delivery.mode: "announce"
- Wait for the cron job to fire and produce output that gets delivered to WhatsApp
- Observe the gateway logs
What happens
- Cron job runs in isolated session, agent generates a response
- Response is delivered to WhatsApp via
sock.sendMessage() (outbound)
- WhatsApp echoes the message back as an incoming event with
fromMe: true
checkInboundAccessControl allows it because isFromMe && isSamePhone is not blocked (line ~419 in channel-web-DHXuNMKT.js)
echoTracker.has(msg.body) returns false — the text was never registered
- Message is processed as new inbound → agent responds → loop
Log evidence (timestamps condensed):
18:17:46 — outbound: "Sent message 3EB003A1... -> sha256:0eb78bb3..."
18:17:48 — inbound: "Inbound message +40740243812 -> +40740243812 (direct, 188 chars)"
18:17:48 — processed: "[Ovo] 🦉 Notat. E doar notificare standard..."
Root cause
createEchoTracker (source: src/web/auto-reply/monitor/echo.ts) maintains a Set of recently sent texts via rememberText(). This is called from the deliver callback inside dispatchReplyWithBufferedBlockDispatcher (the auto-reply flow).
However, messages sent through other code paths — specifically the message tool handler and the cron announce delivery — call sock.sendMessage() directly without registering the text in the echo tracker.
Relevant code locations
createEchoTracker: channel-web-DHXuNMKT.js ~line 945
rememberSentText (only caller): channel-web-DHXuNMKT.js ~line 1673, inside deliver callback
- Echo check on inbound:
channel-web-DHXuNMKT.js ~line 1748
- Access control gap:
channel-web-DHXuNMKT.js ~line 419 (isFromMe && !isSamePhone — allows same-phone fromMe)
Expected behavior
All outbound WhatsApp messages — whether sent via auto-reply, tool calls, or cron delivery — should be registered in the echo tracker before sending. When the echo returns, it should be detected and dropped.
Possible fixes
- Centralize outbound tracking: Register all texts in the echo tracker at the
sendMessageWhatsApp level (in outbound-*.js), so every outbound path is covered
- Filter
isFromMe in self-chat DMs: Add an option to skip fromMe messages even when isSamePhone is true, since the bot should never need to process its own outbound echoes
- Prefix-based filtering: In self-chat mode, skip inbound messages that start with the configured
responsePrefix (e.g., [Ovo])
Current workaround
Added an anti-self-reply instruction in SOUL.md (system prompt) that tells the LLM to detect and skip its own echoed messages based on the [Ovo] prefix. This works at the LLM level but is a soft guard — it depends on model compliance and wastes tokens on the echo processing turn.
Summary
In self-chat mode (
selfChatMode: true), messages sent via agent tools (e.g., themessagetool during heartbeat/cron jobs) bypass the echo tracker (createEchoTracker). When WhatsApp echoes these messages back as inbound events, they pass access control and trigger a new agent turn, causing double-reply loops.Environment
delivery.mode: "announce"Steps to reproduce
channels.whatsapp.selfChatMode: truewithallowFromcontaining the bot's own phone numbersessionTarget: "isolated"anddelivery.mode: "announce"What happens
sock.sendMessage()(outbound)fromMe: truecheckInboundAccessControlallows it becauseisFromMe && isSamePhoneis not blocked (line ~419 inchannel-web-DHXuNMKT.js)echoTracker.has(msg.body)returnsfalse— the text was never registeredLog evidence (timestamps condensed):
Root cause
createEchoTracker(source:src/web/auto-reply/monitor/echo.ts) maintains aSetof recently sent texts viarememberText(). This is called from thedelivercallback insidedispatchReplyWithBufferedBlockDispatcher(the auto-reply flow).However, messages sent through other code paths — specifically the
messagetool handler and the cronannouncedelivery — callsock.sendMessage()directly without registering the text in the echo tracker.Relevant code locations
createEchoTracker:channel-web-DHXuNMKT.js~line 945rememberSentText(only caller):channel-web-DHXuNMKT.js~line 1673, insidedelivercallbackchannel-web-DHXuNMKT.js~line 1748channel-web-DHXuNMKT.js~line 419 (isFromMe && !isSamePhone— allows same-phonefromMe)Expected behavior
All outbound WhatsApp messages — whether sent via auto-reply, tool calls, or cron delivery — should be registered in the echo tracker before sending. When the echo returns, it should be detected and dropped.
Possible fixes
sendMessageWhatsApplevel (inoutbound-*.js), so every outbound path is coveredisFromMein self-chat DMs: Add an option to skipfromMemessages even whenisSamePhoneis true, since the bot should never need to process its own outbound echoesresponsePrefix(e.g.,[Ovo])Current workaround
Added an anti-self-reply instruction in SOUL.md (system prompt) that tells the LLM to detect and skip its own echoed messages based on the
[Ovo]prefix. This works at the LLM level but is a soft guard — it depends on model compliance and wastes tokens on the echo processing turn.