HomeSkillsWhatsApp

WhatsApp

Skillv1.07 files

Read and reply on your WhatsApp from Claude Code: unread chats with real names, threads, and the screenshots people send. Replies go out only after you approve the exact text. Ships wa.mjs (zero-dependency Node) and its tests; you provide a Unipile account with WhatsApp connected.

whatsappunipilemessaginginboxrepliesscreenshots
CategoriesProductivityAutomation

Documentation

# /whatsapp Your WhatsApp, read and answered from Claude Code through Unipile's API. Everything goes through one zero-dependency script: ```bash node ~/.claude/skills/whatsapp/scripts/wa.mjs health ``` Write the path exactly like that, tilde and all, in every command. Never put it in a variable (`W=...; node "$W"`) and never write `$HOME`: a worktree-isolated Claude Code session refuses both as "a value computed at runtime" and runs nothing. The tilde form passes. ## Step 0 — Prerequisites Before any other operation, verify these are present. If any are missing, stop and tell the user where to get each; do not proceed with broken state. | Requirement | Check | Where to get it | |---|---|---| | Node.js 18 or newer | `node --version` prints v18 or higher | https://nodejs.org | | A Unipile account | You can sign in to the Unipile dashboard | https://www.unipile.com | | WhatsApp connected in Unipile | The dashboard lists a WhatsApp account | Connect WhatsApp from the Unipile dashboard; it shows a QR code to scan with your phone | | Your Unipile API key and DSN | Both are in the config file below | The Unipile dashboard (the DSN is a host and port, like `api1.unipile.com:13111`) | | The config file, private to you | `ls -l ~/.config/whatsapp-skill/.env` shows `-rw-------` | Created in the first-run steps below | | Everything wired up | `node ~/.claude/skills/whatsapp/scripts/wa.mjs health` prints `"ok": true` | Follow what its error says | If anything is missing, STOP. Do not guess an account id, and do not write the API key anywhere but the config file. ### First run 1. Create `~/.config/whatsapp-skill/.env` with your two Unipile values: ``` UNIPILE_API_KEY=<your key> UNIPILE_DSN=<your dsn> ``` 2. `chmod 600 ~/.config/whatsapp-skill/.env`. The script refuses a config file other users on the machine can read, because it holds your API key. 3. Run `health`. It stops with "WA_ACCOUNT_ID is not set" and prints a ready-to-paste `WA_ACCOUNT_ID=...` line for each WhatsApp account your key can see. Add the right one to the file. 4. Run `health` again. `"ok": true` means you are set up. Optional keys in the same file: | Key | What it does | |---|---| | `WA_BLOCKLIST` | Path to a `.txt` file, one word or phrase per line (`#` comments allowed). A reply containing any of them, whole-word and case-insensitive, is refused. Use it for competitor names, internal code names, anything a customer must never read. A `.mjs` module exporting `vendorHit(text)` also works. Once set, a list that will not load refuses every draft rather than letting text through unchecked, and `health` fails. | | `WA_CONFIG` | Set in your shell environment, not in the file: points at a config file somewhere else. | Values may start with `~/`. `$HOME` is not expanded. Keys set in the shell environment win over the file. ## What it can and cannot do | Can | Cannot | |---|---| | List recent chats with names and unread counts | Start a new chat with someone | | Read a thread, senders named per message, groups included | Mark chats read, react, send images or voice notes | | Download images and screenshots, then look at them | Download view-once media, ever | | Reply in an existing chat, once the user approves the exact text | Send anything the user has not seen word for word | It only ever touches the account in `WA_ACCOUNT_ID`. A chat id from any other account on the same Unipile key (LinkedIn, Instagram, a second WhatsApp) is refused before anything in it is read. ## Commands | Command | What it returns | |---|---| | `health` | `ok`, the config file it read, which keys are set (true/false, never the values), the account, its status, and the blocklist path or `"off"`. Fails if anything is missing or wrong, and says what. | | `inbox [--limit 20] [--unread] [--dms] [--since <ISO>]` | Chats newest first: `chat_id, name, is_group, unread_count, last_at, read_only, muted`. A chat whose name lookup failed carries `name_error` and shows its raw id as the name. | | `find --name "<text>" [--pages 5]` | `{matches, scanned, more}`. Several matches means ask the user which one. Empty `matches` with `more: true` means the search stopped early, not that the person is missing: raise `--pages`. | | `thread --chat <id> [--limit 30]` | Messages oldest first: `from` ("me" for the user's own), `text`, `attachments`, `deleted`, `is_event`. | | `media --chat <id> [--message <id>] [--limit 30] [--types img] [--max-mb 10]` | Saved file paths, plus what was skipped and why. | | `draft --chat <id> --text-file <path> --after <message_id> [--group]` | Stores a reply and sends nothing: `draft_id`, `to`, `text`, `confirm` (10 characters). `--after` is the id of the newest message you read. | | `send --draft <id> --confirm <code>` | Sends that draft, once, then reads it back: `message_id`, `verified_via`. | | `drafts` | Every draft and its state. Works offline. | | `abandon --draft <id>` | Retires a draft without sending it. Works offline. | ## Every time 1. `health`. If it fails, tell the user in one line what it said (for example "WhatsApp needs reconnecting in Unipile") and stop. 2. `inbox --unread` (newest first; the script does not sort by unread). Report the one-to-one chats first, then groups, one line each: name, unread count, the gist. Read the thread before giving a gist; never guess one from the chat name. 3. Groups with many unread messages get a two-line summary, not a transcript. `last_at` on a chat is Unipile's sync time and can be months newer than the last real message (seen 2026-10-06 on a group whose newest message was from December). Order by it, but take dates from `thread`. ## Screenshots and images ```bash node ~/.claude/skills/whatsapp/scripts/wa.mjs media --chat <chat_id> --limit 30 ``` Then Read each path in `saved` and say what it shows in the context of the thread. Images, including screenshots, are the default. Videos are listed under `skipped` and fetched only if the user asks (`--types img,video`). Stickers, view-once media, files over 10 MB and media WhatsApp no longer holds are always skipped, and the reason is in the output. Files land in `~/.cache/whatsapp-skill/media/<chat_id>/` (folders private to the user, files mode 600). Any `media` run deletes files older than 7 days. They never go into a repo or an artifact. If an image shows a password, API key or card number, say so without repeating it, and suggest the owner change it. ## Replying Nothing is sent without the user approving that exact text in this session. "Reply to everyone" still means one approval per message. 1. Read the thread first (`thread`), so the reply answers what was actually said last. Note the `id` of the newest message: `draft` needs it as `--after`, and refuses if anything newer has arrived since (use `--after none` for an empty chat). 2. Draft in the user's own voice, WhatsApp register: short, plain text, no greeting line, no sign-off. No prices, offers, refunds or commitments unless the user said so first. 3. Write it to a file in the session's scratchpad directory, or in `~/.cache/whatsapp-skill/` when there is none. Never write it inside a repo or the current project: these are private conversations, and a reply file in a working tree can be committed. Then review it for AI voice. If a humanizer or AI-voice checker is installed, run it; either way, read the text as the recipient would and fix anything that does not sound like the user. 4. `node ~/.claude/skills/whatsapp/scripts/wa.mjs draft --chat <id> --text-file <file> --after <newest id>`. The script refuses markdown WhatsApp would print literally (`**`, `__`, `[label](url)`, `#` headings, backticks, tables), an em dash (replies should read as typed by a person), anything on `WA_BLOCKLIST`, read-only chats, groups without `--group`, and anything that is not a person or a group (status broadcasts, channels). WhatsApp's own `*bold*` with one asterisk is fine. Pass `--group` only when the user named that group as the place to reply. 5. Ask the user with `AskUserQuestion`. The question carries the recipient, whether it is a group, and the exact text. Options: "Send as written", "Edit", "Skip". 6. On "Send as written": `node ~/.claude/skills/whatsapp/scripts/wa.mjs send --draft <draft_id> --confirm <confirm>`. Report who it went to and the `message_id`. The `confirm` code binds the text, the chat, the group permission and the `--after` snapshot, so the draft file cannot be changed between approval and the send without the send refusing. 7. On "Edit": write the new text and make a new draft. On "Skip": `abandon` the draft. ### What send refuses, and what to do | Message | What happened | Do | |---|---|---| | `--confirm ... does not match` | Wrong code. Nothing sent; the draft is still usable. | Re-run with the code from `draft`. | | `changed on disk since it was drafted` | The draft file's text, chat or snapshot was edited after approval. | New draft, new approval. | | `arrived after the thread was read` (from `draft`) | A message came in between reading the thread and drafting. | Read the thread again, then redraft. | | `someone wrote in the chat after this draft was made` | The conversation moved on. The draft is dead. | Read the thread again, redraft, ask again. | | `already claimed` | Each draft can be sent once in its life, and this one has been tried. | Check `drafts` for what happened; never work around it. | | `VERIFY BEFORE RETRY` | The message may or may not have gone out. State is `unconfirmed`. | Ask the user to check the chat on their phone. **Never resend.** If it is not there, `abandon` and redraft. | | `WhatsApp rejected the send` | A definite rejection (400, 401, 403, 404, 422 or 429). State is `failed`. | Tell the user the reason; redraft if it makes sense. Any other error, a timeout or a dropped connection is `unconfirmed` instead. | | Draft stuck in `sending` | The process died mid-send. | Same as `VERIFY BEFORE RETRY`. The script never decides this on its own. | `verified_via` says how the send was confirmed: `direct` (read back at once), `direct_after_retry` (read back after the read lagged), or `chat_list` (found by its id in the chat's last 20 messages). On the first live send (2026-10-06), WhatsApp read the message back `direct` on the first attempt, under the same id the send returned. `chat_list` has only been exercised against the test server. Drafts live in `~/.cache/whatsapp-skill/drafts/`, private to the user. ## When something fails | Symptom | Meaning | |---|---| | `WA_ACCOUNT_ID is not set` | First run, or the line was removed. Paste one of the lines the error lists into the config file. | | `WA_ACCOUNT_ID ... is not in Unipile` | The WhatsApp account was re-linked and has a new id. The error lists the current ones; update the config file. | | `status is CREDENTIALS, not OK` | WhatsApp logged out of Unipile. Reconnect it by QR code in the Unipile dashboard. | | `can be read by other users ... chmod 600` | The config file is too open. Run the `chmod` the error prints. | | `Missing UNIPILE_API_KEY` or `Missing UNIPILE_DSN` | The config file is not where the error says, or lacks that line. | | `the word blocklist could not load` | `WA_BLOCKLIST` names a file that is missing, empty, the wrong type, or (for `.mjs`) has no `vendorHit`. Reads still work; drafting refuses until it is fixed or the line is removed. | | `WA_API_BASE is set but WA_TEST is not 1` | A test variable leaked into the shell. `unset WA_API_BASE`. | | `media` exits 1 with `failed` entries | Those downloads errored, came back the wrong size, or passed the size cap, and were discarded. The `error` field says which. Retry once; the rest were saved. | | `message ... is not in the last N messages` | `--message` only looks inside the `--limit` window. Raise `--limit`. | ## Tests ```bash node --test ~/.claude/skills/whatsapp/tests/ ``` The tests run against a fake Unipile server on 127.0.0.1 and a throwaway HOME; they never reach the network, your config or your cache.
Jay Feldman
Built by Jay FeldmanFounder, Lead Gen Jay · Inc. 5000 · 84K+ YouTube subs
AI Automation Insiders

The locked half of the library

This skill is free to install. The ones wearing a padlock are not, and they are where the heavier automations live. Membership opens all of them, plus weekly live coaching and the private community.

Total real-world value
$13,479
You pay today
$1,497
Claim Your SpotSee What's Inside

Encrypted checkout 14-day refund