docs/channels/whatsapp.md
Status: production-ready via WhatsApp Web (Baileys). The gateway owns the linked session(s); there is no separate Twilio WhatsApp channel.
openclaw onboard and openclaw channels add --channel whatsapp prompt to install the plugin the first time you select it; openclaw channels login --channel whatsapp offers the same install flow if the plugin is missing. Dev checkouts use the local plugin path; stable/beta installs @openclaw/whatsapp from ClawHub first, falling back to npm. The WhatsApp runtime ships outside the core OpenClaw npm package, so its runtime dependencies stay with the external plugin. Manual install:
openclaw plugins install clawhub:@openclaw/whatsapp
Use the bare npm package (@openclaw/whatsapp) only for the registry fallback; pin an exact version only for a reproducible install.
{
channels: {
whatsapp: {
dmPolicy: "pairing",
allowFrom: ["+15551234567"],
groupPolicy: "allowlist",
groupAllowFrom: ["+15551234567"],
},
},
}
openclaw channels login --channel whatsapp
Login is QR-only. On remote or headless hosts, have a reliable path to deliver the live QR to the phone before starting login; terminal-rendered QRs, screenshots, or chat attachments can expire in transit.
For a specific account:
openclaw channels login --channel whatsapp --account work
To attach an existing/custom 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 account.
```json5
{
channels: {
whatsapp: {
dmPolicy: "allowlist",
allowFrom: ["+15551234567"],
},
},
}
```
web.whatsapp.*: keepAliveIntervalMs (application ping interval), connectTimeoutMs (opening handshake timeout), defaultQueryTimeoutMs (Baileys query waits, plus OpenClaw's outbound send/presence and inbound read-receipt timeouts).@+<digits> and @<digits> tokens (in text and media captions) when the token matches current participant metadata, including LID-backed groups.@status, @broadcast) are ignored.session.dmScope; default main collapses DMs into the agent main session). Group sessions are isolated per JID (agent:<agentId>:whatsapp:group:<jid>).@newsletter JID, using channel session metadata (agent:<agentId>:whatsapp:channel:<jid>) rather than DM semantics.HTTPS_PROXY, HTTP_PROXY, NO_PROXY, lowercase variants). Prefer host-level proxy config over per-channel settings.messages.removeAckAfterReply enabled, OpenClaw clears the ack reaction once a visible reply is delivered.The plugin can expose whatsapp_call in WhatsApp-originated agent turns. It uses MeowCaller to place a WhatsApp voice call to the current authorized requester and play an OpenClaw TTS message after they answer. The tool has no destination-number parameter, so a prompt cannot redirect the call. Disabled by default.
Add `actions.calls: true` to the WhatsApp channel config and restart the gateway:
{
"channels": {
"whatsapp": {
"actions": {
"calls": true
}
}
}
}
When absent or `false`, OpenClaw does not expose the `whatsapp_call` tool.
The adapter expects a `meowcaller` executable on the gateway host's `PATH`. Until [MeowCaller PR #7](https://github.com/purpshell/meowcaller/pull/7) merges, build the reviewed branch:
git clone --branch feat/send-only-notify https://github.com/steipete/meowcaller.git
cd meowcaller
git checkout 752050471fc2bf7a8cdfbf7dbd3cd4e865d85d3f
mkdir -p "$HOME/.local/bin"
go build -o "$HOME/.local/bin/meowcaller" ./cmd/meowcaller
Ensure `$HOME/.local/bin` is on the gateway service's `PATH`. This revision has explicit `pair` and send-only `notify` commands; `notify` opens no microphone, speaker, video device, or diagnostic capture. Do not substitute the upstream example CLI's `play` command.
Ask the WhatsApp agent to check call setup (`whatsapp_call` status action reports the account-specific state directory and pairing command). For the default account:
state_dir="$HOME/.openclaw/credentials/whatsapp-calls/default"
mkdir -p "$state_dir"
chmod 700 "$state_dir"
meowcaller pair --store "$state_dir/wa-voip.db"
Run this interactively, scan the QR from **WhatsApp > Linked devices**, and wait for `MeowCaller linked device ready`. Keep `wa-voip.db` private — it is the MeowCaller session. Non-default accounts get their own store path from the status action; on Windows, run its PowerShell command.
Configure a telephony-capable [TTS provider](/tools/tts), restart the gateway, then send a request such as `Call me and say the build finished.` The tool resolves the sender from trusted inbound context, synthesizes a temporary private WAV file, runs MeowCaller for a bounded call window, and deletes the audio file afterward. OpenClaw passes the account's store explicitly, waits for a zero exit status after answer/playback/hangup, and treats a timeout or nonzero exit as a failed tool call.
Limits: one-to-one outbound audio calls only, no arbitrary destination numbers, no shared auth with the chat connection, no self-calls from personal-number/self-chat mode, synthesized audio capped at 60 seconds, no handset-side audibility receipt beyond MeowCaller's answer/playback/hangup completion, and OpenClaw stops the companion process after a bounded 115-175 second window (covering MeowCaller's connection, answer, playback, and shutdown phases).
WhatsApp can render exec and plugin approval prompts as 👍/👎 reactions, controlled by the top-level approval forwarding config:
{
approvals: {
exec: {
enabled: true,
mode: "session",
},
plugin: {
enabled: true,
mode: "targets",
targets: [{ channel: "whatsapp", to: "+15551234567" }],
},
},
}
approvals.exec and approvals.plugin are independent; enabling WhatsApp as a channel only links the transport and sends nothing unless the matching approval family is enabled and routed there. Session mode delivers native emoji approvals only for approvals that originate from WhatsApp. Target mode uses the shared forwarding pipeline for explicit targets and does not create separate approver-DM fanout.
WhatsApp approval reactions require explicit approvers in allowFrom (or "*"). defaultTo sets ordinary default message targets, not an approver list. Manual /approve commands still pass the normal WhatsApp sender-authorization path before approval resolution.
Inbound WhatsApp messages can carry personal content, phone numbers, group identifiers, sender names, and session correlation fields. WhatsApp does not broadcast inbound message_received hook payloads to plugins unless you opt in:
{
channels: {
whatsapp: {
pluginHooks: {
messageReceived: true,
},
},
},
}
Scope the opt-in to one account under channels.whatsapp.accounts.<id>.pluginHooks.messageReceived. Only enable this for plugins you trust with inbound WhatsApp content and identifiers.
| Value | Behavior |
| --- | --- |
| `pairing` (default) | Unknown senders request pairing; owner approves |
| `allowlist` | Only `allowFrom` senders admitted |
| `open` | Requires `allowFrom` to include `"*"` |
| `disabled` | Block all DMs |
`allowFrom` accepts E.164-style numbers (normalized internally). It is a DM sender access-control list only — it does not gate explicit outbound sends to 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 notes:
- pairings persist in the channel allow-store and merge with configured `allowFrom`
- scheduled automation and heartbeat recipient fallback use explicit delivery targets or configured `allowFrom`; DM pairing approvals are not implicit cron/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 yourself from the linked device)
1. **Group membership allowlist** (`channels.whatsapp.groups`): if `groups` is omitted, all groups are eligible; if present, it acts as a group allowlist (`"*"` admits all).
2. **Group sender policy** (`channels.whatsapp.groupPolicy` + `groupAllowFrom`): `open` bypasses the sender allowlist, `allowlist` requires a `groupAllowFrom` (or `*`) match, `disabled` blocks all group inbound.
If `groupAllowFrom` is unset, sender checks fall back to `allowFrom` when it has entries. Sender allowlists are evaluated before mention/reply activation.
If no `channels.whatsapp` block exists at all, runtime falls back to `groupPolicy: "allowlist"` (with a warning log), even if `channels.defaults.groupPolicy` is set to something else.
<Note>
Group-membership resolution has a single-account safety net: if only one WhatsApp account is configured and its `accounts.<id>.groups` is an explicit empty object (`{}`), that is treated as "not set" and falls back to the root `channels.whatsapp.groups` map, instead of silently blocking every group. With 2+ accounts configured, an explicit empty account map stays empty and does not fall back — this lets one account intentionally disable all groups without affecting siblings.
</Note>
- 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: quote/reply only satisfies mention gating — it does **not** grant sender authorization. With `groupPolicy: "allowlist"`, non-allowlisted senders stay blocked even replying to an allowlisted user's message.
Session-level activation command: `/activation mention` or `/activation always`. This updates session state (not global config) and is owner-gated.
WhatsApp supports persistent ACP bindings via top-level bindings[]:
{
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "whatsapp",
accountId: "work",
peer: { kind: "direct", id: "+15555550123" },
},
},
{
type: "acp",
agentId: "codex",
match: {
channel: "whatsapp",
accountId: "work",
peer: { kind: "group", id: "[email protected]" },
},
},
],
}
Direct chats match E.164 numbers; groups match WhatsApp group JIDs. Group allowlists, sender policy, and mention/activation gating run before OpenClaw ensures the bound ACP session exists. A matched binding owns the route — broadcast groups do not fan that turn out to ordinary WhatsApp sessions.
When the linked self number is also present in allowFrom, self-chat safeguards activate: skip read receipts for self-chat turns, ignore mention-JID auto-trigger behavior that would ping yourself, and default replies to [{identity.name}] (or [openclaw]) when messages.responsePrefix is unset.
```text
[Replying to <sender> id:<stanzaId>]
<quoted body or media placeholder>
[/Replying]
```
Reply metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`, sender JID/E.164) is populated when available. If the quoted target is downloadable media, OpenClaw saves it through the normal inbound media store and exposes `MediaPath`/`MediaType` so the agent can inspect it directly instead of seeing only `<media:image>`.
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, it stays in pending group history instead of the raw placeholder.
Location bodies render as terse coordinate text. Location labels/comments and contact/vCard details render 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]` and `[Current message - respond to this]`.
```json5
{ channels: { whatsapp: { sendReadReceipts: false } } }
```
Per-account override: `channels.whatsapp.accounts.<id>.sendReadReceipts`. Self-chat turns skip read receipts even when globally enabled.
channels.whatsapp.replyToMode controls native reply quoting (outbound replies visibly quote the inbound message):
| Value | Behavior |
|---|---|
"off" (default) | 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; leave immediate replies unquoted |
Per-account override: channels.whatsapp.accounts.<id>.replyToMode.
{ channels: { whatsapp: { replyToMode: "first" } } }
channels.whatsapp.reactionLevel controls how broadly the agent uses emoji reactions:
| Level | Ack reactions | Agent-initiated reactions |
|---|---|---|
"off" | No | No |
"ack" | Yes | No |
"minimal" (default) | Yes | Yes, conservative guidance |
"extensive" | Yes | Yes, encouraged guidance |
Per-account override: channels.whatsapp.accounts.<id>.reactionLevel.
{ channels: { whatsapp: { reactionLevel: "ack" } } }
channels.whatsapp.ackReaction sends an immediate reaction on inbound receipt, gated by reactionLevel (suppressed when "off"):
{
channels: {
whatsapp: {
ackReaction: {
emoji: "👀",
direct: true,
group: "mentions", // always | mentions | never
},
},
},
}
Notes: sent immediately after inbound is accepted (pre-reply); if ackReaction is present without emoji, WhatsApp uses the routed agent's identity emoji falling back to "👀" (omit ackReaction or set emoji: "" for no ack); failures are logged but do not block reply delivery; group mode mentions reacts only on mention-triggered turns, while group activation always bypasses that check; WhatsApp uses channels.whatsapp.ackReaction only (legacy messages.ackReaction does not apply here).
Set messages.statusReactions.enabled: true to let WhatsApp replace the ack reaction during a turn instead of leaving a static receipt emoji, cycling through states such as queued, thinking, tool activity, compaction, done, and error:
{
messages: {
statusReactions: {
enabled: true,
emojis: {
deploy: "🛫",
build: "🏗️",
concierge: "💁",
},
},
},
}
Notes: channels.whatsapp.ackReaction still controls eligibility for direct messages and groups; the queued state uses the same effective emoji as plain ack reactions; WhatsApp has one bot reaction slot per message, so lifecycle updates replace the current reaction in place; messages.removeAckAfterReply: true clears the final status reaction after the configured done/error hold; tool emoji categories include tool, coding, web, deploy, build, and concierge.
In legacy auth directories, `oauth.json` is preserved while Baileys auth files are removed.
react).channels.whatsapp.actions.reactions, channels.whatsapp.actions.polls (existing actions default to true), channels.whatsapp.actions.calls (default false, see MeowCaller above).channels.whatsapp.configWrites: false.openclaw channels login --channel whatsapp
openclaw channels status
Quiet accounts can stay connected past the normal message timeout; the watchdog restarts only when WhatsApp Web transport activity stops, the socket closes, or application-level activity stays silent beyond the longer safety window (see Runtime model above).
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 channels status --probe
openclaw doctor
openclaw logs --follow
openclaw gateway status
```
If the loop persists after host connectivity and timing are fixed, back up the account auth directory and re-link:
```bash
cp -a ~/.openclaw/credentials/whatsapp/<accountId> \
~/.openclaw/credentials/whatsapp/<accountId>.bak
openclaw channels logout --channel whatsapp --account <accountId>
openclaw channels login --channel whatsapp --account <accountId>
```
If `~/.openclaw/logs/whatsapp-health.log` says `Gateway inactive` but `openclaw gateway status` and `openclaw channels status --probe` both show healthy, run `openclaw doctor`. On Linux, doctor warns about legacy crontab entries invoking the retired `~/.openclaw/bin/ensure-whatsapp.sh` script; remove those entries with `crontab -e` — cron can lack the systemd user-bus environment and make that old script misreport gateway health.
WhatsApp Web login uses the gateway host's standard proxy environment (`HTTPS_PROXY`, `HTTP_PROXY`, lowercase variants, `NO_PROXY`). Verify the gateway process inherits the proxy env and that `NO_PROXY` does not match `mmg.whatsapp.net`.
Ack reactions are independent pre-reply receipts — a successful reaction does not prove the later text/media reply was accepted. Check gateway logs for `auto-reply delivery failed` or `auto-reply was not accepted by WhatsApp provider`.
If `channels.whatsapp.groups` is present, WhatsApp can still observe messages from other groups, but OpenClaw drops them before session routing. Add the group JID to `channels.whatsapp.groups`, or add `groups["*"]` to admit all groups while keeping sender authorization under `groupPolicy`/`groupAllowFrom`.
WhatsApp supports Telegram-style system prompts for groups and direct chats via the groups and direct maps.
Resolution for group messages: the effective groups map is determined first — if the account defines its own groups key at all, it fully replaces the root groups map (no deep merge). Prompt lookup then runs on that single resulting map:
groups["<groupId>"].systemPrompt): used when the group entry exists and its systemPrompt key is defined. An empty string ("") suppresses the wildcard and applies no prompt.groups["*"].systemPrompt): used when the specific group entry is absent, or exists without a systemPrompt key.Resolution for direct messages follows the identical pattern against the direct map and direct["*"].
Difference from Telegram: Telegram suppresses root groups for every account in a multi-account setup (even accounts with no groups of their own) to stop a bot receiving group messages for groups it does not belong to. WhatsApp does not apply that guard — root groups/direct are inherited by any account without its own override, regardless of account count. In a multi-account WhatsApp setup, define the full map under each account explicitly if you want per-account prompts.
Important behavior:
channels.whatsapp.groups is both a per-group config map and the chat-level group allowlist. At either root or account scope, groups["*"] means "all groups are admitted" for that scope.systemPrompt when you already want that scope to admit all groups. To keep only a fixed set of group IDs eligible, repeat the prompt on each explicitly allowlisted entry instead of using groups["*"].groups["*"] widens which groups reach group handling; it does not authorize every sender in those groups — that stays controlled by groupPolicy/groupAllowFrom.channels.whatsapp.direct has no equivalent side effect for DMs: direct["*"] only supplies a default 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: Configuration reference - WhatsApp
| Area | Fields |
|---|---|
| Access | dmPolicy, allowFrom, groupPolicy, groupAllowFrom, groups |
| Delivery | textChunkLimit, chunkMode, mediaMaxMb, sendReadReceipts, ackReaction, reactionLevel |
| Multi-account | accounts.<id>.enabled, accounts.<id>.authDir, and other per-account overrides |
| Operations | configWrites, debounceMs, web.enabled, web.heartbeatSeconds, web.reconnect.*, web.whatsapp.* |
| Session behavior | session.dmScope, historyLimit, dmHistoryLimit, dms.<id>.historyLimit |
| Prompts | groups.<id>.systemPrompt, groups["*"].systemPrompt, direct.<id>.systemPrompt, direct["*"].systemPrompt |