{
  "markdown": "```\n██╗    ██╗ █████╗ ███████╗ █████╗ ██████╗\n██║    ██║██╔══██╗╚══███╔╝██╔══██╗██╔══██╗\n██║ █╗ ██║███████║  ███╔╝ ███████║██████╔╝\n██║███╗██║██╔══██║ ███╔╝  ██╔══██║██╔═══╝\n╚███╔███╔╝██║  ██║███████╗██║  ██║██║\n ╚══╝╚══╝ ╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝╚═╝\n```\n\n**WhatsApp for your AI agent.** An MCP server that puts your WhatsApp account —\nchats, messages, media, contacts, groups — behind 31 tools any MCP client can\ncall. Pairing-code login, no browser, no phone-number reseller, ~20 MB of RAM.\n\nBuilt on [Baileys](https://github.com/WhiskeySockets/Baileys), which speaks the\nWhatsApp multi-device protocol over a WebSocket.\n\n## Get started\n\nThe npm package is `wazap-mcp`; the command it installs is `wazap`.\n\n```bash\nnpx wazap-mcp setup\n```\n\nThat is the whole install. It links your account, finds the MCP clients\ninstalled on this machine, writes their config, copies the five skills where\nthat client reads them, and tells you what to restart. At a terminal it is one\nblack, centered screen per step: ghosted ASCII logo, step number, then the\nQR or the question. Piped output stays a log. When you\nstarted through `npx`, `setup` offers to install wazap globally so Claude\nDesktop and the background service have a path that does not change. It also\noffers to `brew install` whisper-cpp, ffmpeg or Tailscale when a step needs one\nand it is missing, and to restart Claude Desktop itself once it has connected it.\n\n### Or the path your harness prefers\n\n| Harness | Fastest path |\n| --- | --- |\n| Claude Code | `/plugin marketplace add razvangirgiz/wazap`, then `/plugin install wazap@wazap` |\n| Claude Desktop | download `wazap-<version>.mcpb` from [Releases](https://github.com/razvangirgiz/wazap/releases) and double-click it |\n| Gemini CLI | `gemini extensions install https://github.com/razvangirgiz/wazap` |\n| Cursor | the [Install in Cursor](#other-mcp-clients) badge, then `npx wazap-mcp skills install cursor` |\n| VS Code | the [Install in VS Code](#other-mcp-clients) badge |\n| Codex CLI | `npx wazap-mcp connect codex`, then `npx wazap-mcp skills install codex` |\n| OpenCode | `npx wazap-mcp connect opencode`, then `npx wazap-mcp skills install opencode` |\n| Windsurf | `npx wazap-mcp connect windsurf` |\n| Anything else | the MCP entry `npx -y wazap-mcp` over stdio, or a [self-hosted](#self-host) URL |\n\nEach of those registers the server. Linking the WhatsApp account is a separate,\none-time step in every one of them: `npx wazap-mcp login`.\n\nOr have your agent do it. Paste this:\n\n*Set up WhatsApp for me: run `npx wazap-mcp setup --agent` and follow what it prints.*\n\nThen ask your agent: *\"what did I miss on WhatsApp today?\"*\n\nBelow are the steps `setup` runs for you. Each is still its own command when you\nwant to run it by hand.\n\n`npx wazap-mcp login` shows a QR code; scan it from **Settings → Linked devices\n→ Link a device**. No camera handy, or linking over SSH? `npx wazap-mcp login --phone +15550100`\nprints an 8-character code you type under *Link with phone number instead*.\nIt ends by asking whether the agent may send messages; the answer is no unless\nyou say yes, and `npx wazap-mcp config writes on` changes it later.\n\n`npx wazap-mcp connect claude-code` writes the MCP entry for one client. The\ntable under **Connect a client** has the rest.\n\n`npx wazap-mcp` on its own is safe to run: it prints where you stand and what to do\nnext, and starts no server. When something is off, `npx wazap-mcp status` is the\nfirst thing to run — it checks Node, the data directory, the lock, the\ncredentials and whether a newer version is out, and prints the fix next to\nanything broken.\n\n### Connect a client\n\n`wazap connect <client>` writes the entry for you, keeping whatever else is in\nthe file and backing it up once before the first change. `--dry-run` shows what\nit would write.\n\n| Client | What `connect` writes |\n| --- | --- |\n| `claude-code` | runs `claude mcp add whatsapp` for you |\n| `claude-desktop` | `claude_desktop_config.json` in the Claude application directory |\n| `cursor` | `~/.cursor/mcp.json` |\n| `codex` | `[mcp_servers.whatsapp]` in `~/.codex/config.toml` |\n| `vscode` | `./.vscode/mcp.json`, for the current workspace |\n| `gemini` | `~/.gemini/settings.json` |\n| `windsurf` | `~/.codeium/windsurf/mcp_config.json` |\n| `opencode` | `mcp.whatsapp` in `~/.config/opencode/opencode.json` |\n| anything remote | client's MCP URL field: `https://your-host/mcp` with header `Authorization: Bearer <token>`, or just the URL once [OAuth](#hosted-agents-oauth) is on (see [Self-host](#self-host)) |\n\n### Other MCP clients\n\nCursor and VS Code install from a link:\n\n[![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.png)](cursor://anysphere.cursor-deeplink/mcp/install?name=whatsapp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIndhemFwLW1jcCJdfQ)\n[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](vscode:mcp/install?%7B%22name%22%3A%22whatsapp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22wazap-mcp%22%5D%7D)\n\nBoth carry the same entry `connect` writes. Where a custom scheme is stripped\nbefore you can click it, VS Code also takes\n[the https form](https://insiders.vscode.dev/redirect/mcp/install?name=whatsapp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22wazap-mcp%22%5D%7D).\n`node scripts/badges.mjs` reprints all three.\n\nAny other MCP client works the same way: the command is `npx -y wazap-mcp`, the\ntransport is stdio. Tell the agent to call `learn` first — it returns the id\nformats, the workflows and every error code with what to do about it.\n\n<details>\n<summary>The raw entries, for editing by hand</summary>\n\n```json\n{\n  \"mcpServers\": {\n    \"whatsapp\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"wazap-mcp\"]\n    }\n  }\n}\n```\n\nClaude Desktop, Cursor, Gemini CLI and Windsurf take exactly that. VS Code nests\nit under `servers` and wants a `\"type\": \"stdio\"` alongside `command`. Codex CLI\nis TOML:\n\n```toml\n[mcp_servers.whatsapp]\ncommand = \"npx\"\nargs = [\"-y\", \"wazap-mcp\"]\n```\n\nOpenCode takes the command and its arguments as one array, under `mcp`:\n\n```json\n{\n  \"mcp\": {\n    \"whatsapp\": { \"type\": \"local\", \"command\": [\"npx\", \"-y\", \"wazap-mcp\"] }\n  }\n}\n```\n\n</details>\n\nThe `skills/` folder follows the [Agent Skills](https://agentskills.io) format, so Codex, Cursor and other skill-aware agents can load the same five skills.\n\n### Gemini CLI\n\n```bash\ngemini extensions install https://github.com/razvangirgiz/wazap\n```\n\nThat reads `gemini-extension.json` at the repo root, so it registers the MCP\nserver and loads `GEMINI.md` — the five skills below, concatenated, because the\nGemini CLI takes one context file per extension rather than a skills directory.\n`wazap connect gemini` writes the server alone, without the context.\n\n`GEMINI.md` is generated: `npm run context:build` rebuilds it from\n`skills/*/SKILL.md`, and a test fails if the two have drifted, so a workflow is\nonly ever edited in its skill.\n\n### Claude Desktop, without a terminal\n\nDownload `wazap-<version>.mcpb` from [Releases](https://github.com/razvangirgiz/wazap/releases)\nand double-click it. Claude Desktop installs the server, its Node dependencies\nand the icon, and shows two settings: **Read-only**, ticked, and **Data\ndirectory**, empty. `wazap connect claude-desktop` does the same job by editing\n`claude_desktop_config.json`. Claude Desktop starts its servers without your\nshell PATH, so that entry is the absolute path to `node` when wazap is installed\nglobally, and `npx` otherwise; `wazap setup` checks that the entry it wrote is\none Claude Desktop can actually launch.\n\nThen ask Claude to link your WhatsApp. It calls `link_account` with your number,\nhands back an 8-character code, and you type that code into **WhatsApp →\nSettings → Linked devices → Link a device → Link with phone number instead**.\nNo terminal at any point. `npx wazap-mcp login` does the same job from a shell\nwhen you have one.\n\nUntick **Read-only** to let Claude send. It ships ticked because a bundle that\ncan message people from your number before you have said so is the wrong\ndefault, and because the setting cannot be left unanswered: the manifest format\nhas no way to omit an argument, so the box you see is the answer the server gets.\n`link_account` is registered either way. Read-only exists to stop Claude\nmessaging people from your number, and relinking your own dead session messages\nnobody.\n\nBuild it yourself with `npm run bundle:mcpb`, which stages `dist/`, the\nmanifest, the icon and a fresh production `node_modules`, then packs them with\n[`@anthropic-ai/mcpb`](https://github.com/modelcontextprotocol/mcpb).\n\n### Keep it running\n\nA wazap started by a client lives as long as that client does. Quit Claude Code\nand the session is gone until you open it again. Two commands change that.\nStaying up and being reachable are separate choices.\n\n```bash\nnpx wazap-mcp service install\n```\n\nThat writes a launchd agent on macOS (`~/Library/LaunchAgents/com.wazap.server.plist`)\nor a systemd user unit on Linux (`~/.config/systemd/user/wazap.service`), starts\nit, and waits for `/healthz` to answer. The unit runs `serve --http` on\n`127.0.0.1:8766` with the absolute path of this Node and this install, so it\nsurvives a reboot and a logout. Point any client at\n`http://127.0.0.1:8766/mcp`, or keep using the stdio entry. A second wazap on\nthe same data directory becomes a bridge onto the session this one holds.\n\n`service status` prints the pid, the health check and whether the unit still\nruns the version you have installed. `service logs` tails it. `service restart`\npicks up an upgrade; `service uninstall` removes the unit and leaves your\nsession and credentials alone. `wazap login` needs the session to itself, so it\nstops the service, pairs, and starts it again on its own.\n\nA sleeping Mac is an offline wazap. System Settings → Lock Screen, or Battery →\nOptions, has the switch that keeps it awake on power.\n\n```bash\nnpx wazap-mcp expose\n```\n\nThat gives the running service a public `https` URL, for agents that are not on\nthis machine: a cloud agent, claude.ai, ChatGPT. It uses Tailscale Funnel if\n`tailscale` is installed, Cloudflare Tunnel if `cloudflared` is, opens the\ntunnel, writes `WAZAP_PUBLIC_URL` and a fresh `WAZAP_OAUTH_PASSWORD` into\n`<data-dir>/.env`, restarts the service and checks the URL from here. It then\nprints the MCP URL and the password once.\n\nGive an agent the URL only. It signs in on a consent page on your own host with\nthat password and picks read or read-and-send there; `wazap status` lists who\nholds a grant. See [Hosted agents (OAuth)](#hosted-agents-oauth) for what that\npage does. `npx wazap-mcp expose off` takes the tunnel down and keeps the\npassword, so the next `expose` hands agents the same one.\n\n`npx wazap-mcp setup` asks all of this once, as its fourth step.\n\n### Upgrade\n\n```bash\nnpx wazap-mcp update\n```\n\nOne command for what used to be three. It compares this install against the\nregistry, installs the new package when wazap is global, restarts the service so\nit runs the new code, and copies the new skills into every harness that keeps\nthem. `--dry-run` prints the plan and touches nothing.\n\n## Tools\n\n| Tool | Kind | What it does |\n| --- | --- | --- |\n| `learn` | read | The guide to every tool, id format and error code. Call it first. |\n| `get_status` | read | Connection status, sync state, linked account, named-contact count, versions, data dir. |\n| `link_account` | read | Pair the account without a terminal: returns the code to type into the phone. Registered in read-only mode too. |\n| `list_chats` | read | Conversations newest-first; filter `all`/`unread`/`groups`/`individual`/`archived`. |\n| `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. |\n| `get_recent_messages` | read | Everything from the last N hours, grouped by chat. The catch-up tool. `include_system` adds WhatsApp's own notices, `types` narrows to one or more message types, `include_previews` attaches a small image of each photo, `compact` halves it for a routine catch-up. |\n| `get_unanswered` | read | Who is waiting on the user: chats whose last word is theirs and asks for something, with the ask quoted. Groups only when the user was @-mentioned or replied to. |\n| `set_contact_note` | local | Remember something about a person, on this machine only; it then shows next to their name everywhere. |\n| `mark_handled` | local | Take a chat off `get_unanswered` until the other side writes again. Nothing changes on WhatsApp. |\n| `get_stories` | read | The stories (status updates) received in the last day, by author, with previews on request. They show nowhere else. |\n| `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. |\n| `search_messages` | read | Text search across the locally held messages; `since`, `until` and `from` narrow it. |\n| `get_message` | read | One message in full, with its quoted message and reactions. |\n| `search_contacts` | read | Find contacts by name or number. |\n| `sync_contacts` | read | Fetch the phone's address book from WhatsApp again, when names are missing. |\n| `get_contact` | read | Name, number, about text, profile picture. |\n| `get_group_info` | read | Participants, admins, announcement mode, invite link (when you are admin). |\n| `download_media` | read | Save an attachment to disk; small images also come back inline. |\n| `transcribe_audio` | read | Turn a voice note or audio message into text, with the local or the API provider. |\n| `send_message` | write | Draft text, optionally as a reply, with @-mentions. Does not send. |\n| `send_media` | write | Draft an image, video, audio, voice note, document or GIF (`as_gif`: an mp4 loops, a .gif is converted with ffmpeg) from a path or URL. Does not send. |\n| `send_poll` | write | Draft a poll with 2–12 options. Does not send. |\n| `send_location` | write | Draft a map pin. Does not send. |\n| `edit_message` | write | Edit your own message, within WhatsApp's 15-minute window. |\n| `react_to_message` | write | Add or remove an emoji reaction. |\n| `forward_message` | write | Draft a forward to another chat. Does not send. |\n| `confirm_send` | write | Send a draft after the user has seen the preview and said yes. |\n| `delete_message` | write | Retract your own message, within WhatsApp's 2-day window. |\n| `manage_chat` | write | Archive, pin, mute (8h by default), mark read/unread. |\n| `create_group` | write | Create a group and add participants. |\n| `manage_group` | write | Add, remove, promote, demote, leave, rename, invite links. |\n\n### Seeing, waiting, following up\n\n`include_previews: true` on `get_recent_messages` or `read_messages` attaches a\nsmall JPEG of each photo as an image block, newest first, up to 12 per call,\nand labels each message line with the preview it belongs to, so a catch-up can\nsay \"a photo of a receipt\" without a download. WhatsApp used to ship such a\npreview inside every image message and in 2026 almost never does, so when\nnone is there wazap downloads the photo once, shrinks it to 320 px on this\nmachine with pure JavaScript, and keeps the result as a file under\n`previews/` in the data directory, so a restart does not redo it. A video gets\none frame, taken by ffmpeg a second in, when ffmpeg is installed. The first\ncall over a day of photos takes a few seconds; the next is instant.\n\n`wait_for_messages` blocks until something arrives, up to 55 seconds, then\nreturns it with a `cursor`. Calling it again with that cursor replays whatever\nlanded in between, so an agent can sit in a loop and miss nothing. With\n`addressed_to_me` only direct messages, @-mentions of the user and replies to\ntheir messages wake it; group chatter does not. The user's own messages and\nWhatsApp's notices never do.\n\n`get_unanswered` returns the chats whose last word is the other side's and reads\nas an ask: a question mark, a request word, or a voice note nobody has heard\nyet. \"Ok, thanks\" is not an ask, a link is not a question, and an ask older\nthan two weeks (`max_age_hours`) was abandoned rather than left waiting. People\ncome first, then the oldest wait, each with the ask quoted and how long they\nhave been waiting; a WhatsApp Business account is marked, since its asks are\noften automatic replies.\n\nEvery message comes back with a non-empty `text`: media and system messages\ncarry a placeholder such as `[image] caption`, `[voice message · 0:42]`, `[deleted]` or\n`[poll] Pizza or pasta?`. Timestamps are ISO 8601 with the machine's UTC offset,\nalongside a human `age` like `2h ago`.\n\n## Voice messages\n\nA voice note is the one message an agent cannot read. Switch transcription on and\nit becomes text: `[voice message · 0:42] \"sunt la notar, ajung în 20 de minute\"`,\nwith the bare words also in a `transcript` field. `get_recent_messages` and\n`search_messages` see that text, so a voice note becomes findable by what was\nsaid in it.\n\nPick a provider once, in `wazap setup` or later:\n\n```bash\nwazap config transcribe local     # free and private, one 574 MB model on disk\nwazap config transcribe openai    # cheap and fast, the audio leaves this machine\nwazap config transcribe off\n```\n\n| | `local` | `openai` |\n| --- | --- | --- |\n| Runs | whisper.cpp, here | any OpenAI-compatible `/audio/transcriptions` |\n| Costs | nothing | per minute of audio, on your key |\n| Privacy | the audio never leaves this machine | **the audio leaves this machine** |\n| Needs | `whisper-cpp` and `ffmpeg`, plus a model | an API key |\n\n### Local, with whisper.cpp\n\n```bash\nbrew install whisper-cpp ffmpeg      # macOS; elsewhere build whisper.cpp, install ffmpeg from your package manager\nwazap transcribe download            # fetch and verify the model\nwazap transcribe test recording.ogg  # prove it before you trust it\n```\n\n`wazap setup` and `wazap transcribe download` offer that `brew install`\nthemselves when either binary is missing, and go straight on to the model in the\nsame run. `--no-brew` turns the offer off everywhere.\n\nModels land in `<data-dir>/models/` and are checked against a SHA-256 pinned in\nthe source; an interrupted download resumes where it stopped.\n\n| `WAZAP_WHISPER_MODEL` | File | Size |\n| --- | --- | --- |\n| `turbo` (default) | `ggml-large-v3-turbo-q5_0.bin` | 574 MB |\n| `large-v3` | `ggml-large-v3-q5_0.bin` | 1.08 GB |\n| `medium` | `ggml-medium-q5_0.bin` | 539 MB |\n\n`turbo` is the default because it is the smallest model that still gets Romanian\nright. `medium` and below drop diacritics and mangle names, which is worse than\nno transcript at all: a missing transcript is a question, a wrong name is a wrong\nanswer. `large-v3` is the same accuracy for several times the wait.\n\n### An API, OpenAI-compatible\n\n`wazap config transcribe openai` asks for the key without echoing it and stores\nit in `<data-dir>/.env`. The default endpoint is OpenAI; Groq works unchanged:\n\n```bash\nWAZAP_TRANSCRIBE_URL=https://api.groq.com/openai/v1\nWAZAP_TRANSCRIBE_MODEL=whisper-large-v3-turbo\n```\n\n**With this provider the audio leaves your machine.** Every voice note wazap\ntranscribes is uploaded to that endpoint. If that is not acceptable, use `local`,\nwhich uploads nothing.\n\nThe key is treated as a secret rather than as a setting:\n\n- It is never accepted as a command-line argument, because an argument lands in\n  your shell history and in `ps`.\n- The prompt echoes nothing, not even asterisks.\n- It is stored only in `<data-dir>/.env`, mode `0600`.\n- `status`, `status --json`, `config` and `get_status` show at most\n  `api key: set (…abcd)`.\n- A provider's own error message has the key stripped out of it before wazap\n  prints it.\n- A plain-`http` `WAZAP_TRANSCRIBE_URL` is refused unless it points back at this\n  machine.\n\n### Without being asked\n\nWith a provider configured, incoming voice notes of up to ten minutes are\ntranscribed in the background as they arrive, one at a time, never holding up a\nmessage. The transcript is cached by message id and persisted, so a voice note is\ntranscribed once and not again after a restart. Audio *files* are left alone,\nsince one can be an hour long; call `transcribe_audio(message_id)` for those.\n`WAZAP_TRANSCRIBE_AUTO=0` keeps the tool and stops the background work.\n\n## Skills\n\nwazap ships five [Agent Skills](https://agentskills.io) that teach an agent the workflows behind the tools, not just the tools:\n\n| Skill | What the agent does |\n| --- | --- |\n| `wazap-setup` | Diagnose with `wazap status`, link by QR or pairing code, connect a client with `wazap connect`, repair an expired session |\n| `whatsapp-inbox` | \"What did I miss?\" Triage into *needs you / FYI / noise*, ranked, plus forgotten replies. Read-only |\n| `whatsapp-recall` | \"Find the invoice Dan sent.\" Search with query variants, page back in time, download and read the file. Read-only |\n| `whatsapp-groups` | Catch up on a 300-message group: decisions, dates, what is asked of you. Read-only |\n| `whatsapp-send` | Draft in the chat's own register, show recipient and text, send only after the user says yes |\n\n`wazap setup` copies them into every client it connects, so there is usually\nnothing to run. The command behind it, for a harness `setup` never offered or\nfor a checkout you want to install by hand:\n\n```bash\nnpx wazap-mcp skills install codex     # or claude-code, cursor, opencode, agents\n```\n\nWith no harness named it installs into every client it finds on this machine.\nFor Claude Code the other route is the plugin, which carries the server as well:\n\n```\n/plugin marketplace add razvangirgiz/wazap\n/plugin install wazap@wazap\n```\n\n| Harness | Where the five directories land |\n| --- | --- |\n| `claude-code` | `~/.claude/skills/` |\n| `codex` | `~/.agents/skills/`, the directory Codex documents for user skills. Cursor and OpenCode read it too |\n| `cursor` | `~/.cursor/skills/` |\n| `opencode` | `~/.config/opencode/skills/` |\n| `agents` | `./.agents/skills/`, in the current project, for anything that reads the cross-tool convention |\n\nRe-running overwrites, so an upgrade is the same command. `--dry-run` lists\nwhat it would copy.\n\nA client with no skills directory is not left out. The server registers each of\nthe five as an MCP prompt of the same name, and sends a short `instructions`\nblock that names all five and says when each applies, so an agent that never saw\nthe skill files still follows them. That is how Claude Desktop, VS Code and\nWindsurf get the workflows. A bridged session and a self-hosted HTTP server\ncarry them the same way.\n\n## Errors\n\nEvery failure is a structured `{ error, message, fix }` rather than a stack\ntrace, so an agent can decide whether to retry, ask the user, or stop.\n\n| Code | Meaning |\n| --- | --- |\n| `NOT_LINKED` | No account linked. Call `link_account`, or run `npx wazap-mcp login`. |\n| `ALREADY_LINKED` | `link_account` was called on a session that is already linked. Call `get_status`. |\n| `SESSION_EXPIRED` | Unlinked from the phone. Run `npx wazap-mcp login`. |\n| `SESSION_CORRUPT` | Credentials unreadable. Run `npx wazap-mcp logout` then `login`. |\n| `NOT_CONNECTED` | Still connecting or reconnecting. |\n| `SYNC_IN_PROGRESS` | History sync has not finished; results may be partial. |\n| `INVALID_PHONE` | Number is not in international format. |\n| `INVALID_ID` | Not a WhatsApp chat, contact or group id. |\n| `NOT_ON_WHATSAPP` | That number has no WhatsApp account. |\n| `CHAT_NOT_FOUND` / `MESSAGE_NOT_FOUND` / `CONTACT_NOT_FOUND` / `GROUP_NOT_FOUND` | Unknown id. |\n| `NOT_A_PARTICIPANT` / `NOT_ADMIN` / `GROUP_ANNOUNCEMENT_ONLY` | Group permissions. |\n| `MEDIA_UNAVAILABLE` | WhatsApp expired the file, or it was never synced here. |\n| `FILE_NOT_FOUND` / `FILE_TOO_LARGE` / `URL_FETCH_FAILED` | Outbound media problems. |\n| `TEXT_TOO_LONG` | Over WhatsApp's message limit. |\n| `EDIT_WINDOW_EXPIRED` / `RETRACT_WINDOW_EXPIRED` / `NOT_OWN_MESSAGE` | WhatsApp's own limits on editing and deleting. |\n| `READ_ONLY` | wazap is running read-only. |\n| `RATE_LIMITED` | Too many writes; `fix` says how long to wait. |\n| `DRAFT_NOT_FOUND` / `DRAFT_EXPIRED` | The preview was already sent, unknown, or older than 15 minutes. Draft again. |\n| `TIMEOUT` / `WHATSAPP_ERROR` | WhatsApp did not answer, or rejected the operation. |\n\n## Data directory\n\nEverything lives in `~/.wazap` (override with `--data-dir` or `WAZAP_DATA_DIR`),\ncreated `0700` with credentials written `0600`:\n\n```\n~/.wazap/\n  auth/         WhatsApp credentials — treat this like a password\n  media/        downloads from download_media\n  history/      per-chat message history, so a restart is not amnesia\n  previews/     one small JPEG per photo or video already previewed\n  notes.json    notes on contacts and \"handled\" marks; never sent anywhere\n  models/       whisper.cpp models, when transcription runs locally\n  store.json    chat-list snapshot\n  server.lock   pid of the running server\n  daemon.json   loopback endpoint a second wazap bridges to\n  oauth.json    registered agents and hashed OAuth grants, when OAuth is on\n  .env          optional settings, see .env.example\n```\n\nCredential writes go to a temp file and are renamed into place, so killing the\nprocess mid-write cannot leave you re-linking your phone.\n\n## Several clients at once\n\nClaude Desktop, Claude Code and Cursor each launch their own `wazap`. WhatsApp\nallows one socket per linked device, so they share one session instead of\nfighting over it. The first `wazap` on a data directory owns the session and\nopens an MCP endpoint on `127.0.0.1`; every later one bridges to it over that\nendpoint. There is nothing to configure, and no client can tell the difference.\nThe owner publishes `<data-dir>/daemon.json` (`0600`) with its pid, its port\nand the token a bridge authenticates with.\n\nA bridge serves whatever the owner exposes, so an owner started `--read-only`\nmakes every client read-only, whatever flags that client was launched with.\n\nWhen the owner exits, the bridges exit with it, and the next `wazap` a client\nstarts becomes the new owner.\n\n`WAZAP_NO_SHARE=1` opts out: a second `wazap` on the same directory exits with\ncode 2 naming the pid of the one already running. An explicit `--http` is a\nserver of its own rather than a bridge, and is refused the same way.\n\n## Read-only mode\n\nWrites are opt-in. `login` asks once and stores the answer in\n`<data-dir>/.env`; `wazap config writes on|off` changes it, and `wazap config`\nalone prints every effective setting with where it came from.\n\n`WAZAP_READ_ONLY=1` or `wazap serve --read-only` does not register the write\ntools at all. The agent never sees them, so it cannot message anyone from your\nnumber even by mistake — useful when the linked account is your personal one.\n\nWrites are also rate limited to `WAZAP_RATE_LIMIT` per minute (default 20, `0`\ndisables). Sending faster than a human is how accounts get banned.\n\n## HTTP mode\n\n```bash\nWAZAP_READ_TOKEN=$(openssl rand -hex 32) \\\nWAZAP_WRITE_TOKEN=$(openssl rand -hex 32) \\\nnpx wazap-mcp serve --http --host 0.0.0.0 --port 8766\n```\n\nStreamable HTTP at `/mcp`, with a health check at `/healthz`. That check answers\n`{ ok, status, since }`. It turns 503 once the socket has been anything but\nconnected for two minutes, so a tunnel or a monitor sees a real outage rather\nthan a reconnect in progress. Two bearer tokens:\nthe read token gets the read tools, the write token also unlocks the write\ntools, so a leaked read token can never message anyone. wazap refuses to bind a\nnon-loopback address without a read token. Agents that cannot carry a header\nsign in with [OAuth](#hosted-agents-oauth) instead.\n\n## Self-host\n\nRun wazap on a server of your own when the agent is not on your laptop: another machine, a VPS, a client's infrastructure. The session stays on that server; nothing goes through a third party.\n\n### With systemd\n\n```bash\nnpm install -g wazap-mcp\nsudo useradd --system --home /var/lib/wazap --create-home wazap\nsudo -u wazap WAZAP_DATA_DIR=/var/lib/wazap wazap login --phone +15550100   # pairing code works over SSH\nsudo -u wazap tee /var/lib/wazap/.env >/dev/null <<END\nWAZAP_READ_TOKEN=$(openssl rand -hex 32)\nWAZAP_WRITE_TOKEN=$(openssl rand -hex 32)\nEND\nsudo curl -fsSL https://raw.githubusercontent.com/razvangirgiz/wazap/main/deploy/wazap.service -o /etc/systemd/system/wazap.service\nsudo systemctl enable --now wazap\ncurl -s http://127.0.0.1:8766/healthz\n```\n\nThe unit binds loopback only. Put TLS in front with the two-line [`deploy/Caddyfile`](deploy/Caddyfile) (`caddy run --config deploy/Caddyfile` after editing the hostname) or any reverse proxy, then point the client at `https://your-host/mcp` with `Authorization: Bearer <read or write token>`.\n\n### With Docker\n\n```bash\ngit clone https://github.com/razvangirgiz/wazap && cd wazap\nprintf 'WAZAP_READ_TOKEN=%s\\nWAZAP_WRITE_TOKEN=%s\\n' $(openssl rand -hex 32) $(openssl rand -hex 32) > .env\ndocker compose run --rm wazap login --phone +15550100   # once; the session lands in the wazap-data volume\ndocker compose up -d\ncurl -s http://127.0.0.1:8766/healthz\n```\n\nThe container publishes `8766` on loopback only; add the same TLS proxy in front. Upgrading is `git pull && docker compose up -d --build`; the volume keeps the session.\n\n### From a machine without a public address\n\nA laptop or a box behind NAT can still serve hosted agents through a tunnel, with no port opened and TLS done at the edge. `npx wazap-mcp expose` does the whole thing with Tailscale Funnel or Cloudflare Tunnel, whichever is installed. See [Keep it running](#keep-it-running).\n\nwazap keeps binding loopback either way; only the tunnel reaches it.\n\n<details>\n<summary>By hand, with Cloudflare Tunnel and a domain on Cloudflare</summary>\n\n```bash\ncloudflared tunnel login\ncloudflared tunnel create wazap\ncloudflared tunnel route dns wazap wazap.example.com\ncloudflared tunnel run --url http://127.0.0.1:8766 wazap\n```\n\nSet `WAZAP_PUBLIC_URL=https://wazap.example.com` for OAuth and keep `cloudflared` running the way you keep wazap running (a systemd unit, a launchd agent). Tailscale Funnel or ngrok work the same way: whatever ends at `https://your-host` with `/mcp` behind it.\n\n</details>\n\n### Which clients can reach it\n\nClaude Code, Claude Desktop, Cursor, Codex, VS Code, Poke and any client with an \"MCP URL + header\" field connect with the bearer token. Keep the read token in clients that only need to read; hand out the write token deliberately.\n\nclaude.ai Connectors, ChatGPT and some hosted agents will not take a static header. They want OAuth, which is the next section.\n\n### Hosted agents (OAuth)\n\nTwo more lines in the same `.env` turn wazap into its own OAuth 2.1 server:\n\n```bash\nWAZAP_PUBLIC_URL=https://wazap.example.com\nWAZAP_OAUTH_PASSWORD=$(openssl rand -base64 18)\n```\n\nThen give an agent nothing but `https://wazap.example.com/mcp`. It finds the\nauthorization server at `/.well-known/oauth-protected-resource/mcp`, registers\nitself (RFC 7591, so there is no client id to paste anywhere), and sends you to\na page on your own host that asks two things: the password above, and whether\nthis agent may only read or also send. A refresh token keeps the agent signed\nin until you revoke it; access tokens rotate every 24 hours on their own.\n\nTested against the flow claude.ai, ChatGPT and Poke use: S256 PKCE, public\nclients, `/token` with refresh, `/revoke`. The bearer tokens keep working next\nto it, so a laptop client on a header and a hosted agent on OAuth share one\nserver.\n\nWhat to know before exposing it:\n\n- `WAZAP_PUBLIC_URL` must be `https` and a bare origin, no path: the\n  endpoints live at its root. The password travels to it.\n- The password is the whole identity layer. Use a long one. A consent page\n  takes three wrong guesses and is gone; five from one address lock that\n  address out for fifteen minutes; twenty from anywhere close the page for\n  everyone for fifteen minutes.\n- With OAuth on, `/mcp` never answers an unauthenticated request, whether or\n  not a read token is set.\n- Grants live in `<data-dir>/oauth.json` as hashes. Delete the file to sign\n  every agent out at once, running server included; `wazap status` lists who\n  holds one. Disconnecting an agent on its side revokes its refresh token and\n  every access token it minted. A refresh token unused for ninety days is\n  dropped.\n- A read grant never sees a write tool, whatever scope the agent requested.\n  The radio button on the consent page is the only thing that decides.\n\n## Settings\n\n| Variable | Default | Meaning |\n| --- | --- | --- |\n| `WAZAP_DATA_DIR` | `~/.wazap` | Where everything is stored. |\n| `WAZAP_READ_ONLY` | `0` | Do not register the write tools. |\n| `WAZAP_SYNC_FULL_HISTORY` | `0` | Ask WhatsApp for a fuller history sync. |\n| `WAZAP_PERSIST_HISTORY` | `1` | Keep chats and messages across restarts. |\n| `WAZAP_RATE_LIMIT` | `20` | Write tool calls per minute; `0` disables. |\n| `WAZAP_TRANSPORT` | `stdio` | `stdio` or `http`. |\n| `WAZAP_HOST` / `WAZAP_PORT` | `127.0.0.1` / `8766` | HTTP bind address. |\n| `WAZAP_READ_TOKEN` / `WAZAP_WRITE_TOKEN` | unset | HTTP bearer tokens. |\n| `WAZAP_PUBLIC_URL` | unset | The `https` address agents reach the server at. With the password, turns OAuth on. |\n| `WAZAP_OAUTH_PASSWORD` | unset | What the consent page asks for. At least 8 characters. |\n| `WAZAP_NO_UPDATE_CHECK` | `0` | `1` stops `status` asking npm for a newer version. |\n| `WAZAP_TRANSCRIBE` | `off` | `local`, `openai` or `off`. |\n| `WAZAP_TRANSCRIBE_AUTO` | `1` | Transcribe incoming voice notes in the background. |\n| `WAZAP_TRANSCRIBE_LANGUAGE` | `auto` | Spoken language, e.g. `ro`. |\n| `WAZAP_WHISPER_MODEL` | `turbo` | `turbo`, `large-v3` or `medium`. |\n| `WAZAP_WHISPER_BIN` | unset | Path to a whisper.cpp binary that is not on `PATH`. |\n| `WAZAP_TRANSCRIBE_API_KEY` | unset | API key; `OPENAI_API_KEY` is the fallback. Never a flag. |\n| `WAZAP_TRANSCRIBE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL. |\n| `WAZAP_TRANSCRIBE_MODEL` | `gpt-4o-mini-transcribe` | Model at that URL. |\n\nFlags beat environment variables, which beat `<data-dir>/.env`.\n\n## Known limitations\n\n- **Unofficial.** Baileys reverse-engineers the WhatsApp multi-device protocol.\n  This is not the WhatsApp Business API and Meta does not support it.\n- **Ban risk is real.** Automated sending, bulk messaging or anything a human\n  would not plausibly type can get the number banned, and that is not\n  recoverable from here. The rate limit helps; it is not a guarantee.\n- **Media keys expire.** WhatsApp drops old attachments from its servers, so\n  `download_media` on an old message returns `MEDIA_UNAVAILABLE`.\n- **History is what the phone syncs.** wazap sees the history WhatsApp hands the\n  linked device, not your full phone archive. `read_messages` with `before` asks\n  for more, within whatever WhatsApp still keeps.\n- **`@lid` ids.** Newer accounts are addressed by a privacy id rather than a\n  phone number. wazap translates them back to phone numbers when it has learned\n  the mapping, and passes the `@lid` through when it has not.\n- **Names come from the phone's address book.** WhatsApp delivers it as an app\n  state sync, and only to a connection asking for it from scratch. If contacts\n  read as phone numbers and `get_status` shows `contacts_named: 0`, ask for it\n  again with the `sync_contacts` tool or `wazap contacts resync`.\n- **Calls are WhatsApp calls only.** A call shows up as a message with\n  `type: \"call\"`, carrying its kind, direction, outcome and duration. WhatsApp's\n  own call log and the missed-call notices arrive on their own; a call that\n  starts and ends while wazap is running is recorded live, so calls placed or\n  received while it is stopped can be missing entirely. A cellular call from the\n  phone's dialler is never visible, on any device.\n- **Your phone must stay reachable.** A linked device stops receiving once the\n  phone has been offline long enough; `get_status` says so in `hint`.\n\n## Development\n\n```bash\nnpm install\nnpm run typecheck\nnpm test                       # builds, then runs node --test\nnode test/smoke-stdio.mjs      # drives the built binary over MCP stdio\nnpm run dev -- status          # run from source with tsx\n```\n\n`npm test` needs no WhatsApp session. The stdio smoke test spawns the built\nbinary against a throwaway data directory and checks that an unlinked install\nstill answers `initialize`, `tools/list` and `get_status`.\n\nMIT licensed.\n",
  "bytes": 36596,
  "sha": "fe7160267b0efd118ad5bd75d013790d134a4de842af071489e371b525a97d77",
  "repo_slug": "razvangirgiz/wazap",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_razvangirgiz_wazap_ed0febc4/readme"
}