aboutsummaryrefslogtreecommitdiffstats

wa-cli

EspaΓ±ol

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 β†’ $1 in message IDs (wwebjs#201830). Symptom: r: r in chats list, sendMessage returning undefined.
  • 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, exposeFunction fails with Target closed and ready never 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