A standalone WhatsApp bridge using Baileys v7
(WhatsApp Web Multi-Device protocol). Runs as its own long-lived HTTP server
(systemd --user unit) and exposes a small RPC-flat HTTP API on loopback.
Any consumer can drive it: curl, a cron job, or — via the bundled
whatsapp-channel skill — a Claude Code agent. The HTTP API is the contract;
the skill is one supported consumer that ships in the same repo.
Note: This is a personal project that I've open-sourced for the community. It works for my 24/7 setup and I'm sharing it as-is. PRs are welcome.
Credits: Originally forked from diogo85/claude-code-whatsapp, which provided the initial Claude Code WhatsApp channel plugin. This fork has since diverged — replacing the MCP-over-stdio transport with a standalone HTTP bridge (v0.1.0) and decoupling defaults, naming, and framing from Claude Code (v0.2.0) — so the projects target different use cases. Thanks to @diogo85 for the starting point.
WhatsApp (phone)
↕ Baileys v7.0.0-rc.9 (Multi-Device protocol)
app.cjs (HTTP server on 127.0.0.1:8787)
↕ HTTP POST /reply, /react, /download_attachment, /fetch_messages
↕ HTTP GET /health, /status
↕ Redis Streams (optional): XADD whatsapp:raw, XREADGROUP whatsapp:egress
Any consumer (Claude Code agent via skill, curl, your own worker, ...)
The server runs independently of any agent runtime. Two consumer surfaces:
- HTTP — inbound messages buffered in memory; consumers poll
POST /fetch_messagesto pop new messages (server-side per-chat cursor). Sends are synchronous viaPOST /reply/POST /react. - Redis Streams (optional, opt-in via
subscribers.json) — every accepted inbound message is XADD'd towhatsapp:raw; multiple consumer groups can fan out independently with replay/durability. Outbound producers XADD towhatsapp:egress; the bridge runs an XREADGROUP consumer that dispatches through the same internal send path the HTTP routes use. Seesubscribers.json.md.
When subscribers.json is absent both Redis surfaces are off and the
bridge behaves exactly like v0.2.x.
- Production-grade stability — connection patterns based on OpenClaw's proven WhatsApp gateway
- 515 is normal — WhatsApp restart requests are handled gracefully (reconnect in 2s, not crash)
- Never crashes the process — only stops on 440 (conflict) or 401 (logout); everything else reconnects
- Exponential backoff with jitter — factor 1.8, jitter 25%, max 30s, reset after healthy period
- Watchdog — detects stale connections (30 min timeout) and forces reconnect
- Credential backup — auto-backup before each save, auto-restore if corrupted
- getMessage handler — required for E2EE retry in Baileys v7
- Crypto error recovery — Baileys crypto errors trigger reconnect instead of crash
- Graceful shutdown — clean exit on SIGTERM/SIGINT
- Hot-reloaded access control —
access.jsonre-read on every inbound message
- Node.js 22+ (Bun is NOT supported — lacks WebSocket events Baileys needs)
- systemd with
--userunits enabled (Linux). Other init systems work too — just adaptdeploy/whatsapp-http.serviceby hand. - WhatsApp account (regular or Business)
git clone https://github.com/MWZ-Code/claude-code-whatsapp.git
cd claude-code-whatsapp
npm install
npm run build # compile TS modules under channels/ + streams/npm run build is required once before first start, and any time you
edit a .ts file under channels/ or streams/. It runs esbuild
(zero-config, ~10ms). npm start runs build automatically via
prestart.
mkdir -p ~/.config/whatsapp-bridge/auth
node pair.cjsBy default the bridge stores creds, inbox, and access.json in
${XDG_CONFIG_HOME:-~/.config}/whatsapp-bridge/. Override with
WHATSAPP_STATE_DIR=/some/other/path.
The script shows both a QR code and a pairing code. On your phone:
- QR: WhatsApp > Linked Devices > Link a Device — scan the QR
- Code: WhatsApp > Linked Devices > Link a Device > Link with phone number — enter the code
Wait for "✅ WhatsApp connected!" before closing.
bash deploy/install.shThis renders deploy/whatsapp-http.service into
~/.config/systemd/user/ (paths and the Node binary are detected from your
environment), runs systemctl --user daemon-reload, then
systemctl --user enable --now whatsapp-http.service. It's safe to
re-run any time — it diffs the rendered unit and only rewrites on change.
Override paths via env vars if needed:
NODE_BIN=/home/user/.local/bin/node \
STATE_DIR=~/.config/whatsapp-bridge \
WORKING_DIR=/path/to/repo \
bash deploy/install.shConfirm it's up:
curl -s http://127.0.0.1:8787/health
# → {"status":"ok"}
curl -s http://127.0.0.1:8787/status
# → {"connected":true,"last_inbound_at":...,"retry_count":0,"watchdog_age_ms":...}Tail logs with journalctl --user -u whatsapp-http.service -f.
This repo ships a whatsapp-channel skill under skills/whatsapp-channel/.
When Claude Code loads this directory as a plugin, the skill auto-loads and
documents every endpoint with curl examples and request/response shapes for
the agent. There is no .mcp.json to register — the skill is the contract.
For non-Claude-Code consumers, just hit the HTTP API directly. The skill is a human-and-agent-readable reference that lives next to the server.
Create ~/.config/whatsapp-bridge/access.json:
{
"allowFrom": ["5511999999999"],
"allowGroups": false,
"allowedGroups": [],
"requireAllowFromInGroups": false,
"mentionKey": null
}| Field | Default | Description |
|---|---|---|
allowFrom |
[] |
Phone numbers allowed to DM the bot. Empty = anyone. |
allowGroups |
false |
Whether the bot listens to group chats at all. |
allowedGroups |
[] |
Specific group JIDs to allow (when allowGroups: true). Empty = all groups. |
requireAllowFromInGroups |
false |
When true, group messages are only processed if the sender is in allowFrom. |
mentionKey |
null |
Regex pattern (case-insensitive). When set, group messages are only processed if their text matches this pattern. DMs are never filtered. |
Changes to access.json take effect immediately — no restart required.
The mentionKey field is a JavaScript regex string applied case-insensitively to every inbound group message. Only messages whose text matches are forwarded to consumers; everything else is silently ignored.
{ "mentionKey": "@pricebot|hey pricebot" }
⚠️ Choose a low-collision pattern. A pattern like\bb\borokwill match too many ordinary messages and make the bot noisy. Use a short, unique string — e.g. your bot's handle or a distinctive prefix — so accidental triggers are rare.
All routes accept/return JSON. Default base URL http://127.0.0.1:8787,
overridable via WHATSAPP_HTTP_BIND and WHATSAPP_HTTP_PORT.
| Method | Path | Purpose |
|---|---|---|
| GET | /health |
Liveness probe. Always 200 {"status":"ok"} if the process is up. |
| GET | /status |
{connected, last_inbound_at, retry_count, watchdog_age_ms}. |
| POST | /fetch_messages |
Pop new messages for a chat (server-side cursor). |
| POST | /reply |
Send or edit a WhatsApp message; supports file attachments. |
| POST | /react |
Add an emoji reaction. |
| POST | /download_attachment |
Materialize an inbound media message into <state>/inbox/. |
See skills/whatsapp-channel/SKILL.md for full request/response schemas and
curl examples. Or read the handlers directly in app.cjs.
The bridge can fan out inbound messages and accept queued sends via
Redis Streams. Enable by creating a subscribers.json next to
access.json:
{
"redis": { "url": "redis://127.0.0.1:6379" },
"streams": {
"raw": { "enabled": true, "key": "whatsapp:raw", "maxLen": 10000 },
"egress": { "enabled": true, "key": "whatsapp:egress", "consumerGroup": "bridge" }
}
}Restart the bridge. GET /status now reports subscribers counters.
See subscribers.json.md for the full schema and
wire-format reference.
A reference downstream worker lives at workers/echo_bot.cjs. It reads
whatsapp:raw, builds an echo: <text> reply for every non-self DM,
publishes to whatsapp:egress, and ACKs the raw entry. It speaks only
Redis — no Baileys, no channel imports.
# 1. Make sure Redis is running and subscribers.json enables both streams.
# 2. Restart the bridge.
# 3. In another shell:
REDIS_URL=redis://127.0.0.1:6379 npm run echo-botEnd-to-end:
- Send a WhatsApp message to the paired account.
- The bridge accepts it →
XADD whatsapp:raw. echo_botreads the entry →XADD whatsapp:egresswith the reply.- The bridge's egress consumer reads it → middleware →
performSend(). - The original sender receives
echo: <your text>.
The bridge's _sentSet filter prevents the echo's own messages.upsert
event from re-entering whatsapp:raw and triggering an infinite loop.
This server was rewritten based on analysis of OpenClaw's WhatsApp extension, which runs 24/7 without issues. Key patterns:
| Pattern | Description |
|---|---|
| 515 = reconnect | WhatsApp sends 515 regularly. It's a normal restart request, not an error |
| Never process.exit | Only stop on 440 (conflict) or 401 (logout). Everything else reconnects |
| New socket each time | Never reuse a dead socket — create fresh on every reconnect |
| Backoff with jitter | Prevents thundering herd. Reset after 60s of healthy connection |
| Watchdog timer | 30min without inbound messages = force reconnect (detects zombie connections) |
| Creds backup | Auto-backup before each save. Auto-restore if JSON is corrupted |
| Listener cleanup | Remove all event listeners before creating new socket (prevents leaks) |
Set WHATSAPP_TRACE=1 to log every inbound message and the decision applied
to it (accept / drop reason). Trace lines go to stderr (and into journald
when run under systemd) and are prefixed whatsapp trace:. Disabled by
default — zero overhead when off.
WHATSAPP_TRACE=1 node app.cjs 2>&1 | grep '^whatsapp trace:'Each inbound message produces two lines: the inbound … line classifies the
chat (dm / group / broadcast / status) and shows the JID, sender
participant (groups only), message id, and an 80-char text preview; the
indented follow-up line shows the decision (accept or drop: <reason>).
Finding a group's JID. Group JIDs look like 1234567890-1234567890@g.us
and aren't exposed in the WhatsApp UI. With trace enabled, send any message
in the target group and grep:
WHATSAPP_TRACE=1 node app.cjs 2>&1 | grep 'whatsapp trace: inbound group'The first hit gives you the JID to paste into access.json under
allowedGroups.
| Issue | Cause | Solution |
|---|---|---|
connection refused from curl |
systemd unit not running | systemctl --user start whatsapp-http.service |
503 WhatsApp not connected |
Auth expired or not paired, or still reconnecting | Run pair.cjs if needed; otherwise wait and poll /status |
| Error 515 in journal | Normal — WhatsApp requested restart | Auto-handled (reconnect in 2s) |
| Error 440 in journal | Two devices competing | Unlink in phone settings, re-pair |
| Error 401 in journal | Logged out | Session invalidated, re-pair |
| Rate limit on pairing | Too many rapid attempts | Wait 1-2 hours, try ONCE |
| Messages stop without error | Zombie connection | Watchdog detects in 30min. Or systemctl --user restart whatsapp-http.service |
creds.json corrupted |
Crash during save | Restored from backup automatically on next boot |
- Redis Streams subscriber interface (additive, opt-in). New
subscribers.jsonconfig gates two surfaces:streams.raw— the bridge XADDs every accepted inbound message towhatsapp:raw. Lets multiple downstream consumer groups fan out independently with replay/durability instead of polling/fetch_messages.streams.egress— the bridge runs an XREADGROUP consumer onwhatsapp:egressand dispatches each entry through the same internal send path that powersPOST /reply/POST /react. Producers no longer have to hold an HTTP connection open through the Baileys round-trip; idempotency is per-entry viarequest_id. Whensubscribers.jsonis absent both surfaces are off and behavior is byte-identical to v0.2.x. Seesubscribers.json.md.
- Entry point renamed
server.cjs→app.cjs. Re-runbash deploy/install.shto update the systemd unit. The install script now also runsnpm run buildbefore enabling the unit so the compiled TS modules are present. - TypeScript modules added under
channels/WhatsApp/andstreams/Redis/(built with esbuild → CJS inbuild/). Existing inline access/dedup/path-safety/text-cap checks consolidated intochannels/WhatsApp/middleware.tsso HTTP and queue paths share one source of truth. workers/echo_bot.cjsadded as the success-criterion worker — reference downstream that imports no channel code, speaks only Redis. Run withnpm run echo-bot.GET /statusnow returns subscriber counters undersubscribersfor raw publisher (published,droppedNoConnection,droppedError) and egress consumer (dropped_middleware,dropped_duplicate,dropped_retry_exhausted,dropped_queue_full).
- Default state directory moved from
~/.claude/channels/whatsapp/to${XDG_CONFIG_HOME:-~/.config}/whatsapp-bridge/. Existing installs keep working:app.cjs,pair.cjs,diag.cjs, anddeploy/install.shfall back to the legacy path when the new directory does not exist and the legacy one holds a pairedcreds.json. SetWHATSAPP_STATE_DIRto pin either explicitly. To migrate, stop the unit andmv ~/.claude/channels/whatsapp ~/.config/whatsapp-bridge. - Systemd unit renamed from
claude-whatsapp-http.servicetowhatsapp-http.service(template + identifier + log prefix). Re-runbash deploy/install.shand disable the old unit:systemctl --user disable --now claude-whatsapp-http.servicethenrm ~/.config/systemd/user/claude-whatsapp-http.service. The install script prints this hint when it detects the legacy unit file. - Framing: the HTTP API is now described as the contract; the
whatsapp-channelskill is one supported consumer that ships in-tree.
- Breaking: Replaced MCP-over-stdio transport with a long-running HTTP server (
127.0.0.1:8787by default, override viaWHATSAPP_HTTP_BIND/WHATSAPP_HTTP_PORT). - Endpoints:
GET /health,GET /status,POST /reply,POST /react,POST /download_attachment,POST /fetch_messages. - Dropped
@modelcontextprotocol/sdkdependency. - Removed permission-relay handler (no consumer was using it).
- Removed stdin-close shutdown trigger; SIGTERM/SIGINT only.
- Added
deploy/claude-whatsapp-http.service(template) +deploy/install.sh(idempotent renderer +systemctl --user enable --now). - Added
skills/whatsapp-channel/SKILL.mddocumenting the HTTP contract (curl per endpoint, schemas, access.json reference, polling guidance). - Deleted
.mcp.json.
- Trace logging — set
WHATSAPP_TRACE=1to log every inbound message and the decision applied (accept or drop-with-reason). Zero overhead when off. See the Debugging section, including the recipe for discovering group JIDs.
- Mention-key trigger — new
mentionKeyfield inaccess.json: a case-insensitive regex that gates group messages. Only messages whose text matches are forwarded to Claude; DMs are unaffected. Invalid patterns fall back to no filter with a logged warning. Hot-reload supported.
- Breaking: Rewrote connection lifecycle based on OpenClaw patterns
- 515 treated as normal reconnect (was fatal
process.exit) - Never
process.exitin reconnect loop (only 440/401 stop) - Exponential backoff with jitter + reset after healthy period (60s)
- Watchdog detects stale connections (30min timeout)
- Credential backup/restore before each save
getMessagehandler for E2EE retry (required in Baileys v7)- Crypto error handler (reconnect instead of crash)
- Permission relay capability (
claude/channel/permission) process.setMaxListeners(50)to avoid warnings- Full listener cleanup before reconnecting
browserfixed to["Mac OS", "Safari", "1.0.0"](valid for Baileys v7)- Basic exponential backoff + max retries
- Creds save with retry
- Permission relay (outbound + inbound)
- Initial implementation based on OpenClaw's architecture
- Baileys v7.0.0-rc.9
- MCP server with channel capability
- 4 tools: reply, react, download_attachment, fetch_messages
- Access control via allowlist
- Deduplication cache (20min TTL)
MIT