A single-container WhatsApp Model Context Protocol server. Pull the image, run it, and your AI agent (Claude Code, or any MCP-speaking client) can pair your phone and then read and send WhatsApp messages on your behalf — all through MCP.
Built on whatsmeow. Everything —
MCP transport, pairing, session persistence — runs in one Go process
inside one Docker image. No sidecars, no compose bundle, no second
language runtime.
Today the server ships 34 MCP tools: cache-backed read tools for
chats and messages, plus send_message, send_file (image, video,
audio, document, sticker), send_audio_message (voice notes),
send_reaction, polls (send_poll / vote_poll / get_poll_results),
download_media, contact / group lookups, resolve_jid (any recipient →
readable identity), cache_sync / cache_sync_status, the ping health
check, and the native pairing_start / pairing_complete tools that let
an agent drive the link flow over MCP itself. A further set of tools
mutates account-visible state — the About text, online and per-chat
typing presence, disappearing-message timers, and read receipts — so an
agent can look like a real participant rather than a silent reader. The
full coverage matrix — including whatsmeow capabilities not yet exposed —
lives in SUPPORTED.md.
⚠️ Unofficial. This project useswhatsmeow, an unofficial reimplementation of the WhatsApp protocol. Use at your own risk; WhatsApp may rate-limit or ban accounts that misuse automation.
whatsapp.mp4
You need: Docker, a phone with WhatsApp, and Claude Code.
mkdir -p ~/whatsapp-mcp
( umask 077 && openssl rand -hex 32 > ~/whatsapp-mcp/.auth_token )
docker run -d \
--name whatsapp-mcp \
--restart unless-stopped \
-p 8081:8081 \
-v whatsapp-mcp-data:/data \
-e AUTH_TOKEN="$(cat ~/whatsapp-mcp/.auth_token)" \
docker.io/angelmanuel/whatsapp-mcp:latest8081 carries everything: the MCP transport at /mcp and the media byte
route at /media/<sha256>, both behind the same AUTH_TOKEN. There is no
second port and no separate admin API.
claude mcp add --transport http whatsapp http://localhost:8081/mcp \
--header "Authorization: Bearer $(cat ~/whatsapp-mcp/.auth_token)" \
--scope userRestart Claude Code, run /mcp, and whatsapp should be listed.
Then ask Claude to pair the device. It calls pairing_start
through MCP, gets back the QR code, and renders it for you. Scan it in
WhatsApp → Linked devices → Link a device. Claude polls
pairing_complete until the link succeeds; the session then survives
container restarts (everything lives under /data).
For a project-scoped config (committed alongside a repo), use
--scope project instead — claude mcp add writes to ./.mcp.json.
Don't commit the token; the env-var form Bearer ${WHATSAPP_MCP_AUTH_TOKEN}
in .mcp.json works once you export the variable in the shell that
launches Claude Code.
💡 Claude Desktop? Claude Desktop only speaks stdio MCP, not HTTP. Run the container with
-e TRANSPORT=stdioand wrap it with a stdio-bridging launcher (or just use Claude Code, which speaks HTTP natively).
Published to Docker Hub on every release tag:
| Tag | Base | Use when |
|---|---|---|
angelmanuel/whatsapp-mcp:latest |
distroless/static, non-root, no shell | Default. Smallest, hardest to misuse. |
angelmanuel/whatsapp-mcp:latest-slim |
debian:bookworm-slim + ffmpeg + tini |
You want a shell for triage, or you want send_audio_message to accept audio that is not already Ogg/Opus. ffmpeg transcodes it; without this variant, non-Opus audio is refused rather than sent unplayable. |
Both are multi-arch (linux/amd64, linux/arm64). Each release also
publishes immutable :X.Y.Z and :X.Y.Z-slim tags (no v prefix —
Docker tag convention) plus sha256 digests in the GitHub release
notes — pin by digest in production.
Most operators only touch these:
| Var | Default | Notes |
|---|---|---|
TRANSPORT |
http |
http or stdio. HTTP requires AUTH_TOKEN. |
PORT |
8081 |
Serves /mcp, /media (upload), /media/<sha256> (download) and /healthz. |
DATA_DIR |
/data |
The only writable volume; holds session.db (whatsmeow identity), cache.db (chat/message cache), and media/ (attachment blobs, both downloaded and staged for sending). |
AUTH_TOKEN |
(unset) | Bearer token required on every HTTP request, /mcp and /media/ alike. Only the /healthz liveness probe is exempt. |
MTLS_CA_FILE / MTLS_CERT_FILE / MTLS_KEY_FILE |
(unset) | Not implemented. Setting any of them is a fatal startup error — there is no TLS listener, so they only ever served plaintext. Terminate TLS in a reverse proxy. |
WHATSAPP_DEVICE_NAME |
whatsapp-mcp |
Label shown on the user's phone. |
LOG_LEVEL |
info |
debug|info|warn|error. |
LOG_FORMAT |
json |
json or text. |
MEDIA_MAX_BYTES |
1073741824 (1 GiB) |
Cap on $DATA_DIR/media. Over the cap, least-recently-requested blobs are evicted. 0 disables the cap. |
FFMPEG_PATH |
/usr/bin/ffmpeg |
Where send_audio_message looks for ffmpeg to transcode non-Opus audio. Present in the -slim image; absent in the default distroless one, where non-Opus audio is refused instead. |
MEDIA_TTL |
(unset) | Go duration (e.g. 168h). Evicts media older than this. Unset/0 disables age-based eviction. |
MEDIA_MAX_UPLOAD_BYTES |
104857600 (100 MiB) |
Largest single POST /media body. Over it the request is refused with 413; unlike MEDIA_MAX_BYTES this is a hard limit, not an eviction trigger. |
MEDIA_SWEEP_INTERVAL |
1h |
How often retention runs. A sweep also runs at startup regardless. |
Full env-var contract: REQUIREMENTS.md.
In production, deliver AUTH_TOKEN as a tmpfs-mounted file referenced by
path, not via -e — -e exposes the secret to anyone who can read
/proc/<pid>/environ.
The server speaks plaintext HTTP and authenticates with a bearer token only. Transport encryption and client-certificate auth are the reverse proxy's job.
MCP cannot carry bytes usefully, so attachments never travel through a tool
call in either direction. Bytes move over plain HTTP on the same port,
with the same bearer token as /mcp; tool calls only ever carry the
/media/<sha256> pointer to them.
Receiving an attachment is a two-step flow:
-
The agent calls the
download_mediatool withchat_jid+message_id. The container fetches the attachment from WhatsApp's CDN, stores it content-addressed under$DATA_DIR/media, and returns a small JSON descriptor — never bytes, never base64:{ "media_path": "/media/<sha256>", "mime": "video/mp4", "size": 4210513, "filename": "video_20260804_150405.mp4", "sha256": "<sha256>" } -
Anything that needs the actual file
GETsmedia_pathon the same port, with the same bearer token:curl -H "Authorization: Bearer $AUTH_TOKEN" \ http://localhost:8081/media/<sha256> -o video.mp4
The route sends
Content-Type,Content-Length,Content-Disposition,ETag,Last-ModifiedandCache-Control, supportsRangerequests (206), answers401without a valid bearer and404for an unknown or evicted digest. Repeatdownload_mediacalls for the same message are cache hits and re-download nothing.
Attachments cached before the media_direct_path column existed (migration
004) only have an expiring CDN URL, which cannot be backfilled. If
download_media returns media_unavailable for an old message, run
cache_sync to re-ingest it and retry.
Sending mirrors it, one step earlier:
-
POSTthe bytes to/media. The container stores them content-addressed and answers201with the same descriptor shapedownload_mediareturns:curl -H "Authorization: Bearer $AUTH_TOKEN" \ -H "Content-Type: image/jpeg" \ --data-binary @holiday.jpg \ "http://localhost:8081/media?filename=holiday.jpg" # {"media_path":"/media/<sha256>","mime":"image/jpeg","size":183422,...}
Content-Typesets the mimetype (sniffed from the bytes when absent orapplication/octet-stream) and?filename=names the file, which is what a document send shows the recipient. Bodies over 100 MiB are refused with413; identical bytes uploaded twice are one blob. -
The agent calls
send_file(orsend_audio_message) with thatmedia_path:{ "recipient": "34600111222", "media_path": "/media/<sha256>", "caption": "from the trip" }The envelope is chosen from the stored mimetype —
image/*→ image,video/*→ video,audio/*→ audio,image/webp→ sticker, anything else → document — andmedia_typeoverrides that when the caller disagrees. Forwarding works with no upload at all: pass amedia_paththatdownload_mediajust returned.captionbelongs tosend_fileonly, and only on image, video and document envelopes: audio and sticker messages cannot carry one, so passing it there is rejected rather than silently dropped.send_audio_messagetakesrecipient,media_pathandreply_to_id.
send_audio_message sends a voice note (PTT), which WhatsApp only plays
as Ogg/Opus. Opus goes out as-is on either image variant; anything else
needs ffmpeg. The -slim image ships it and transcodes transparently,
while the default distroless image has none — there the call fails with
invalid_argument rather than delivering a voice note nobody can play.
Use send_file for a plain audio attachment, which accepts
mp3/m4a/aac/amr directly.
Tools shipping today (34):
- Cache-backed reads —
list_chats,list_conversations,get_chat,list_messages,get_message_context,get_last_interaction,get_contact_chats,get_direct_chat_by_contact,get_conversation - Contacts —
search_contacts,list_all_contacts,get_contact_details,resolve_jid - Groups —
get_group_info - Sending —
send_message(text),send_file(image, video, audio, document, sticker),send_audio_message(voice note / PTT),send_reaction - Polls —
send_poll,vote_poll,get_poll_results. Results are tallied from vote events as they arrive: WhatsApp offers no way to query a poll's standings, so votes cast before the device was linked (or while the container was down) are not counted. - Media —
download_media(returns a descriptor; bytes come fromGET /media/<sha256>, and go in viaPOST /media) - Account & presence (all of these are visible to other WhatsApp
users) —
set_status_message,send_presence,send_chat_presence,subscribe_presence,set_disappearing_timer,set_default_disappearing_timer,mark_read - Native —
ping,cache_sync,cache_sync_status,pairing_start,pairing_complete
Every tool that returns messages also reports the emoji reactions on
them (reactions, omitted when there are none), populated from reaction
events as they arrive and backfilled from history sync.
Presence a subscribe_presence call asks for arrives asynchronously and is
cached against the contact; read it back through get_contact_details
(presence_observed, is_online, last_seen_ts).
For the full picture — including the long list of whatsmeow
capabilities not yet exposed (edits, group admin, newsletters,
privacy/blocklist, …) — see
SUPPORTED.md. Intentional divergences from the prior
Python reference's argument shapes are tracked in
CHANGES.md.
Pairing is driven by the MCP tools (pairing_start,
pairing_complete) — the agent calls pairing_start, receives the QR
code, renders it, and polls pairing_complete until the link succeeds.
Pairing is driven exclusively through MCP. The former admin HTTP surface
(/admin/pair/start, /admin/unpair, /admin/events, /admin/status)
was removed; make pair-qr drives the MCP tools directly.
ping, pairing_start, and pairing_complete are exempt from the
not_paired gate; every other tool returns a structured not_paired
error until pairing succeeds.
Full pairing contract — events, error codes — is in REQUIREMENTS.md §Pairing.
- One process per
/datavolume. Ratchet state rotates on every message; the binary acquires an exclusiveflockon/data/.lockat startup and exits non-zero if another process owns it. /datais the only persistent volume. Rundocker volume rm whatsapp-mcp-datato fully reset the device identity; preserve it across container restarts to avoid re-pairing.- Read-only root filesystem compatible — mount
/asro,/dataand/tmpasrw. - Healthcheck is built-in —
whatsapp-mcp --healthcheckprobes the unauthenticatedhttp://127.0.0.1:$PORT/healthzliveness endpoint. No shell or curl needed in the distroless image. - Media is a bounded store.
$DATA_DIR/mediaholds both attachments fetched bydownload_mediaand bytes staged viaPOST /media, capped byMEDIA_MAX_BYTES/MEDIA_TTL(and per-request byMEDIA_MAX_UPLOAD_BYTES). Evicting a downloaded blob costs a round trip on the nextdownload_mediacall, not data — but an uploaded blob has no origin to re-fetch from, so upload shortly before you send, and treat anot_foundfromsend_fileas "upload it again". - Rootless Podman: the image runs as UID 1000 (non-root). Named volumes
are initialised with the correct ownership automatically. If you switch to a
bind mount instead, add
--userns=keep-idso the host directory is writable by the container user. - No telemetry. The binary does not phone home.
make build # bin/whatsapp-mcp
make test # unit tests with -race
make image # docker.io/angelmanuel/whatsapp-mcp:dev (distroless)
make image-slim # …:dev-slim (debian:bookworm-slim + ffmpeg)
make run-local # build + run with a local ./data volume
make run-master # pull :master, run detached, mint a token at ./.auth_token
make pair-qr # render QR for the running container in the terminalReleases are cut by release-please. Commit messages follow
Conventional Commits (feat:, fix:, docs:, feat!: …); on
every push to master, release-please keeps a
chore(master): release X.Y.Z pull request up to date with the next
version and the generated CHANGELOG.md.
Merging that PR is the whole release procedure. release-please then
tags vX.Y.Z and opens the GitHub release, and the release workflow
builds both image variants for linux/amd64 + linux/arm64, pushes
X.Y.Z, X.Y.Z-slim, X.Y, and (for non-prerelease tags)
latest / latest-slim to Docker Hub, then appends the immutable
digests and SPDX SBOMs (via syft) to that release.
Version bumps follow SemVer with bump-minor-pre-major: while the
project is pre-1.0, feat: and breaking changes bump the minor, fix:
bumps the patch. Pushing a vX.Y.Z tag by hand still works and runs the
same build — useful for a one-off or a backfill, but it bypasses the
changelog.
- SUPPORTED.md — what the server actually exposes
today, mapped against the underlying
whatsmeow.Clientcapabilities. - REQUIREMENTS.md — full design & env-var contract.
- CHANGES.md — every divergence from the Python reference, with rationale.
- DOCKERHUB.md — the trimmed-down README synced to the Docker Hub repo overview.
See repository.