docs/channels/whatsapp.md
Status: production-ready via WhatsApp Web (Baileys). Gateway owns linked session(s).
openclaw onboard) and openclaw channels add --channel whatsapp
prompt to install the WhatsApp plugin the first time you select it.openclaw channels login --channel whatsapp also offers the install flow when
the plugin is not present yet.@openclaw/whatsapp on the current official
release tag.Manual install stays available:
openclaw plugins install @openclaw/whatsapp
Use the bare package to follow the current official release tag. Pin an exact version only when you need a reproducible install.
On Windows, the WhatsApp plugin needs Git on PATH during npm install because
one of its Baileys/libsignal dependencies is fetched from a git URL. Install
Git for Windows, then restart the shell and rerun the install:
winget install --id Git.Git -e
Portable Git also works if its bin directory is on PATH.
{
channels: {
whatsapp: {
dmPolicy: "pairing",
allowFrom: ["+15551234567"],
groupPolicy: "allowlist",
groupAllowFrom: ["+15551234567"],
},
},
}
openclaw channels login --channel whatsapp
For a specific account:
openclaw channels login --channel whatsapp --account work
To attach an existing/custom WhatsApp Web auth directory before login:
openclaw channels add --channel whatsapp --account work --auth-dir /path/to/wa-auth
openclaw channels login --channel whatsapp --account work
openclaw gateway
openclaw pairing list whatsapp
openclaw pairing approve whatsapp <CODE>
Pairing requests expire after 1 hour. Pending requests are capped at 3 per channel.
- separate WhatsApp identity for OpenClaw
- clearer DM allowlists and routing boundaries
- lower chance of self-chat confusion
Minimal policy pattern:
```json5
{
channels: {
whatsapp: {
dmPolicy: "allowlist",
allowFrom: ["+15551234567"],
},
},
}
```
- `dmPolicy: "allowlist"`
- `allowFrom` includes your personal number
- `selfChatMode: true`
In runtime, self-chat protections key off the linked self number and `allowFrom`.
There is no separate Twilio WhatsApp messaging channel in the built-in chat-channel registry.
web.whatsapp.*: keepAliveIntervalMs controls WhatsApp Web application pings, connectTimeoutMs controls the opening handshake timeout, and defaultQueryTimeoutMs controls Baileys query timeouts.@+<digits> and @<digits> tokens in text and media captions when the token matches current WhatsApp participant metadata, including LID-backed groups.@status, @broadcast).session.dmScope; default main collapses DMs to the agent main session).agent:<agentId>:whatsapp:group:<jid>).@newsletter JID. Outbound newsletter sends use channel session metadata (agent:<agentId>:whatsapp:channel:<jid>) rather than DM session semantics.HTTPS_PROXY, HTTP_PROXY, NO_PROXY / lowercase variants). Prefer host-level proxy config over channel-specific WhatsApp proxy settings.messages.removeAckAfterReply is enabled, OpenClaw clears the WhatsApp ack reaction after a visible reply is delivered.WhatsApp inbound messages can contain personal message content, phone numbers,
group identifiers, sender names, and session correlation fields. For that reason,
WhatsApp does not broadcast inbound message_received hook payloads to plugins
unless you explicitly opt in:
{
channels: {
whatsapp: {
pluginHooks: {
messageReceived: true,
},
},
},
}
You can scope the opt-in to one account:
{
channels: {
whatsapp: {
accounts: {
work: {
pluginHooks: {
messageReceived: true,
},
},
},
},
},
}
Only enable this for plugins you trust to receive inbound WhatsApp message content and identifiers.
- `pairing` (default)
- `allowlist`
- `open` (requires `allowFrom` to include `"*"`)
- `disabled`
`allowFrom` accepts E.164-style numbers (normalized internally).
`allowFrom` is a DM sender access-control list. It does not gate explicit outbound sends to WhatsApp group JIDs or `@newsletter` channel JIDs.
Multi-account override: `channels.whatsapp.accounts.<id>.dmPolicy` (and `allowFrom`) take precedence over channel-level defaults for that account.
Runtime behavior details:
- pairings are persisted in channel allow-store and merged with configured `allowFrom`
- scheduled automation and heartbeat recipient fallback use explicit delivery targets or configured `allowFrom`; DM pairing approvals are not implicit cron or heartbeat recipients
- if no allowlist is configured, the linked self number is allowed by default
- OpenClaw never auto-pairs outbound `fromMe` DMs (messages you send to yourself from the linked device)
1. **Group membership allowlist** (`channels.whatsapp.groups`)
- if `groups` is omitted, all groups are eligible
- if `groups` is present, it acts as a group allowlist (`"*"` allowed)
2. **Group sender policy** (`channels.whatsapp.groupPolicy` + `groupAllowFrom`)
- `open`: sender allowlist bypassed
- `allowlist`: sender must match `groupAllowFrom` (or `*`)
- `disabled`: block all group inbound
Sender allowlist fallback:
- if `groupAllowFrom` is unset, runtime falls back to `allowFrom` when available
- sender allowlists are evaluated before mention/reply activation
Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is `allowlist` (with a warning log), even if `channels.defaults.groupPolicy` is set.
Mention detection includes:
- explicit WhatsApp mentions of the bot identity
- configured mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- inbound voice-note transcripts for authorized group messages
- implicit reply-to-bot detection (reply sender matches bot identity)
Security note:
- quote/reply only satisfies mention gating; it does **not** grant sender authorization
- with `groupPolicy: "allowlist"`, non-allowlisted senders are still blocked even if they reply to an allowlisted user's message
Session-level activation command:
- `/activation mention`
- `/activation always`
`activation` updates session state (not global config). It is owner-gated.
When the linked self number is also present in allowFrom, WhatsApp self-chat safeguards activate:
messages.responsePrefix is unset, self-chat replies default to [{identity.name}] or [openclaw]If a quoted reply exists, context is appended in this form:
```text
[Replying to <sender> id:<stanzaId>]
<quoted body or media placeholder>
[/Replying]
```
Reply metadata fields are also populated when available (`ReplyToId`, `ReplyToBody`, `ReplyToSender`, sender JID/E.164).
When the quoted reply target is downloadable media, OpenClaw saves it through
the normal inbound media store and exposes it as `MediaPath`/`MediaType` so
the agent can inspect the referenced image instead of only seeing
`<media:image>`.
- `<media:image>`
- `<media:video>`
- `<media:audio>`
- `<media:document>`
- `<media:sticker>`
Authorized group voice notes are transcribed before mention gating when the
body is only `<media:audio>`, so saying the bot mention in the voice note can
trigger the reply. If the transcript still does not mention the bot, the
transcript is kept in pending group history instead of the raw placeholder.
Location bodies use terse coordinate text. Location labels/comments and contact/vCard details are rendered as fenced untrusted metadata, not inline prompt text.
- default limit: `50`
- config: `channels.whatsapp.historyLimit`
- fallback: `messages.groupChat.historyLimit`
- `0` disables
Injection markers:
- `[Chat messages since your last reply - for context]`
- `[Current message - respond to this]`
Disable globally:
```json5
{
channels: {
whatsapp: {
sendReadReceipts: false,
},
},
}
```
Per-account override:
```json5
{
channels: {
whatsapp: {
accounts: {
work: {
sendReadReceipts: false,
},
},
},
},
}
```
Self-chat turns skip read receipts even when globally enabled.
WhatsApp supports native reply quoting, where outbound replies visibly quote the inbound message. Control it with channels.whatsapp.replyToMode.
| Value | Behavior |
|---|---|
"off" | Never quote; send as a plain message |
"first" | Quote only the first outbound reply chunk |
"all" | Quote every outbound reply chunk |
"batched" | Quote queued batched replies while leaving immediate replies unquoted |
Default is "off". Per-account overrides use channels.whatsapp.accounts.<id>.replyToMode.
{
channels: {
whatsapp: {
replyToMode: "first",
},
},
}
channels.whatsapp.reactionLevel controls how broadly the agent uses emoji reactions on WhatsApp:
| Level | Ack reactions | Agent-initiated reactions | Description |
|---|---|---|---|
"off" | No | No | No reactions at all |
"ack" | Yes | No | Ack reactions only (pre-reply receipt) |
"minimal" | Yes | Yes (conservative) | Ack + agent reactions with conservative guidance |
"extensive" | Yes | Yes (encouraged) | Ack + agent reactions with encouraged guidance |
Default: "minimal".
Per-account overrides use channels.whatsapp.accounts.<id>.reactionLevel.
{
channels: {
whatsapp: {
reactionLevel: "ack",
},
},
}
WhatsApp supports immediate ack reactions on inbound receipt via channels.whatsapp.ackReaction.
Ack reactions are gated by reactionLevel — they are suppressed when reactionLevel is "off".
{
channels: {
whatsapp: {
ackReaction: {
emoji: "👀",
direct: true,
group: "mentions", // always | mentions | never
},
},
},
}
Behavior notes:
mentions reacts on mention-triggered turns; group activation always acts as bypass for this checkchannels.whatsapp.ackReaction (legacy messages.ackReaction is not used here)When a Gateway is reachable, logout first stops the live WhatsApp listener for the selected account so the linked session does not keep receiving messages until the next restart. `openclaw channels remove --channel whatsapp` also stops the live listener before disabling or deleting account config.
In legacy auth directories, `oauth.json` is preserved while Baileys auth files are removed.
react).channels.whatsapp.actions.reactionschannels.whatsapp.actions.pollschannels.whatsapp.configWrites=false).Fix:
```bash
openclaw channels login --channel whatsapp
openclaw channels status
```
Quiet accounts can stay connected past the normal message timeout; the watchdog
restarts when WhatsApp Web transport activity stops, the socket closes, or
application-level activity stays silent beyond the longer safety window.
If logs show repeated `status=408 Request Time-out Connection was lost`, tune
Baileys socket timings under `web.whatsapp`. Start by shortening
`keepAliveIntervalMs` below your network's idle timeout and increasing
`connectTimeoutMs` on slow or lossy links:
```json5
{
web: {
whatsapp: {
keepAliveIntervalMs: 15000,
connectTimeoutMs: 60000,
defaultQueryTimeoutMs: 60000,
},
},
}
```
Fix:
```bash
openclaw doctor
openclaw logs --follow
```
If `~/.openclaw/logs/whatsapp-health.log` says `Gateway inactive` but
`openclaw gateway status` and `openclaw channels status --probe` show the
gateway and WhatsApp are healthy, run `openclaw doctor`. On Linux, doctor
warns about legacy crontab entries that still invoke
`~/.openclaw/bin/ensure-whatsapp.sh`; remove those stale entries with
`crontab -e` because cron can lack the systemd user-bus environment and
make that old script misreport gateway health.
If needed, re-link with `channels login`.
WhatsApp Web login uses the gateway host's standard proxy environment (`HTTPS_PROXY`, `HTTP_PROXY`, lowercase variants, and `NO_PROXY`). Verify the gateway process inherits the proxy env and that `NO_PROXY` does not match `mmg.whatsapp.net`.
Make sure gateway is running and the account is linked.
Ack reactions are independent pre-reply receipts. A successful reaction does not prove that the later text or media reply was accepted by WhatsApp.
Check gateway logs for `auto-reply delivery failed` or `auto-reply was not accepted by WhatsApp provider`.
- `groupPolicy`
- `groupAllowFrom` / `allowFrom`
- `groups` allowlist entries
- mention gating (`requireMention` + mention patterns)
- duplicate keys in `openclaw.json` (JSON5): later entries override earlier ones, so keep a single `groupPolicy` per scope
WhatsApp supports Telegram-style system prompts for groups and direct chats via the groups and direct maps.
Resolution hierarchy for group messages:
The effective groups map is determined first: if the account defines its own groups, it fully replaces the root groups map (no deep merge). Prompt lookup then runs on the resulting single map:
groups["<groupId>"].systemPrompt): used when the specific group entry exists in the map and its systemPrompt key is defined. If systemPrompt is an empty string (""), the wildcard is suppressed and no system prompt is applied.groups["*"].systemPrompt): used when the specific group entry is absent from the map entirely, or when it exists but defines no systemPrompt key.Resolution hierarchy for direct messages:
The effective direct map is determined first: if the account defines its own direct, it fully replaces the root direct map (no deep merge). Prompt lookup then runs on the resulting single map:
direct["<peerId>"].systemPrompt): used when the specific peer entry exists in the map and its systemPrompt key is defined. If systemPrompt is an empty string (""), the wildcard is suppressed and no system prompt is applied.direct["*"].systemPrompt): used when the specific peer entry is absent from the map entirely, or when it exists but defines no systemPrompt key.Difference from Telegram multi-account behavior: In Telegram, root groups is intentionally suppressed for all accounts in a multi-account setup — even accounts that define no groups of their own — to prevent a bot from receiving group messages for groups it does not belong to. WhatsApp does not apply this guard: root groups and root direct are always inherited by accounts that define no account-level override, regardless of how many accounts are configured. In a multi-account WhatsApp setup, if you want per-account group or direct prompts, define the full map under each account explicitly rather than relying on root-level defaults.
Important behavior:
channels.whatsapp.groups is both a per-group config map and the chat-level group allowlist. At either the root or account scope, groups["*"] means "all groups are admitted" for that scope.systemPrompt when you already want that scope to admit all groups. If you still want only a fixed set of group IDs to be eligible, do not use groups["*"] for the prompt default. Instead, repeat the prompt on each explicitly allowlisted group entry.groups["*"] widens the set of groups that can reach group handling, but it does not by itself authorize every sender in those groups. Sender access is still controlled separately by channels.whatsapp.groupPolicy and channels.whatsapp.groupAllowFrom.channels.whatsapp.direct does not have the same side effect for DMs. direct["*"] only provides a default direct-chat config after a DM is already admitted by dmPolicy plus allowFrom or pairing-store rules.Example:
{
channels: {
whatsapp: {
groups: {
// Use only if all groups should be admitted at the root scope.
// Applies to all accounts that do not define their own groups map.
"*": { systemPrompt: "Default prompt for all groups." },
},
direct: {
// Applies to all accounts that do not define their own direct map.
"*": { systemPrompt: "Default prompt for all direct chats." },
},
accounts: {
work: {
groups: {
// This account defines its own groups, so root groups are fully
// replaced. To keep a wildcard, define "*" explicitly here too.
"[email protected]": {
requireMention: false,
systemPrompt: "Focus on project management.",
},
// Use only if all groups should be admitted in this account.
"*": { systemPrompt: "Default prompt for work groups." },
},
direct: {
// This account defines its own direct map, so root direct entries are
// fully replaced. To keep a wildcard, define "*" explicitly here too.
"+15551234567": { systemPrompt: "Prompt for a specific work direct chat." },
"*": { systemPrompt: "Default prompt for work direct chats." },
},
},
},
},
},
}
Primary reference:
High-signal WhatsApp fields:
dmPolicy, allowFrom, groupPolicy, groupAllowFrom, groupstextChunkLimit, chunkMode, mediaMaxMb, sendReadReceipts, ackReaction, reactionLevelaccounts.<id>.enabled, accounts.<id>.authDir, account-level overridesconfigWrites, debounceMs, web.enabled, web.heartbeatSeconds, web.reconnect.*, web.whatsapp.*session.dmScope, historyLimit, dmHistoryLimit, dms.<id>.historyLimitgroups.<id>.systemPrompt, groups["*"].systemPrompt, direct.<id>.systemPrompt, direct["*"].systemPrompt