The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Wa — safe WhatsApp automation listing page.
Personal WhatsApp automation CLI + daemon, written in Go.
A hexagonal Go daemon that owns a WhatsApp Multi-Device session and a thin JSON-RPC client that talks to it — safe enough to let a language model send messages on your behalf, crash-safe enough to survive a power loss mid-migration, and paranoid enough to refuse every destructive flag you might expect.
Quickstart · Install · Manual · Architecture · Security · Contributing · Português (BR)
Pattern. Persistent WhatsApp daemon over a Unix socket — one wad process holds the Multi-Device session, ratchet store, and websocket; a thin wa JSON-RPC client invokes it.
Trade-off. ~30 MB RSS per profile and a 5–8 s cold connect, in exchange for sub-second warm-call latency on every subsequent wa send. Per-call session reattachment is avoided entirely.
Use when. A shell pipeline, cron job, or Claude Code plugin needs to dispatch WhatsApp messages with predictable latency and a non-overridable safety pipeline (default-deny allowlist + rate limiter + warmup ramp + append-only audit log) sitting below every RPC path.

Every frame is real output from the binaries in this repo, recorded against a
throwaway unpaired daemon in a temp directory — which is why doctor shows one
honest WARN and no JID, phone number, or session data appears anywhere.
The recording is generated, never hand-edited. The source of truth is the
plain-text docs/assets/wa-demo.tape; regenerate
the GIF from a clean checkout with one command:
wa comparesClosest peers in the reverse-engineered-WhatsApp ecosystem:
| Capability | wa (this repo) | whatsmeow direct | whatsapp-web-cli |
|---|---|---|---|
| Persistent daemon (sub-second warm-call) | yes | no (per-call session attach) | no (browser-driven) |
| JSON-RPC over Unix socket | yes | n/a (library, not a daemon) | no (Chrome bridge) |
| Default-deny allowlist (per-action) | yes | manual implementation | no |
| Non-overridable limiter (2/sec + 30/min) | yes | manual implementation | no |
| Warmup ramp for fresh sessions | yes | manual implementation | no |
| Append-only JSON-Lines audit log | yes | manual implementation | no |
| SLSA L2 + Sigstore signed releases | yes | n/a | no |
| Dual SBOM (CycloneDX 1.6 + SPDX 2.3) | yes | n/a | no |
CGO_ENABLED=0 static binary | yes | depends on consumer | no (browser-driven) |
| Inbound prompt-injection firewall | yes | n/a | no |
For multi-tenant REST gateways see EvolutionAPI or WAHA — different shape of problem, listed in What this is NOT.
Two binaries, one repo:
wad — long-running daemon that owns the WhatsApp session, the SQLite ratchet store, and the websocket to web.whatsapp.com. Runs under systemd (Linux), launchd (macOS), or a NixOS module. Single-instance per profile, never as root.wa — thin JSON-RPC client that speaks to wad over a unix socket. This is what shell scripts, cron jobs, and Claude Code plugins actually invoke.It is built on go.mau.fi/whatsmeow — the library that powers mautrix-whatsapp at production scale — because it is the only reverse-engineered WhatsApp library actively maintained in 2026. It also ships a safety-first MCP server (wa mcp serve) so an AI agent drives WhatsApp through the same non-bypassable safety pipeline — draft-gated by default. See MCP server.
--force flag anywhere.wa install is scoped to one person, with optional multi-profile isolation for work/personal splits.mautrix-whatsapp if that's what you want.EvolutionAPI or WAHA if that's what you want.whatsmeow.The recipient flag is spelled --to, --jid, or --group depending on the command; --chat <jid> is accepted as a universal alias on all of them (the original flags still work).
For the full tour including multi-profile setup, shell completion, migration, and the audit log, see docs/manual.md.
wa mcp serve exposes the daemon to AI agents over the Model Context Protocol (stdio transport). Every tool call runs through the same non-bypassable safety pipeline as the CLI — default-deny allowlist, enforced short-window limits (2/second with burst 2; 30/minute with burst 30; no daily ordinary-send cap) with warmup, and an append-only audit log. Sends default to draft mode: the model proposes a message into a human-review queue and nothing leaves until you run wa draft approve. That draft-gate is the point — it is what makes handing WhatsApp to an autonomous agent safe.
Live on the official MCP Registry as io.github.yolo-labz/wa; one-click install via the signed .mcpb bundle on each release, or wire it manually into Claude Desktop/Code or Cursor:
Tools (12):
| Tool | Purpose |
|---|---|
wa_send_message | Send (or draft) a message through the safety pipeline |
wa_send_media | Send (or draft) a media file |
wa_schedule_message | Schedule a send for later (drafts respected) |
wa_draft_review | List pending drafts awaiting human approval |
wa_search_messages | Full-text search across synced messages |
wa_get_thread | Fetch recent messages of one chat |
wa_list_chats | List chats with recent activity |
wa_resolve_contact | Resolve a name/phone to a JID |
wa_group_info | Group metadata and participants |
wa_wait_for_reply | Block until a matching inbound event arrives |
wa_transcribe_voice | Transcribe a voice note |
wa_status | Daemon connection and session status |
It also serves resources (read-only context surfaces) and prompts (pre-built workflows) per the MCP spec. Scope the surface with --toolsets; the send mode is draft (default), direct, or deny, and a read-only set hides every mutating tool.
See docs/mcp-registry.md for the registry publish flow and bundle internals.
NixOS module — import the system module and enable:
home-manager module — import for a per-user installation:
The gh attestation verify step proves that this exact tarball was produced by the yolo-labz/wa GoReleaser job on the exact commit SHA the tag points at. Every release ships:
wa_<version>_<os>_<arch>.tar.gz — the platform tarballchecksums.txt — SHA256 of every artifactchecksums.txt.sigstore.json — Cosign-signed Sigstore bundle for checksums.txt (legacy/optional verify path; not needed when using gh attestation verify against the artifact above)sbom.cdx.json — CycloneDX 1.6 SBOM (full repo, syft)sbom.spdx.json — SPDX 2.3 SBOM (full repo, syft)sbom.gomod.{wa,wad}.cdx.json — Go-native CycloneDX SBOMs (cyclonedx-gomod, per binary, with stdlib + license info)openvex.json — OpenVEX 0.2 statement file. VEX-aware scanners (Trivy, Grype) consume this to filter "is this CVE actually exploitable in wa?" — most transitive CVEs are not_affected because the depguard-enforced port boundary keeps them off the runtime path. Statements grow as findings emerge; current baseline is empty pending per-CVE triage.CHANGELOG.md — git-cliff generated changelogdarwin users — unsigned release: releases cut without an Apple Developer account ship the darwin-arm64 binary unsigned and un-notarized. Gatekeeper will quarantine it on first launch. Strip the quarantine flag before running:
Or install via the Homebrew tap (
brew install yolo-labz/tap/wa) — brew rebuilds from source locally and bypasses the quarantine entirely. Signed + notarized darwin binaries return on the next GA tag cut afterAPPLE_DEVELOPER_ID_APPLICATION_{CERT,KEY}secrets are populated.
go installThe module path includes the /v2 suffix per Go's semantic import versioning rule for v2+ releases.
go install on a tag does NOT inherit the GoReleaser build flags (-trimpath, -buildvcs=true, ldflag-stamped version). The resulting binary will run, but wa --version reports (devel). Use the GoReleaser tarball or the Homebrew tap if version-stamped, reproducible binaries matter to you.
Run two WhatsApp accounts side-by-side — personal and work, or one per client — with full process isolation. Each profile has its own session.db, allowlist.toml, audit.log, rate limiter, unix socket, and warmup timestamp.
Profile selection precedence (highest wins):
--profile <name> flagWA_PROFILE env var (empty = unset)$XDG_CONFIG_HOME/wa/active-profile pointerdefaultSee docs/manual.md §4.
wa is safe enough to let a language model invoke it on your behalf. The safety pipeline is non-overridable and lives inside the daemon, below every RPC path:
read / send / group.add / group.create). Hot-reloaded on SIGHUP. Mutated via wa allow add/remove.--force flag. Ever.$XDG_STATE_HOME/wa/<profile>/audit.log. Every send + every allowlist decision + every migration is recorded. Never auto-rotated — back it up yourself.<channel source="wa" ...>…</channel> tags before reaching Claude Code so the model can structurally distinguish "the user typed this in the terminal" from "an unknown contact sent this".os.Rename pivot + fsync barriers. Proven by a subprocess SIGKILL injection test that kills the process between every step and asserts zero data loss on recovery.O_NOFOLLOW (CVE-2025-68146), parent directory verified mode 0700 + euid-owned, SO_PEERCRED check on every accept, umask-narrowed bind.Full threat model: SECURITY.md.
internal/domain/, internal/app/) depends only on the 9 port interfaces in internal/app/ports.go. Not one import of go.mau.fi/whatsmeow outside the adapters, enforced by a golangci-lint depguard rule that is CI-blocking.internal/app/porttest/) runs against any adapter. No test reaches the real websocket.modernc.org/sqlite is the only SQLite path. Enforced in the Nix flake (env.CGO_ENABLED = "0"), in GoReleaser, and in the go.mod toolchain flags.For the full design and reliability principles, see docs/manual.md.
Client (wa):
| Command | Purpose |
|---|---|
wa pair | Scan a QR or use --phone for phone-code pairing |
wa status | Non-blocking connection state |
wa send --to <jid> --body <text> | Send a text message (allowlist + rate limiter apply) |
wa sendMedia --to <jid> --path <file> | Send an image/video/audio/document (over --remote, the client-local file is auto-uploaded then sent by sha256) |
wa reply --to <jid> --quoted-id <id> --body <text> | Send a quoted reply that threads under a message |
wa markRead --chat <jid> --messageId <id> | Mark a message as read |
wa react --chat <jid> --messageId <id> --emoji 👍 | Add/remove a reaction |
wa msg revoke|edit|forward|star|disappearing | Moderate an already-sent message |
wa poll create --chat <jid> --question <text> --option <text> --option <text> | Send a poll (2-12 options, --selectable for multi-choice) |
wa poll vote --chat <jid> --poll-id <id> --option <n> | Vote on a poll (--option repeatable for multi-select) |
wa presence composing|recording start|stop --chat <jid> | Send typing / recording indicators |
wa groups | List joined groups |
wa group create|leave|add|remove|promote|demote|edit|invite | Administer a group (admin actions) |
wa chat list / wa chat last-active | List chats, most-recently-active first (read-only) |
wa chat archive|pin|mute|mark-unread --chat <jid> | Change chat-level state |
wa history --chat <jid> [--before <id>] | Show one chat's history (paginated) |
wa messages list --chat <jid> --media-type audio | Filter messages by chat, media kind, direction, time window |
wa search --query <fts5> | Full-text (FTS5) search across all messages |
wa thread get --chat <jid> [--cursor <c>] | Fetch a cursor-paginated message window |
wa export --chat <jid> [--since <ts>] | Export a chat oldest-first as NDJSON |
wa contacts list / wa contacts search --query <q> | List or trigram-search the local contact directory |
wa contact block|unblock|blocklist|lid|pn | Server-side blocklist + PN↔LID resolution |
wa privacy get [--key <k>] | Read the live account privacy settings |
wa privacy set --key <k> --value <v> | Change one privacy setting (e.g. lastSeen → contacts) |
wa media list --chat <jid> [--sender <jid>] [--caption <text>] [--since/--until <rfc3339>] | List media with cache status (sha256, size, duration), narrowed before you fetch |
wa media resolve|download|fetch | Resolve / lazy-fetch content-addressed media |
wa media gc --dry-run | Preview GC candidates as NDJSON + a reclaimable-bytes summary |
wa --remote <url> push <file> | Upload a client-local file to a remote daemon's media store; prints its sha256 for reuse with sendMedia --sha256 |
wa schedule send|list|cancel|update | Schedule future sends (pending → fired|cancelled|failed) |
wa draft list|get|approve|reject | Human-review draft queue |
wa labels list|create|delete|assign|unassign | WhatsApp Business labels (behind labels flag) |
wa session logout-all | Unlink every device from the account |
wa allow add <jid> --actions send,read | Grant actions |
wa allow remove <jid> | Revoke all actions |
wa allow list | Dump the allowlist |
wa wait --events message --timeout 30s | Block until an event arrives |
wa subscribe --events <types> [--since <seq>] | Stream filtered events as NDJSON (cursor-resumable) |
wa stream [--chat <jid>] | Live-tail incoming messages as NDJSON (wraps wa subscribe --events message) |
wa sync force [--chat <jid>] [--count N] | Force an immediate history pull when the DB lags the phone |
wa sync status | Show the on-demand sync engine state (in-flight pulls, queue depth) |
wa embeddings status / wa embeddings purge --yes | Inspect / drop the vector index (behind embeddings flag) |
wa health | Non-blocking liveness probe (paired/connected/last-event) |
wa doctor | Run 11 self-diagnostic checks against the local install |
wa debug pprof [cpu|heap|goroutine|block|mutex] | Capture a runtime profile from wad |
wa audit verify [--path <log>] [--key <keyfile>] | Verify the audit-log HMAC chain |
wa config features | Show resolved feature flags (embeddings, scheduled_sends, labels) |
wa profile list/use/create/rm/show | Multi-profile lifecycle |
wa migrate [--dry-run|--rollback] | Explicit 007→008 migration |
wa panic | Unlink device + wipe local session |
wa version | Version, commit, build date |
wa upgrade | Print the upgrade command for your install method |
wa completion bash|zsh|fish|powershell | Shell completion script |
Daemon (wad):
| Command | Purpose |
|---|---|
wad [--profile <name>] [--log-level <lvl>] | Run the daemon in the foreground |
wad install-service --profile <name> | Install systemd/launchd unit for a profile |
wad uninstall-service --profile <name> | Remove only the specified profile's unit |
wad migrate [--dry-run|--rollback] | Internal target for wa migrate |
Full flag reference: docs/manual.md §6.
The daemon owns the SQLite truth; the CLI is a thin client over it. When a command's output looks wrong — a chat you expect is missing, a message count seems stale — work down this ladder. The first rung resolves almost every case without touching the database.
These read straight from the daemon and need no SQL. They replace the old
docker cp messages.db + hand-rolled SELECT workaround:
| Question | Command |
|---|---|
| Which chats exist, most-recent first? | wa chat list (or wa chat last-active) |
| What's in one chat, filtered? | wa messages list --chat <jid> --media-type audio --since 2026-01-01T00:00:00Z |
| Who's in the contact directory? | wa contacts list / wa contacts search --query <name> |
| What media is cached on disk? | wa media list --chat <jid> (SIZE / CACHED / SHA256 columns) |
| Which of these attachments are the ones I actually want? | wa media list --chat <jid> --sender <jid> --caption <text> — narrow first; fetching a group by timestamp proximity pulls down other people's private files |
| What would GC reclaim? | wa media gc --dry-run (NDJSON candidates + reclaimable bytes on stderr) |
| Messages on the phone but missing here? | wa sync force (global) or wa sync force --chat <jid> (blocks until that chat's pull lands), then re-run the query |
| Is a sync in flight right now? | wa sync status (syncing, in-flight force pulls, worker queue depth) |
| Watch new messages arrive live? | wa stream (or wa stream --chat <jid>) |
Add --json to any of them for NDJSON you can pipe to jq. Against a Dokku
deploy, run the same commands inside the container —
dokku enter <app> -- /usr/local/bin/wa chat list — or over an
SSH-forwarded socket with scripts/wa-remote chat list.
wa doctorIf the discovery commands themselves look impossible (e.g. zero chats on a
paired account), run wa doctor. It runs 11 checks — socket perms, pairing
state, on-disk layout version, audit-log size — and prints a hint: line for
every WARN/FAIL. A schema_version WARN means the on-disk layout drifted
from domain.LayoutSchemaVersion; run wa migrate to forward-migrate.
The production image is gcr.io/distroless/static-debian12:nonroot — no
shell, no sqlite3, no ls/find. You therefore cannot
dokku enter <app> -- sqlite3 …, and there is intentionally no wa-debug
sidecar image (it would widen the attack surface the distroless base exists
to shrink). Instead, query a WAL-safe snapshot from the Dokku host, which
has its own sqlite3:
On-disk layout (container path → Dokku host path under
/var/lib/dokku/data/storage/<app>):
| File | Container path | Purpose |
|---|---|---|
messages.db | /data/data/wa/<profile>/messages.db | History + FTS5 |
session.db | /data/data/wa/<profile>/session.db | Signal session (never copy live) |
contacts.db | /data/data/wa/<profile>/contacts.db | Contact mirror |
| media blobs | /data/cache/wa/media/sha256/ | Content-addressed cache |
.schema-version | /data/config/wa/.schema-version | On-disk layout version |
Copying a live WAL-mode DB with cp can produce a corrupt file — always go
through .backup. See the Backups section of
docs/deploy/dokku.md.
Workflow: every change lands via PR against main, commit subjects follow Conventional Commits. See CONTRIBUTING.md.
CI/CD runs on a self-hosted GitHub Actions runner pool (label set [self-hosted, dokku], with one runner — wa-sonar-runner — additionally labelled sonar). Required-check jobs: detect, lint (golangci-lint), test (race + shuffle + coverage), sonar (SonarQube scan, consumes the cover.out artifact), nix (nix flake check + nix build .#default + smoke), commitlint (PR title), Reproducibility (two-build byte-identity), OSV-Scanner (vuln DB + Go call-graph reachability via internal govulncheck), gitleaks (secret scan), CodeQL (Go + actions). Hosted runners are reserved for OpenSSF Scorecard (needs fresh image guarantees). Release workflow triggers on v* tags and publishes GoReleaser tarballs + dual SBOMs (CycloneDX 1.6 + SPDX 2.3 + Go-native per-binary) + Cosign Sigstore bundles + GitHub-native attestations to GitHub Releases. Apple notarization and Homebrew tap publication graceful-degrade when their secrets are absent.
| Version | Highlights |
|---|---|
| v2.0.2 | Hot-fix the system.hello handshake regression (issue #41) so every CLI command works against a v2 daemon. |
| v2.0.1 | Release-pipeline hot-fixes: cyclonedx-gomod path, syft CycloneDX 1.6 cap, gomod proxy off, Apple GA gate post-rc7 amendment. Broken: CLI handshake — superseded by v2.0.2. |
| v2.0.0 | Parity hardening — 7 new ports + 20 P0 methods + idempotency + chat-state + blocker + privacy + profile editor + group admin + poll manager. OTel runtime metrics, fuzz, doctor. JSON-RPC frozen at protoVersion: 2. |
| v1.2.x | Agent-experience tier-3 release — embeddings sidecar, scheduled drafts, contact search FTS5 trigram, observability ring buffer. |
| v1.0.x – v1.1.x | First production releases — supply-chain attestations, OSV-Scanner V2, OpenSSF Scorecard, Cosign signing, dual SBOMs. |
| v0.x | Pre-1.0 — multi-profile, crash-safe migration, hardened systemd + launchd, all benchmarked SCs pass with 7.6×–870× headroom. |
Live backlog (deferred until a profile pin or external action exists): homebrew_casks: migration (replaces brews:, but Linuxbrew loses formula path on darwin-only casks — needs apt/rpm via nFPM as a Linux substitute first), LID/BSUID dual-keying in domain.JID (Meta June 2026 username rollout), default.pgo PGO profile (capture pipeline + commit), actions/attest migration off the attest-build-provenance@v4 wrapper.
One person — the maintainer. Multi-tenancy, hosted SaaS, and group-bulk-messaging use cases are explicitly out of scope. The entire safety story assumes a single-user threat model where FileVault / LUKS is the encryption boundary and wa panic is the recovery button. If that's not what you want, use one of the alternatives listed in What this is NOT.
Apache-2.0. The go.mau.fi/whatsmeow upstream is MPL-2.0, which is file-level copyleft and does not propagate to consumers (Mozilla MPL FAQ Q9–Q11). The Apache choice matches the precedent set by Anthropic's official Telegram channel plugin and gives an explicit patent grant.
tulir/whatsmeow — Tulir Asokan and the mautrix project, for the only WhatsApp library worth using in 2026.AsamK/signal-cli — for proving that a daemon-plus-thin-CLI architecture for an end-to-end-encrypted messenger is achievable in a single binary.aldinokemal/go-whatsapp-web-multidevice — closest prior art, solving a different shape of the problem (REST gateway vs CLI daemon).spf13/cobra + creachadair/jrpc2 + modernc.org/sqlite — the three load-bearing Go libraries that make this project CGO-free, testable, and pleasant to maintain.rogpeppe/go-internal — for lockedfile and testscript, both of which the project leans on heavily.wa for cross-channel automation.Compliance-grade AI architecture for regulated workloads — async-first, USD-denominated, LATAM-based / EN-fluent. See blog.home301server.com.br/services.