``` ██╗ ██╗ █████╗ ███████╗ █████╗ ██████╗ ██║ ██║██╔══██╗╚══███╔╝██╔══██╗██╔══██╗ ██║ █╗ ██║███████║ ███╔╝ ███████║██████╔╝ ██║███╗██║██╔══██║ ███╔╝ ██╔══██║██╔═══╝ ╚███╔███╔╝██║ ██║███████╗██║ ██║██║ ╚══╝╚══╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ``` **WhatsApp for your AI agent.** An MCP server that puts your WhatsApp account — chats, messages, media, contacts, groups — behind tools any MCP client can call. Pairing-code login, no browser, no phone-number reseller, ~20 MB of RAM. Built on [Baileys](https://github.com/WhiskeySockets/Baileys), which speaks the WhatsApp multi-device protocol over a WebSocket. ## Get started The npm package is `wazap-mcp`; the command it installs is `wazap`. ```bash npx wazap-mcp setup ``` That is the whole install. It links your account, finds the MCP clients installed on this machine, writes their config, copies the five skills where that client reads them, and tells you what to restart. At a terminal it is one black, centered screen per step: ghosted ASCII logo, step number, then the QR or the question. Piped output stays a log. When you started through `npx`, `setup` offers to install wazap globally so Claude Desktop and the background service have a path that does not change. It also offers to `brew install` whisper-cpp, ffmpeg or Tailscale when a step needs one and it is missing, and to restart Claude Desktop itself once it has connected it. ### Or the path your harness prefers | Harness | Fastest path | | --- | --- | | Claude Code | `/plugin marketplace add razvangirgiz/wazap`, then `/plugin install wazap@wazap` | | Claude Desktop | download `wazap-.mcpb` from [Releases](https://github.com/razvangirgiz/wazap/releases) and double-click it | | Gemini CLI | `npx wazap-mcp connect gemini` | | Cursor | the [Install in Cursor](#other-mcp-clients) badge, then `npx wazap-mcp skills install cursor` | | Codex CLI | `npx wazap-mcp connect codex`, then `npx wazap-mcp skills install codex` | | A hosted agent (claude.ai, ChatGPT) | a URL it signs in to: [Keep it running](#keep-it-running) | | Anything else | the MCP entry `npx -y wazap-mcp` over stdio | Each local harness registers the server; a hosted agent gets a URL. Linking the WhatsApp account is a separate, one-time step: `npx wazap-mcp login`. Or have your agent do it. Paste this: *Set up WhatsApp for me: run `npx wazap-mcp setup --agent` and follow what it prints.* Then ask your agent: *"what did I miss on WhatsApp today?"* Below are the steps `setup` runs for you. Each is still its own command when you want to run it by hand. `npx wazap-mcp login` shows a QR code; scan it from **Settings → Linked devices → Link a device**. No camera handy, or linking over SSH? `npx wazap-mcp login --phone +15550100` prints an 8-character code you type under *Link with phone number instead*. It ends by asking whether the agent may send messages; the answer is no unless you say yes, and `npx wazap-mcp config writes on` changes it later. `npx wazap-mcp connect claude-code` writes the MCP entry for one client. The table under **Connect a client** has the rest. `npx wazap-mcp` on its own is safe to run: it prints where you stand and what to do next, and starts no server. When something is off, `npx wazap-mcp status` is the first thing to run — it checks Node, the data directory, the lock, the credentials and whether a newer version is out, and prints the fix next to anything broken. ### Connect a client `wazap connect ` writes the entry for you, keeping whatever else is in the file and backing it up once before the first change. `--dry-run` shows what it would write. | Client | What `connect` writes | | --- | --- | | `claude-code` | runs `claude mcp add whatsapp` for you | | `claude-desktop` | `claude_desktop_config.json` in the Claude application directory | | `cursor` | `~/.cursor/mcp.json` | | `codex` | `[mcp_servers.whatsapp]` in `~/.codex/config.toml` | | `gemini` | `~/.gemini/settings.json` | | anything remote | client's MCP URL field: `https://your-host/mcp`, signed in with [OAuth](#hosted-agents-oauth) (see [Keep it running](#keep-it-running)) | ### Other MCP clients Cursor installs from a link: [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.png)](cursor://anysphere.cursor-deeplink/mcp/install?name=whatsapp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIndhemFwLW1jcCJdfQ) It carries the same entry `connect` writes; `node scripts/badges.mjs` reprints it. Any other MCP client works the same way: the command is `npx -y wazap-mcp`, the transport is stdio. Tell the agent to call `learn` first — it returns the id formats, the workflows and every error code with what to do about it.
The raw entries, for editing by hand ```json { "mcpServers": { "whatsapp": { "command": "npx", "args": ["-y", "wazap-mcp"] } } } ``` Claude Desktop, Cursor and Gemini CLI take exactly that. Codex CLI is TOML: ```toml [mcp_servers.whatsapp] command = "npx" args = ["-y", "wazap-mcp"] ```
The `skills/` folder follows the [Agent Skills](https://agentskills.io) format, so Codex, Cursor and other skill-aware agents can load the same five skills. ### Claude Desktop, without a terminal Download `wazap-.mcpb` from [Releases](https://github.com/razvangirgiz/wazap/releases) and double-click it. Claude Desktop installs the server, its Node dependencies and the icon, and shows two settings: **Read-only**, ticked, and **Data directory**, empty. `wazap connect claude-desktop` does the same job by editing `claude_desktop_config.json`. Claude Desktop starts its servers without your shell PATH, so that entry is the absolute path to `node` when wazap is installed globally, and `npx` otherwise; `wazap setup` checks that the entry it wrote is one Claude Desktop can actually launch. Then ask Claude to link your WhatsApp. It calls `link_account` with your number, hands back an 8-character code, and you type that code into **WhatsApp → Settings → Linked devices → Link a device → Link with phone number instead**. No terminal at any point. `npx wazap-mcp login` does the same job from a shell when you have one. Untick **Read-only** to let Claude send. It ships ticked because a bundle that can message people from your number before you have said so is the wrong default, and because the setting cannot be left unanswered: the manifest format has no way to omit an argument, so the box you see is the answer the server gets. `link_account` is registered either way. Read-only exists to stop Claude messaging people from your number, and relinking your own dead session messages nobody. Build it yourself with `npm run bundle:mcpb`, which stages `dist/`, the manifest, the icon and a fresh production `node_modules`, then packs them with [`@anthropic-ai/mcpb`](https://github.com/modelcontextprotocol/mcpb). ### Keep it running A wazap started by a client lives as long as that client does. Quit Claude Code and the session is gone until you open it again. Two commands change that. Staying up and being reachable are separate choices. ```bash npx wazap-mcp service install ``` That writes a launchd agent on macOS (`~/Library/LaunchAgents/com.wazap.server.plist`) or a systemd user unit on Linux (`~/.config/systemd/user/wazap.service`), starts it, and waits for `/healthz` to answer. The unit runs `serve --http` on `127.0.0.1:8766` with the absolute path of this Node and this install, so it survives a reboot and a logout. Point any client at `http://127.0.0.1:8766/mcp`, or keep using the stdio entry. A second wazap on the same data directory becomes a bridge onto the session this one holds. `service status` prints the pid, the health check and whether the unit still runs the version you have installed. `service logs` tails it. `service restart` picks up an upgrade; `service uninstall` removes the unit and leaves your session and credentials alone. `wazap login` needs the session to itself, so it stops the service, pairs, and starts it again on its own. `wazap logout` and `wazap account` changes do not stop anything: the running server applies them. A sleeping Mac is an offline wazap. System Settings → Lock Screen, or Battery → Options, has the switch that keeps it awake on power. ```bash npx wazap-mcp expose ``` That gives the running service a public `https` URL, for agents that are not on this machine: a cloud agent, claude.ai, ChatGPT. It uses Tailscale Funnel if `tailscale` is installed, Cloudflare Tunnel if `cloudflared` is, opens the tunnel, writes `WAZAP_PUBLIC_URL` and a fresh `WAZAP_OAUTH_PASSWORD` into `/.env`, restarts the service and checks the URL from here. It then prints the MCP URL and the password once. Give an agent the URL only. It signs in on a consent page on your own host with that password and picks read or read-and-send there; `wazap status` lists who holds a grant. See [Hosted agents (OAuth)](#hosted-agents-oauth) for what that page does. `npx wazap-mcp expose off` takes the tunnel down and keeps the password, so the next `expose` hands agents the same one. `npx wazap-mcp setup` asks all of this once, as its fourth step. ### Upgrade ```bash npx wazap-mcp update ``` One command for what used to be three. It compares this install against the registry, installs the new package when wazap is global, restarts the service so it runs the new code, and copies the new skills into every harness that keeps them. `--dry-run` prints the plan and touches nothing. ## Tools | Tool | Kind | What it does | | --- | --- | --- | | `learn` | read | The guide to every tool, id format and error code, as text. Call it first. | | `get_status` | read | Connection status, sync state, linked account, how fresh the history is, webhook delivery, versions, data dir. Top-level fields are the default account; `accounts` lists every configured one and `default` names it. Optional `account_id` on this and every other tool. | | `link_account` | read | Pair an account that already exists (`wazap account add`). Returns the code to type into the phone. Registered in read-only mode too. | | `list_chats` | read | Conversations newest-first; filter `all`/`unread`/`groups`/`individual`/`archived`. | | `read_messages` | read | Messages in a chat; `before` pages further back, pulling older history from the phone; `types` narrows to one or more message types, e.g. `["call"]`; `include_previews` attaches a small image of each photo. `chat_id: "status"` reads the stories of the last `hours`, which show nowhere else. | | `catch_up` | read | What the user missed, in one call and within a token budget, across every linked account: who is waiting on a reply, mentions, replies and open polls, missed calls, people, groups condensed, stories. Pages with a cursor. See [Catching up](#catching-up). | | `search` | read | Messages by meaning and by words at once, over everything the account keeps, so a paraphrase or another language still hits; `match: "words"` keeps only messages holding the words. `chat_id`, `since`, `until` and `from` narrow it, and the answer says how much it searched. Without `chat_id`, someone tagged `#private` is left out and counted in `private_omitted`. Without [semantic recall](#semantic-recall) it matches words and says so. | | `get_message` | read | One message in full, with its quoted message, each reaction with who left it, and who voted for each option of a poll or answered an event. On your own messages, `delivery` says whether it was sent, delivered, read or played, and in a group who read it and when. | | `find_contact` | read | Who a name, nickname, relationship ("mama"), group name, number or id means. Resolved: the `chat_id`, number, note, tags and details, plus the recent exchange and how you write there in a session that can send. Otherwise the candidates that tell people apart, to ask you. `tag` lists everyone filed under a tag. See [Finding people](#finding-people). | | `get_group_info` | read | Participants, admins, announcement mode, who may edit the info or add members, join approval, disappearing messages, community, invite link (when you are admin). | | `get_media` | read | A message's media: a voice note or audio as its transcript, a photo attached as an image, any file saved to disk (`save_to` picks the directory). Transcription runs on the local or the API provider; with `save_to` a recording comes as its file, with a transcript only if one was already made, and when no transcript can be made the file comes instead, with `transcript_unavailable` saying why. | | `wait_for_messages` | read | Block up to 55 s until a message arrives, then return it with a cursor for the next call. `addressed_to_me` wakes only for direct messages, @-mentions and replies. | | `remember` | local | Keep what the user says about a person, on this machine only: a note, tags, details (`relatie`, `nickname`, "role": "contabil") that `find_contact` matches, and `handled: true` to take an ask off `catch_up`'s waiting list until they write again. `#private` keeps their words out of what the assistant did not ask about them by name ([Keeping someone private](#keeping-someone-private)). Nothing changes on WhatsApp. | | `send_message` | write | Draft a message: text (a reply, @-mentions), media from a path or URL (`as`: document, voice note or GIF), a poll (`options`), a location (`latitude`, `longitude`) or a forward (`forward`). Does not send. A text draft to someone you write to often carries `style_check`: where it does not read like you. | | `confirm_send` | write | Send a draft after the user has seen the preview and said yes. A draft is sent at most once; see [Sending once](#sending-once). | | `edit_message` | write | Edit your own message, within WhatsApp's 15-minute window. | | `react_to_message` | write | Add or remove an emoji reaction. | | `delete_message` | write | `for_everyone: true` retracts your own message, within WhatsApp's 2-day window, and in a group where you are admin someone else's message too. `for_everyone: false` deletes any message for the linked account only, at any age. | | `manage_chat` | write | Archive, pin, mute (8h by default), mark read/unread; pin a message for everyone (24h, 7 days or 30 days) or star it; clear or delete the chat for the linked account; block or unblock a person. | | `manage_group` | write | Create a group; join one from an invite link or message (without `confirm: true` it only shows the group); add, remove, promote, demote, leave, rename, set or remove the group photo, invite links, list, approve or reject join requests, and change the settings. Every member sees a change at once. | ### Sending once Nothing reaches WhatsApp until `confirm_send`, and a draft goes out at most once, even across a crash. Drafts are kept in the account database for 15 minutes, at most 20 per MCP session and 200 per account, each with the WhatsApp message id it will be sent under. Confirming a draft again answers the same receipt with `already_sent: true`, and two confirms at once send it once. A failure while the message is still being prepared (not connected, the write budget, the number lookup, a missing file, a media upload) leaves the draft as it was, to confirm again. Once the message is handed to WhatsApp's relay (which also looks up the recipient's devices and encrypts it before writing), a failure answers `SEND_OUTCOME_UNKNOWN`: WhatsApp may have the message, so that draft is never sent again. The agent checks the chat instead. When WhatsApp later echoes that message id, the send is recorded as sent, and the session that confirmed it gets the receipt from then on. For 24 hours a confirmed draft answers its receipt or `SEND_OUTCOME_UNKNOWN` to that session; deleting the sent message, or clearing or deleting its chat, removes its words from the record. MCP sessions do not survive a restart: after one, no session can confirm a draft made before it, sent or not, though a send the restart interrupted is still recorded as unknown and still settles when its id is echoed. ### Catching up `catch_up` answers "what did I miss?" in one call. It reads the account database only — no network, except the cached member list of at most a dozen groups, fetched within a second — and fits its answer into `budget_tokens` (2,500 by default, 500 to 8,000), one line per entry, in this order: 1. **Waiting on you**: people whose last word asks for something — a question mark, a request word, or a voice note nobody has heard, never "ok, thanks" or a link — with the ask quoted (a voice note by its transcript) and what they said after it. In a group only when the user was @-mentioned or replied to. An ask stays until the user answers, `remember` marks it `handled` or it is two weeks old; `new` marks one that arrived since the last catch-up, and an answered call after the ask says it may have been dealt with by phone. 2. **Mentions, replies and polls**: group messages that @-mention the user or reply to them, and polls and events they have not answered, muted and archived groups included. 3. **Missed calls**, one line per person, saying whether the user called back or wrote since. 4. **People** who wrote: saved contacts first, then by how much they wrote; business accounts, numbers nobody saved and muted chats last. One quote each, with the media counted by kind. 5. **Groups**, one line each: how many messages, from how many people, the three who wrote most, media, polls, and a quote when the budget allows. Muted and archived groups share one line. 6. **Stories**: how many, and from whom. A footer names the voice notes nobody transcribed (for `get_media`) — only counting those of someone tagged `#private` — and counts what was left out: chats tagged `#no-catchup`, groups the user left, channels and broadcast lists. Signals — an amount, a date, a time, an address, a link, a question — are marked on the entries shown, and an ask carrying a sum or a date moves up. A chat's messages count as missed only after the user's own last message there and after what their phone already read. **The mark.** Each client keeps its own mark per account: the OAuth client, the token (`WAZAP_READ_TOKEN` and `WAZAP_WRITE_TOKEN` are two clients), or for stdio and the clients sharing a running wazap the MCP client's own name (`local:claude-code`, `local:cursor`), so two assistants on one machine do not share a mark. By default a catch-up reads since that client's last complete one, and moves the mark once all of it was given: a digest with no `more`, or the last page of one. The mark follows what reached wazap, not the time a message carries, so a message filed late — a missed call stored when it stops ringing, a message decrypted on a retry, one from a phone whose clock runs ahead — is in the next catch-up rather than under the mark; nothing sent more than two weeks ago counts. The first time, or when the mark is more than a week old, it reads the last 24 hours and says so. `since: "previous"` gives the last catch-up again; `hours: N` (up to 336) or `since` as an ISO date or time (`2026-09-16`, `2026-09-16T18:00`, an offset optional) from the last 14 days read an explicit window and leave the mark where it is, and so does a catch-up limited by `include`. No window reaches further back than 14 days. Two catch-ups of one client at once move the mark once. The mark moves when the last page is answered, before the answer is on its way, so a catch-up is given at most once: if the answer is lost (a dropped connection, a client that crashed), `since: "previous"` gives it again. Nothing is marked read on WhatsApp. **Paging.** When the entries do not fit, the answer ends with `more`: how many are left per section, about how many tokens they take, and a `cursor`. The first page works out the whole digest and holds it, so the next pages give exactly the rest of it, whatever arrives or is read on the phone in between. A cursor is a random id that only the client that got it can use, and it lasts 15 minutes past its page; after that, or after a restart, it is `CURSOR_EXPIRED`: call `catch_up` again without it, the mark has not moved. **Several accounts.** Without `account_id`, a catch-up covers every linked account at once, each section labelled per account, sharing the budget. A disconnected account is reported as disconnected, with what it had stored, and keeps its mark. **Leaving a chat out.** Tag a person `#no-catchup` with `remember` (an agent, a bot, a busy notification number) and catch-ups skip their chat, counting it in the footer, and nothing they send elsewhere shows either: no ask, mention, poll or quote of theirs in a group, no group call, no story. Tag them `#private` instead and they stay in, counted, but nothing they wrote is quoted — not the ask, not a mention or a poll of theirs in a group, not a group's quote — and their entries say `private` ([Keeping someone private](#keeping-someone-private)). A person tagged on any linked account is tagged on every account a catch-up reads, by number or lid, with `account_id` or without. ### Seeing, waiting, following up `include_previews: true` on `read_messages` attaches a small JPEG of each photo as an image block, newest first, up to 12 per call, and labels each message line with the preview it belongs to, so a catch-up can say "a photo of a receipt" without a download. WhatsApp used to ship such a preview inside every image message and in 2026 almost never does, so when none is there wazap downloads the photo once, shrinks it to 320 px on this machine with pure JavaScript, and keeps the result as a file under `previews/` in the data directory, so a restart does not redo it. A video gets one frame, taken by ffmpeg a second in, when ffmpeg is installed. The first call over a day of photos takes a few seconds; the next is instant. `wait_for_messages` blocks until something arrives, up to 55 seconds, then returns it with a `cursor`. Calling it again with that cursor replays whatever landed in between, so an agent can sit in a loop and miss nothing. With `addressed_to_me` only direct messages, @-mentions of the user and replies to their messages wake it; group chatter does not. The user's own messages and WhatsApp's notices never do. Every message comes back with a non-empty `text`: media and system messages carry a placeholder such as `[image] caption`, `[voice message · 0:42]`, `[deleted]` or `[poll] Pizza or pasta?`. A poll also carries each option with who voted for it, and an event who answered going, maybe or not going. Timestamps are ISO 8601 with the machine's UTC offset, alongside a human `age` like `2h ago`. ### Finding people `find_contact` answers "who is mama?", "Ana de la contabilitate" or "Mișu" before anything is drafted. It reads the names wazap keeps for a person — the saved contact name, a business name, the name they give themselves — and what you filed about them: a `nickname` or `relatie` detail, a tag, and a note that says nothing but the relationship ("mama"). Case, diacritics and Romanian case endings do not matter ("Stefan" is Ștefan, "mamei" is mama), a short form finds the full name ("Mișu" is Mihai) below the name itself, and a group is found by its name. A relationship word matches only what you filed, never a message and never a name like "Mama Anei" or "Mamaia Resort". People you talk to more, and more recently, rank higher. `qualifier` tells two of a name apart: a tag, a detail, a note, a business or a group they write in ("contabilitate"), or the last four digits of the number. The answer is one of three: - **resolved** — one person or group is clearly meant: `contact.chat_id`, the full id to send to, with what matched. - **ambiguous** — up to five candidates and what tells them apart: when you last exchanged messages and in which direction, how many you sent them in 90 days, groups in common, your note and tags, whether it is a business, and the number's last four digits. No candidate carries a full number or a word of any message, so the agent has to ask you and look the one you name up again. - **not_found** — the closest names, if any. For a relationship nobody is filed under, the agent is told to ask who it is and file it with `remember` (`fields: {"relatie": "mama"}`). Without `account_id`, every linked account is searched and each candidate says which account it is on; the answer is resolved only when one account has the only match. A resolved person also comes with their number and what the user filed on them (note, tags, details). A number, however it is written ("+40 722 001 111", "0722-001-111"), or an id (a sender's `id` from a message) is looked up as such: `resolved` with `matched.source` `number` or `id`, or `not_found` telling the agent to check the number. `find_contact({ tag: "client" })` lists everyone filed under a tag instead, each with their `chat_id`: up to `limit` (50), shared between the accounts, with `omitted` counting on each account whoever the limit left out. **Draft context.** A resolved contact also carries what a message to them is written after: the last 8 messages both ways (each cut to 200 characters, voice notes as their transcript) and how you write there — language, diacritics, tu or dumneavoastră, length, emoji — from your own messages in that chat in the last 90 days, or across the account when there are fewer than five. The style never counts messages wazap sent, so an agent does not learn its own drafts back. It is on by default, only in a session that can send, and only for a resolved contact the account's [send rules](#send-rules) allow. A contact tagged `#private` (`remember` with `add_tags: ["private"]`) on any linked account gets the style only, never messages, and a group's context leaves out what they wrote ([Keeping someone private](#keeping-someone-private)). `wazap config draft-context off [--account ]` turns it off for an account (`draft_context: false` in `accounts.json`), the style check below included, from the next call, without a restart. **Style check.** A text draft to a person you have written to yourself (not through wazap) at least five times in 90 days comes back with `style_check`: `warnings` among `language_mismatch`, `diacritics_mismatch` (with diacritics where you write without them, or words like „mâine” without them where you use them), `address_mismatch` (tu where you say dumneavoastră, or the reverse) and `length_outlier` (over three times your usual longest there, and over 80 characters), with the `basis` it was measured on. What the draft quotes does not count, nor "doamna" said of someone else, nor a plural spoken to two people. It never blocks a draft; words you dictated stay as they are. **The address book.** Names come from the phone. When no contact has a saved name yet, the first `find_contact` of a server run asks WhatsApp for the address book, the way `wazap contacts resync` does, and waits up to 15 seconds for it before answering; finds that arrive meanwhile wait for the same answer. It does not ask while the connection is still receiving its first sync, nor again within 7 days of the last ask, the same rule wazap heals a missing address book by at connect. ### Keeping someone private Tag a person `#private` (`remember` with `add_tags: ["private"]`) and their words — a message's text, caption, transcript, quote, a link's or a file's preview, a poll's text — stay out of what the assistant did not ask about them by name. A call names them when it gives their chat (`chat_id`), a message of theirs (`message_id`, to `get_message` or `get_media`), or them as the author (`search` with `from`); a group named by `chat_id` reads whole, what they wrote in it included. Everywhere else what is theirs keeps who, when, in which chat and what kind, and loses the words: - `catch_up` counts them and never quotes them; their entries say `private`. - `find_contact`'s draft context carries your style for them, no messages. - `search` without `chat_id` leaves out their chat and what they write in groups before it counts to `limit`, by meaning and by words, and says how many in `private_omitted`. A quote of theirs in someone else's message keeps who wrote it, not what. - `wait_for_messages` without `chat_id` still returns what arrived from them, in their chat or in a group, with `text: "[private]"` and `private: true`; waiting on one chat, theirs or a group's, reads it whole. - `list_chats` shows the last message of their chat, or the last one they wrote in a group, the same way. - `read_messages` on `"status"` keeps their stories with author, time and kind, without text, caption or preview. A story cannot be named, so for now there is no way to read one of theirs through the assistant. The tag goes with the person: filed on one account, it holds in `search`, `wait_for_messages`, `list_chats`, the stories and `find_contact`'s draft context of every other account, by number or lid, and in every catch-up. The [outbound webhook](#outbound-webhook) is not affected; it is the channel for what you build, not the assistant's. ## Voice messages A voice note is the one message an agent cannot read. Switch transcription on and it becomes text: `[voice message · 0:42] "sunt la notar, ajung în 20 de minute"`, with the bare words also in a `transcript` field. `catch_up` and `search` see that text, so a voice note becomes findable by what was said in it. Pick a provider once, in `wazap setup` or later: ```bash wazap config transcribe local # free and private, one 574 MB model on disk wazap config transcribe openai # cheap and fast, the audio leaves this machine wazap config transcribe off ``` | | `local` | `openai` | | --- | --- | --- | | Runs | whisper.cpp, here | any OpenAI-compatible `/audio/transcriptions` | | Costs | nothing | per minute of audio, on your key | | Privacy | the audio never leaves this machine | **the audio leaves this machine** | | Needs | `whisper-cpp` and `ffmpeg`, plus a model | an API key | ### Local, with whisper.cpp ```bash brew install whisper-cpp ffmpeg # macOS; elsewhere build whisper.cpp, install ffmpeg from your package manager wazap transcribe download # fetch and verify the model wazap transcribe test recording.ogg # prove it before you trust it ``` `wazap setup` and `wazap transcribe download` offer that `brew install` themselves when either binary is missing, and go straight on to the model in the same run. `--no-brew` turns the offer off everywhere. Models land in `/models/` and are checked against a SHA-256 pinned in the source. The shared whisper/embedding downloader stops an oversized response before excess bytes are written, independently of `Content-Length`. Only a successfully closed write with the exact size and digest is renamed from `.part` to the final model file. The network/write phase has a 30-second no-progress timeout (including waiting for response headers) and an overall deadline of 30 minutes or the time the model takes at 100 KiB/s, whichever is longer (about three hours for large-v3). A timeout or interrupted transfer keeps a bounded partial file for a later retry to resume; an invalid range, oversized response or failed verification discards it. A receiver that ignores Range restarts the download safely. CDN redirects remain supported, but compressed responses are refused so byte ranges remain unambiguous. Errors report status/category, not signed URLs, response excerpts or raw disk errors. Each destination has an exclusive `.download-lock/` directory, held from cache verification through the final rename and cleanup. A simultaneous download of that model fails promptly with a retry hint; different models can download in parallel. Directory symlinks and relative paths use the same canonical parent. The lock is released on success, handled failures and cancellation; if it cannot be removed, the verified model is kept and the next run names the directory. A known dead owner on the same host/PID scope can be recovered automatically; Linux also checks the PID namespace. Live owners are never evicted by age. If a process dies during lock initialization/cleanup, or the owner record is corrupt, from another scope or inaccessible, recovery fails closed. Inspect the `owner-*.json` inside the lock directory and remove **only that lock directory** only after confirming no downloader is still using the model. Then rerun the command to reuse the partial file when possible. Never remove an active lock. This coordinates cooperating versions on one host, not distributed downloads across machines; stop older downloaders before upgrading. The model is whisper large-v3-turbo (`ggml-large-v3-turbo-q5_0.bin`, 574 MB), the smallest that still gets Romanian right. Smaller models drop diacritics and mangle names, which is worse than no transcript at all: a missing transcript is a question, a wrong name is a wrong answer. ### An API, OpenAI-compatible `wazap config transcribe openai` asks for the key without echoing it, then for the base URL (OpenAI unless you type another), and stores both in `/.env`. **With this provider the audio leaves your machine.** Every voice note wazap transcribes is uploaded to that endpoint. If that is not acceptable, use `local`, which uploads nothing. The key is treated as a secret rather than as a setting: - It is never accepted as a command-line argument, because an argument lands in your shell history and in `ps`. - The prompt echoes nothing, not even asterisks. - It is stored only in `/.env`, mode `0600`. - `status`, `status --json`, `config` and `get_status` show at most `api key: set (…abcd)`. - Provider error bodies, transport exception details and malformed-JSON excerpts are not printed. Errors retain HTTP status, timeouts and actionable fixes. - A plain-`http` base URL is refused unless it points back at this machine. Userinfo credentials, queries and fragments are not allowed in this base URL; set the API key separately. - Redirects are refused, including same-origin redirects: configure the final base endpoint directly. This keeps audio and credentials on the intended route. - Successful JSON responses are capped at 1 MiB, including chunked responses. Error response bodies are discarded without being read. ### Without being asked With a provider configured, incoming voice notes of up to ten minutes are transcribed in the background as they arrive, never holding up a message. The transcript is stored with the message, so a voice note is transcribed once, and its words are searchable, recalled and carried by the webhook event. - **Durable.** The note is queued in the account database in the same transaction that stores it, so a restart or a crash resumes the queue instead of dropping it. A note that just arrived starts at once, ahead of any backlog, which is what lets its webhook event carry the words. A stop waits up to 30 s for a transcription under way to store its words, so a note is not paid for twice; removing an account cancels it instead. - **One at a time for the whole server.** Every account shares one transcriber and they take turns, so a backlog on one does not starve another and two whisper.cpp runs never fight for the machine. Only the server (`wazap serve`, the service) transcribes; short commands such as `wazap status --live` queue what arrives and leave it to the server. - **Retried, then given up on.** A download that times out, a provider answering 429 or 5xx, or whisper.cpp crashing is tried again after 10 s and after a minute more, three attempts in all. Media WhatsApp no longer holds, audio the provider refuses as input, or a file too large gives up at once. A note given up on is not queued again; `get_media(message_id)` still tries it on request. - **Waiting costs nothing.** A note whose account is disconnected spends no attempt and runs within seconds of the connection opening. A provider that cannot take any note — whisper.cpp or its model missing, an API refusing the key — pauses all transcription for 30 s, then twice as long each time up to 15 minutes, and one note probes it before any other audio is downloaded. Meanwhile webhook events post the `[voice message · 0:42]` placeholder at once instead of waiting for words that are not coming. - **Deleted means dropped.** A note deleted, expired or cleared while it waits leaves the queue and is never uploaded. - **A day at most.** A note still waiting 24 hours after it was queued (the account offline, the provider paused) is given up on as `too_old` and never transcribed on its own later. - **Local stays local.** Each note remembers whether it was queued for `local` or for an API. A note queued under `local` is never sent to an API provider configured afterwards: it is given up on as `provider_changed`. A note queued for an API may still be transcribed locally. - **History: the last day only.** A note that a history sync brings (a first link, a relink) is queued only when it is less than 24 hours old, so linking never transcribes the archive. A note WhatsApp delivers live is always queued, however old its timestamp. Audio *files* are left alone, since one can be an hour long, and so are notes you recorded and notes WhatsApp gave no length for; call `get_media(message_id)` for those. `WAZAP_TRANSCRIBE_AUTO=0` keeps that and stops the background work; with it, or with the provider switched off, a queue already stored is kept and waits, and it continues under the provider configured next, within the day and the local-stays-local rule. `get_status` shows the queue under `transcription` (how many wait, how long the current run has taken, how many were given up on, the latest reason, a pause and until when, never content), and `wazap status` prints a `voice queue` line, a warning when notes wait and nothing will run them. ## Semantic recall With recall on, `search` matches what was meant and the words at once: a paraphrase or another language still hits through its meaning, a short or foreign-language question through its words, and the two rankings are fused. It reaches every message the account keeps. For an exact string — an id, a phone number, a URL — pass `match: "words"`. When meaning search cannot run — recall off, the embedding server failing or refusing the query, or the sidecar still starting after 8 s — `search` matches the words only and says so (`mode: "keyword_fallback"`, with `recall_unavailable` naming the cause and, for recall off, the command that turns it on). Off by default, and fully local: a `llama-server` sidecar bound to loopback does the embedding, so nothing leaves the machine. It needs llama.cpp, the pinned model and persisted history (`WAZAP_PERSIST_HISTORY`, on by default): ```bash brew install llama.cpp # macOS; elsewhere build llama.cpp and put llama-server on PATH wazap embed download # fetch the embedding model, ~318 MB sha256-verified wazap config recall local # then restart the service ``` `wazap embed download` offers the `brew install` itself when `llama-server` is missing. `wazap status` runs the three checks — `recall`, `llama-server`, `embed model` — and `get_status` reports the index as `off`, `indexing`, `ready` or `degraded`. `chat_id`, `since`, `until` and `from` narrow a search by meaning exactly as they narrow one by words. Hits rank by a fused score (reciprocal rank fusion of the word and meaning rankings), and a hit found only by meaning must clear the similarity floor, so a question with no answer comes back empty. A match found by meaning weighs a little less with age — 85% a month on, never under 70%, for its rank and for the floor — so the fresher of two close matches comes first while a clearly closer old one still does, and a word hit whose meaning falls under the floor ranks by its words alone. One chat takes at most three leading places before other chats' hits, and a near-duplicate trails the list. The vectors live in the account database next to their messages, are made in the background for every message that has none, and leave with their message when it is deleted, revoked or expires; an edit makes its vector again. A message wazap holds only as text — carried over from the recall index an older wazap built — is marked `from_index`: `get_message` returns its text, but `get_media` has nothing to open and it cannot be replied to or forwarded. Embedding requests refuse redirects, cap replies at 4 MiB and validate vector shape and finite values. Provider bodies and decoder stderr are not copied into errors. `wazap config recall local|off` sets `WAZAP_RECALL`, the one setting recall has. The model is embeddinggemma-300m, and every kept message is indexed. ## Skills wazap ships five [Agent Skills](https://agentskills.io) that teach an agent the workflows behind the tools, not just the tools: | Skill | What the agent does | | --- | --- | | `wazap-setup` | Diagnose with `wazap status`, link by QR or pairing code, connect a client with `wazap connect`, repair an expired session | | `whatsapp-inbox` | "What did I miss?" Triage into *needs you / FYI / noise*, ranked, plus forgotten replies. Read-only | | `whatsapp-recall` | "Find the invoice Dan sent." Search with query variants, page back in time, download and read the file. Read-only | | `whatsapp-groups` | Catch up on a 300-message group: decisions, dates, what is asked of you. Read-only | | `whatsapp-send` | Find who the user means, draft in the chat's own register, show recipient and text, send only after the user says yes | `wazap setup` copies them into every client it connects, so there is usually nothing to run. The command behind it, for a harness `setup` never offered or for a checkout you want to install by hand: ```bash npx wazap-mcp skills install codex # or claude-code, cursor, agents ``` With no harness named it installs into every client it finds on this machine. For Claude Code the other route is the plugin, which carries the server as well: ``` /plugin marketplace add razvangirgiz/wazap /plugin install wazap@wazap ``` | Harness | Where the five directories land | | --- | --- | | `claude-code` | `~/.claude/skills/` | | `codex` | `~/.agents/skills/`, the directory Codex documents for user skills. Cursor reads it too | | `cursor` | `~/.cursor/skills/` | | `agents` | `./.agents/skills/`, in the current project, for anything that reads the cross-tool convention | Re-running overwrites, so an upgrade is the same command. `--dry-run` lists what it would copy. A client with no skills directory is not left out. The server registers each of the five as an MCP prompt of the same name, and sends a short `instructions` block that names all five and says when each applies, so an agent that never saw the skill files still follows them. That is how Claude Desktop, Gemini CLI and any client wired by hand get the workflows. A bridged session and a self-hosted HTTP server carry them the same way. ## Errors Every failure is a structured `{ error, message, fix }` rather than a stack trace, so an agent can decide whether to retry, ask the user, or stop. | Code | Meaning | | --- | --- | | `NOT_LINKED` | No account linked. Call `link_account`, or run `npx wazap-mcp login`. | | `ALREADY_LINKED` | `link_account` was called on a session that is already linked. Call `get_status`. | | `SESSION_EXPIRED` | Unlinked from the phone. Run `npx wazap-mcp login`. | | `SESSION_CORRUPT` | Credentials unreadable. Run `npx wazap-mcp logout` then `login`. | | `NOT_CONNECTED` | Still connecting or reconnecting, or preparing the account database once after an upgrade. | | `SYNC_IN_PROGRESS` | History sync has not finished; results may be partial. | | `INVALID_PHONE` | Number is not in international format. | | `INVALID_ID` | Not a WhatsApp chat, contact or group id. | | `NOT_ON_WHATSAPP` | WhatsApp answered that the number has no account. A lookup it did not answer is `NOT_CONNECTED`. | | `CHAT_NOT_FOUND` / `MESSAGE_NOT_FOUND` / `CONTACT_NOT_FOUND` / `GROUP_NOT_FOUND` | Unknown id. | | `NOT_A_PARTICIPANT` / `NOT_ADMIN` / `GROUP_ANNOUNCEMENT_ONLY` | Group permissions. | | `MEDIA_UNAVAILABLE` | WhatsApp expired the file, or it was never synced here. | | `FILE_NOT_FOUND` / `FILE_TOO_LARGE` / `URL_FETCH_FAILED` / `INVALID_IMAGE` | Outbound media problems. | | `TEXT_TOO_LONG` | Over WhatsApp's message limit. | | `EDIT_WINDOW_EXPIRED` / `RETRACT_WINDOW_EXPIRED` / `NOT_OWN_MESSAGE` | WhatsApp's own limits on editing and deleting. | | `READ_ONLY` | wazap is running read-only. | | `RATE_LIMITED` | Too many writes; `fix` says how long to wait. | | `DRAFT_NOT_FOUND` / `DRAFT_EXPIRED` | The draft is unknown, from another MCP session, sent more than 15 minutes ago, or expired unsent. Draft again. | | `SEND_OUTCOME_UNKNOWN` | The message reached the socket and then the send failed, so WhatsApp may have it. The draft is never sent again; check the chat before drafting anew. | | `SEND_BLOCKED` | The account's send rules refuse this recipient. `wazap config send` changes them; the agent must not route around. | | `AMBIGUOUS_ACCOUNT` | More than one account could handle this, or a write named a chat no account knows. Pass `account_id`. | | `ACCOUNT_NOT_FOUND` | No account with that id. Run `wazap account add`; `get_status` lists the ids. | | `ACCOUNT_DISABLED` | That account is disabled. Run `wazap account enable `; a running server picks it up. | | `TIMEOUT` / `WHATSAPP_ERROR` | WhatsApp did not answer, or rejected the operation. | ## Data directory Everything lives in `~/.wazap` (override with `--data-dir` or `WAZAP_DATA_DIR`), created `0700` with credentials written `0600`. A data dir from before several accounts moves into `accounts/default/` the first time a wazap command runs. ``` ~/.wazap/ accounts.json which accounts exist, and which is default accounts.json.required empty marker: missing policy must not reset permissions accounts// auth/ WhatsApp credentials — treat this like a password media/ files saved by get_media wazap.sqlite the account database: chats, contacts, messages, reactions, receipts, transcripts, notes, recall vectors, deletion barriers, the webhook outbox (plus -wal and -shm beside it) previews/ one small JPEG per photo or video already previewed qr.png last QR, when login showed one legacy/ an earlier wazap's store.json, history/, retention.json, notes.json and recall/, once imported; deleted a week later wazap.