wa-cli
WhatsApp from the terminal, shaped like gws:
wa <resource> <method> [--params JSON] [--format json|table|csv|yaml|ndjson|text|eml|mbox]
wa +<helper> ...
wa schema <resource.method>
It uses whatsapp-web.js: a headless Chrome running WhatsApp Web, linked to your phone as one more device. It is not an official client: WhatsApp may ban accounts that use unofficial clients. Use it in moderation (no bulk sending).
Install
Keep the project outside /tmp (systemd-tmpfiles deletes whatever is unused for 10 days).
git clone https://git.all.ar/pub/wa-cli.git && cd wa-cli
npm install # downloads Chrome for puppeteer if it is not in ~/.cache/puppeteer
ln -s "$PWD/bin/wa.js" ~/.local/bin/wa
wa auth login # QR in the terminal
wa auth login --phone 549... # or an 8-character code
Optional, a permanent daemon (needed to receive messages with the terminal closed):
cp systemd/wa-daemon@.service ~/.config/systemd/user/
systemctl --user enable --now wa-daemon@default
Without systemd, the first wa command starts the daemon by itself (WA_CLI_AUTOSTART=0
disables that; with systemd set "autostart": false in config.json so two do not compete).
Usage
wa +triage # chats with unread messages
wa +read "Family" -n 50 --mark-read # readable conversation
wa +send "Mom" there in 10 # by exact name, number or JID
wa +send +5491122334455 -f report.pdf "here it is"
wa +reply "<messageId>" sure
wa +watch --format text # incoming messages, live
wa +watch --events message_create | jq -r .data.body
wa +download "<messageId>" -d ~/Downloads
wa chats list --params '{"unread": true}' --format table
wa messages list --params '{"chatId": "Family", "limit": 200, "since": 1788000000}'
wa messages search --params '{"query": "invoice"}' --format table
wa messages react --params '{"messageId": "...", "emoji": "π"}'
wa contacts resolve --params '{"number": "+54 9 11 2233-4455"}'
wa schema messages.send # parameters of a method
wa messages send --help
Several accounts: -a work or WA_CLI_ACCOUNT=work. Each account has its own session,
socket and daemon.
Exit codes: 0 ok, 1 WhatsApp error / not found, 2 no session, 3 validation,
4 daemon, 5 internal.
Architecture
wa (CLI) ββJSON per lineβββΆ Unix socket βββΆ daemon βββΆ whatsapp-web.js βββΆ headless Chrome
β
ββββΆ hooks ~/.config/wa-cli/hooks/<event>
ββββΆ events.watch (NDJSON stream)
WhatsApp Web takes 10β30 s to start and only receives messages while it is open, so there is one daemon per account and the CLI is a thin client.
| What | Where |
|---|---|
| Session (profile) | ~/.local/share/wa-cli/<account>/ |
| Downloaded media | ~/.local/share/wa-cli/<account>/media/ |
| Log (autostart) | ~/.local/state/wa-cli/<account>/daemon.log |
| Socket | $XDG_RUNTIME_DIR/wa-cli/<account>.sock |
| Config and hooks | ~/.config/wa-cli/{config.json,hooks/} |
config.json (all optional):
{
"autostart": true,
"readyTimeoutMs": 120000,
"hooks": true,
"puppeteer": { "headless": true, "executablePath": "/usr/bin/chromium" },
"accounts": { "work": { "hooks": false } }
}
Code
| File | Role |
|---|---|
src/api/*.js |
resource.method methods: parameters, columns and handler |
src/serialize.js |
whatsapp-web.js objects β stable JSON (the external contract) |
src/daemon.js |
Session, socket, events, hooks, chat resolution by name |
src/cli.js |
gws-style parser, help, auth login, daemon |
src/helpers.js |
+send, +read, +watch, ... |
src/format/ |
json/table/csv/yaml/ndjson/text/eml/mbox |
src/compat.js |
Patches for WhatsApp Web changes (see Known issues) |
src/wamail/ |
Maildir bridge: sync, send (MIME parser), mailbox list |
Adding a method = adding an entry to src/api/<resource>.js. It becomes available on the
socket, as wa <resource> <method>, in --help and in wa schema. chat parameters accept a
JID, a number or an exact name; path parameters are resolved against the CLI cwd.
Adding a format = a function in FORMATS (src/format/index.js).
Events and hooks
wa events watch --params '{"events": ["all"]}' (or wa +watch) prints one line per event:
{"event": "message", "account": "default", "time": "2026-09-16T21:30:00.000Z", "data": { ...message }}
| Event | data |
|---|---|
message (incoming), message_create (all) |
message |
message_ack |
{message, ack} (1 server, 2 delivered, 3 read) |
message_edit |
{message, newBody, prevBody} |
message_revoke_everyone |
{message, revoked} |
message_reaction |
{messageId, chatId, senderId, reaction} |
unread_count, chat_removed |
chat |
group_join, group_leave, group_update, ... |
group notification |
qr, code, authenticated, ready, disconnected |
session state |
The same events trigger hooks: executables in ~/.config/wa-cli/hooks/<event>,
<event>.d/* or all.d/*. They get the event on stdin, its name as $1 and
WA_CLI_ACCOUNT / WA_CLI_SOCKET in the environment (so they can call wa). They run in order
per hook and are rescanned on every event (no restart needed).
A serialized message:
{
"id": "false_5491122334455@c.us_3EB0C4...", "stanzaId": "3EB0C4...",
"chatId": "5491122334455@c.us", "chatName": "Juan PΓ©rez",
"fromMe": false, "sender": "5491122334455@c.us", "senderName": "Juan PΓ©rez",
"timestamp": 1789500000, "date": "2026-09-15T19:20:00.000Z",
"type": "chat", "body": "hola", "hasMedia": false, "media": null,
"hasQuotedMsg": false, "quotedStanzaId": null, "ack": 1, ...
}
neomutt integration: wamail
wamail is the WhatsApp β Maildir bridge, like gwsmail for Gmail: one folder per chat
in ~/Mail/whatsapp/, and neomutt reads it like any other account.
wa-daemon ββhook (real time)βββΆ wamail sync --chat X βββΆ ~/Mail/whatsapp/<chat>/{new,cur}
βββββββββββββββββββββ wamail sync (5 min timer: read state, stars, deletions)
neomutt ββsendmailβββΆ wamail send βββΆ wa messages send
| In neomutt | On WhatsApp |
|---|---|
| sidebar folder | chat (WhatsApp order: pinned, recent; archived at the end) |
| read every unread message of a chat | chat marked as read (read receipts) |
| mark a message as unread | chat marked as unread |
flag (F) |
starred message |
| delete | local only (not downloaded again) |
r keeping the quote (> ...) |
quoted reply; delete the quote for a normal message |
r / g in a group |
goes to the group only (never privately to the author) |
| attachments | a single image/video: with the text as caption; otherwise text + documents |
gR / gX / gA / gG |
react / delete (everyone or me) / archive chat / sync now |
Tab in To: |
search contacts and groups (wamail query) |
F6 |
switch to the WhatsApp account |
Received on WhatsApp β email: sender = JID (5491122334455@c.us, groups ...@g.us),
Message-ID <stanzaId@wa>, In-Reply-To for quoted replies, X-WA-Id/X-WA-Chat-Id for
the actions, media as an attachment (up to mediaMaxBytes; otherwise a note with the command to
download it). Messages deleted for everyone disappear from the Maildir (message_revoke_everyone hook).
Install
ln -s "$PWD/bin/wamail.js" ~/.local/bin/wamail
mkdir -p ~/.config/wa-cli/hooks/all.d
ln -s "$PWD/contrib/hooks/wamail" ~/.config/wa-cli/hooks/all.d/50-wamail
cp systemd/wamail-sync.{service,timer} ~/.config/systemd/user/
systemctl --user daemon-reload && systemctl --user enable --now wamail-sync.timer
wamail sync default # first time: last 30 days, up to 100 messages per chat
mkdir -p ~/.config/neomutt/accounts/wa
cp contrib/neomutt/wa-default.muttrc ~/.config/neomutt/accounts/wa/default.muttrc
and in the neomutt config:
# neomuttrc
folder-hook 'Mail/whatsapp' 'source ~/.config/neomutt/accounts/wa/default.muttrc'
# keys/bind.muttrc
macro index,pager <f6> '<enter-command>source ~/.config/neomutt/accounts/wa/default.muttrc<enter><change-folder>!<enter>' "WhatsApp (wamail)"
# switch.muttrc (what the WhatsApp account changes)
unmacro index,pager gR
unmacro index,pager gX
set query_command = "khard email --parsable --search-in-source-files '%s'"
Optional config in ~/.config/wa-cli/config.json:
{ "wamail": { "backfillDays": 30, "backfillLimit": 100, "mediaMaxBytes": 16777216, "skipChats": ["120363...@g.us"] } }
State lives in ~/.local/state/wamail/<account>.json (deleting it together with the Maildir
forces a clean backfill); sends are logged in ~/.local/state/wamail/send.log.
Export a chat without syncing
wa +export "Family" -n 500 --media -o /tmp/family.mbox && neomutt -R -f /tmp/family.mbox
Known issues
WhatsApp Web changes often and whatsapp-web.js takes a while to catch up. src/compat.js
patches at runtime, without touching node_modules:
_serializedβ$1in message IDs (wwebjs#201830). Symptom:r: rinchats list,sendMessagereturningundefined.- Re-injection on SPA navigations (wwebjs#201653, fixed on
main, not released). - Cross-origin iframe (
pdf-viewer): with site isolation it is a separate process and, if it navigates while the bindings are registered,exposeFunctionfails withTarget closedandreadynever comes. The daemon launches Chrome with--disable-site-isolation-trials.
If it still hangs, the daemon watchdog exits with code 1 (60 s in authenticated,
startTimeoutMs in starting) and systemd restarts it. To diagnose: WA_CLI_TRACE=1 (or
"trace": true in config.json) logs pending Chrome calls, exceptions inside bindings and
navigations (journalctl --user -u wa-daemon@default).
Headless Chrome uses ~1 GB (2 GB peaks during the initial sync).
Tests
npm test # formats, MIME parser (with real neomutt emails), wamail sync/send against a fake WhatsApp
License
MIT