Skip to content

Getting started

This guide links WaWarden to a real WhatsApp account. It takes you from an empty host to a paired service with a verified backup and a first client registered in Claude Code. To try WaWarden without an account first, use the fake engine demo in the README.

Read the residual risks and the ban-risk notes before linking an account. WhatsApp can restrict, log out or ban an account that links an unofficial client.

The commands use ./wawarden and the default addresses on 127.0.0.1. To run in a container instead, read Running in a container alongside each step.

Contents

Prerequisites

  • The binary or the image. One of:

    • a release binary, wawarden_<version>_linux_amd64 or wawarden_<version>_linux_arm64, verified as RELEASING.md describes and saved as an executable wawarden;
    • the image ghcr.io/dortort/wawarden, pulled by the digest in a release's notes and verified the same way;
    • a build from source with git and Go 1.21 or later, which downloads the toolchain that go.mod names: go build -trimpath -o wawarden ./cmd/wawarden.
  • A user other than root to run the service. serve refuses to start as root.

  • The age tools on a second machine, to hold the key that decrypts backups.
  • openssl, if you want the webhook, to create its signing secret.
  • A supervisor that keeps the service's standard output and gives it more than 10 seconds to stop, such as systemd (see the hardening guide's unit) or a container runtime.
  • The owner's phone, with WhatsApp signed in to the account you link.
  • A way to reach the service over HTTPS from the agent's host: a mesh VPN such as Tailscale, a private subnet with TLS, or a reverse proxy with TLS (see network patterns).
  • Claude Code, or another MCP client, on the agent's host.

1. Prepare the secrets

In a directory that only you can read, generate the admin token. Keep the token and its hash in separate files. The service is given the hash only. Keep a copy of the token in a secret manager, from which --token-command can also read it:

umask 077
./wawarden admin init > admin.txt
sed -n 's/^sha256: //p' admin.txt > admin.sha256
sed -n 's/^token: //p' admin.txt > admin.token
rm admin.txt

On the second machine, create the key pair that backups are encrypted to. Keep backup-identity.txt there. The service needs only the public key, age1..., that age-keygen prints:

age-keygen -o backup-identity.txt

For a webhook, also create its signing secret, readable only by the service's user:

openssl rand -hex 32 > notify.secret

2. Configure and start

Set the owner's number in E.164 form, the hash file, the backup recipient and a data directory whose parent exists. Then start the service:

export WAWARDEN_DATA_DIR="$HOME/wawarden-data"
export WAWARDEN_OWNER_PHONE=+15550100001
export WAWARDEN_ADMIN_TOKEN_SHA256_FILE="$PWD/admin.sha256"
export WAWARDEN_BACKUP_AGE_RECIPIENT='age1...'
./wawarden serve

serve runs in the foreground, so run the admin commands of the next steps in a second terminal. For a lasting deployment, run it under a supervisor instead; the systemd unit sets the same variables.

Optional variables:

  • WAWARDEN_NOTIFY_URL and WAWARDEN_NOTIFY_SECRET_FILE send every event to a signed webhook.
  • WAWARDEN_METRICS_EMF=1 writes metrics on standard output in embedded metric format (EMF).

Environment variables lists every variable. Any other WAWARDEN_ variable stops the start (see startup refusals).

The start writes JSON lines such as keys_loaded, archive_opened, session_opened with "paired":false, one listening per listener and ready. With no device stored, the engine reports unpaired and makes no connection to WhatsApp. Without a backup recipient, every start warns backup_disabled.

The first start creates the master key, keys/master, in the data directory. No backup holds it, so keep a copy of it off the host. Without it, a restored archive gets a new key: pseudonyms, cursors and message references change, and audit verify reports the older audit rows as unknown_key (see Restoring a backup).

3. Put it behind HTTPS

The listeners speak plain HTTP on loopback. The agent's host reaches the client listener through something that encrypts the path and decides who may connect. Never expose WaWarden to the public internet.

Choose one of the network patterns:

  • Mesh or VPN forwarding to loopback, such as Tailscale Serve. It keeps the service on its defaults. For example, tailscale serve --bg 8080 serves the client listener at https://<machine>.<tailnet>.ts.net/. Never use tailscale funnel, which exposes a port to the whole internet.
  • Private subnet, with TLS on top unless you control the segment.
  • Reverse proxy with TLS, optionally with client certificates. Read its list of constraints on bodies, timeouts and headers.

Expose the client listener, port 8080, only. Keep the admin listener on the service's host, or gate it separately (see the hardening guide).

Check from the agent's host. Without a token, the service answers 401:

curl -i https://<your-host>/v1/me

4. Pair

./wawarden admin pair --token-file admin.token

It prints pairing code: <code>, and on standard error where to enter it. On the owner's phone, in WhatsApp, open Linked devices, choose Link a device, then Link with phone number instead, and enter the code. WaWarden links as a Chrome (Linux) device. The code goes to you only; it is never logged or kept.

Refusal Exit code What to do
owner_phone_missing 5 Set WAWARDEN_OWNER_PHONE and restart.
already_paired 5 A device is stored already; see admin status.
rate_limited 5 Three attempts were made in the last hour; wait.
pair_failed 1 The connection failed or no code came within 25 seconds. Check outbound access to web.whatsapp.com and try again.

Only the account whose number is WAWARDEN_OWNER_PHONE can be linked. A code entered on another account's phone is refused before anything is stored. If such a pairing completes, the new device is logged out again. Either way the service reports pair_rejected.

5. Watch the status

./wawarden admin status --token-file admin.token
state: connected
reason: none
paired: true
chats: 12
messages: 3456
history blobs pending: 0
history blobs quarantined: 0
inbox backlog: 0
inbox quarantined: 0
backfills today: 0
clients active: 0
clients expired: 0
clients revoked: 0
all-chats clients active: 0
last ingest: 2026-10-05T08:00:00Z
version: <version>

state is unpaired, connecting, connected or disconnected, the last with a reason. Right after pairing, WhatsApp asks the new device to log in again. The engine handles that as a dropped connection, so expect a short connecting before connected.

Each change is logged as engine_state, and the gauges wawarden_paired and wawarden_connected follow it. Every stop that waits for you is reported as a disconnected event, on standard output and to the webhook. Disconnect reasons says what to do for each reason.

6. The first history sync

Once paired, the owner's phone sends the chat history in blobs:

  • history blobs pending rises as they are announced. It returns to 0 as each is downloaded into history/, applied to the archive and deleted.
  • chats, messages and last ingest grow with it, and so does wawarden_messages_ingested_total.
  • Blobs are downloaded only while connected. A restart takes up those still waiting.
  • A blob larger than WAWARDEN_HISTORY_MAX_BYTES (32 MiB by default), or one that fails three times, is quarantined. The quarantine event reports it with queue history, and history blobs quarantined counts it.

History sync gives the details.

7. The first backup

With a backup recipient, the service takes a first backup once both hold:

  • no history blob is waiting;
  • 10 minutes have passed since the later of the pairing and the latest blob's arrival.

It writes backups/<UTC time>.age, such as backups/20261005T120000Z.age, and reports backup_done, or backup_failed with a reason. A failed backup is tried again after 15 minutes, then after waits that double up to 4 hours. From then on it takes one backup each night at WAWARDEN_BACKUP_TIME, 03:00 UTC by default, and keeps the newest WAWARDEN_BACKUP_KEEP, 14 by default (see backups).

Copy the backup off the host, then check it on the machine that holds the age identity:

set -o pipefail
mkdir -m 700 verify
age --decrypt -i backup-identity.txt 20261005T120000Z.age |
  ./wawarden backup verify --work-dir verify -

It prints counts only and ends with verified: yes. Restoring a backup gives the restore procedure.

8. The first client

List the chats the agent may read, then create a client for them:

./wawarden admin chats list --match <part of a name> --token-file admin.token
./wawarden admin clients create --token-file admin.token \
  --name claude-code --read <id, +E.164 number or ref> --expires-days 90
  • --read takes a chat's id, a +E.164 number or the ref that admin chats list prints. It can be repeated.
  • The client expires after 90 days unless --expires-days says otherwise, 365 at most.
  • The command prints the client's lines, then token: ww_..., once.

Keep the token in a secret manager, or in the agent host's environment as WAWARDEN_TOKEN. Never put it in a repository or in the service's environment (see Registering with Claude Code).

admin status now counts the client under clients active. Each request it makes is written on standard output with its chain head (see the audit chain).

A client that sends

A client that sends needs --write for each chat it may send to. Each write chat must also be one of its --read chats, and an all-chats client cannot write:

./wawarden admin clients create --token-file admin.token \
  --name claude-code-reply --read <chat> --write <chat>

Sending to a direct chat that has never written to the account is refused unless the client was created with --allow-first-contact. Leave it off unless the agent must start conversations (see the ban-risk notes).

Register with Claude Code

On the agent's host, register the endpoint at the HTTPS address from step 3:

claude mcp add --transport http whatsapp https://<your-host>/mcp --header "Authorization: Bearer $WAWARDEN_TOKEN"

claude mcp get whatsapp shows it as connected. /mcp in a Claude Code session lists five tools: get_changes, get_chat, get_messages, list_chats and search_messages. A client with write chats also gets send_message.

This form stores the token itself in ~/.claude.json. Registering with Claude Code says what each --scope writes, and how to keep the token out of files with .mcp.json or a headersHelper.

Before the agent reads anything, read the guide for agents: every name and text it returns is third-party text.

Running in a container

The image runs as 65532:65532, keeps its data in the /data volume and has no shell. Leave WAWARDEN_DATA_DIR unset, pass the hash itself as WAWARDEN_ADMIN_TOKEN_SHA256, and pass the other variables with -e. The full contract is in the container image, and the Docker Compose example shows the same settings as a Compose file.

Prepare the secrets with the image, as in step 1:

image=ghcr.io/dortort/wawarden@sha256:<digest>
umask 077
docker run --rm "$image" admin init > admin.txt
sed -n 's/^sha256: //p' admin.txt > admin.sha256
sed -n 's/^token: //p' admin.txt > admin.token
rm admin.txt

Start the service. The client listener binds 0.0.0.0 inside the container and is published on the host's loopback only, for the HTTPS front of step 3. The service logs listener_not_loopback for it at every start. The admin listener stays on the container's loopback:

docker volume create wawarden-data
docker run -d --name wawarden --read-only --stop-timeout 15 \
  --mount type=volume,src=wawarden-data,dst=/data \
  -e WAWARDEN_ADMIN_TOKEN_SHA256="$(cat admin.sha256)" \
  -e WAWARDEN_OWNER_PHONE=+15550100001 \
  -e WAWARDEN_BACKUP_AGE_RECIPIENT='age1...' \
  -e WAWARDEN_LISTEN=0.0.0.0:8080 \
  -p 127.0.0.1:8080:8080 \
  "$image"
docker logs wawarden
docker exec wawarden /wawarden healthcheck && echo healthy

Run the admin commands inside the container, with the token on standard input:

docker exec -i wawarden /wawarden admin pair --token-stdin < admin.token
docker exec -i wawarden /wawarden admin status --token-stdin < admin.token
docker exec -i wawarden /wawarden admin chats list --token-stdin < admin.token
docker exec -i wawarden /wawarden admin clients create --token-stdin \
  --name claude-code --read <id, +E.164 number or ref> < admin.token

Copy a backup out of the volume to check it, as in step 7:

docker cp wawarden:/data/backups/20261005T120000Z.age .

For a webhook, mount its secret file read-only and point WAWARDEN_NOTIFY_SECRET_FILE at it inside the container. It must be readable by 65532:65532, and grant no write permission to its group and no permission to others:

sudo chown 65532:65532 notify.secret
sudo chmod 0400 notify.secret
# add to the docker run command:
#   --mount type=bind,src="$PWD/notify.secret",dst=/run/secrets/notify.secret,readonly
#   -e WAWARDEN_NOTIFY_URL=https://<webhook>
#   -e WAWARDEN_NOTIFY_SECRET_FILE=/run/secrets/notify.secret

The image declares no health check. Use ["/wawarden","healthcheck"] in exec form in Compose or Kubernetes; docker run --health-cmd needs a shell the image does not have. Container hardening covers a sidecar for Tailscale and the other settings.

You now have

Each item has a command that confirms it. In a container, run the wawarden commands with docker exec -i wawarden /wawarden ... --token-stdin < admin.token.

  • A healthy service under a supervisor. ./wawarden healthcheck && echo healthy prints healthy.
  • A paired, connected account. ./wawarden admin status --token-file admin.token | grep -E '^(state|paired):' prints state: connected and paired: true.
  • A settled history sync. The same status shows history blobs pending: 0.
  • A verified backup off the host. ls "$WAWARDEN_DATA_DIR/backups" lists a .age file, and backup verify printed verified: yes on the second machine.
  • A copy of the master key off the host. sha256sum "$WAWARDEN_DATA_DIR/keys/master" matches the copy's checksum.
  • The client listener behind HTTPS only. curl -i https://<your-host>/v1/me from the agent's host answers 401 Unauthorized.
  • A scoped client. ./wawarden admin clients list --token-file admin.token shows it as state: active, with its expires date.
  • The MCP endpoint registered. claude mcp get whatsapp shows it as connected.

What next