Overview
The WhatsApp transport (internal/transport/whatsapp/) is backed by go.mau.fi/whatsmeow — a reverse-engineered WhatsApp Web multi-device client. Meta considers this an unofficial client; do not run it on a personal number you rely on for anything important.
Signal-protocol end-to-end encryption is preserved (whatsmeow uses the same protocol as the WhatsApp mobile app). The daemon holds device credentials in a SQLite file separate from the session store, so a device relink does not touch conversation history.
Pairing
First launch:
rousseau whatsapp --allow 447900123456@s.whatsapp.net
A QR code prints to stdout via mdp/qrterminal/v3. Scan it with the WhatsApp phone app (Settings → Linked Devices → Link a Device). The pairing state is written to whatsapp.db under the state directory (typically ~/.local/share/rousseau/whatsapp.db).
Subsequent launches reuse the paired device silently. If the QR reappears, the pairing has been revoked from the phone side — delete whatsapp.db and pair again.
Allowlist
--allow restricts inbound handling. Multiple flags accumulate:
rousseau whatsapp \
--allow 447900123456@s.whatsapp.net \
--allow 442071234567@s.whatsapp.net
The value is a WhatsApp JID — the E.164 phone number (no +) followed by @s.whatsapp.net. Group JIDs (<id>@g.us) are also supported.
Empty allowlist accepts every sender. For a chat-transport daemon you always want at least one entry.
LID vs phone-JID normalisation
WhatsApp uses two identifier formats for a user:
| Format | Example | Meaning |
|---|---|---|
| Phone JID | 447900123456@s.whatsapp.net |
The E.164 phone number, without +, followed by @s.whatsapp.net. Stable across time; leaks the phone number. |
| LID | 1234567890@lid |
Location-Independent ID — a random-looking string that does not reveal the phone number. Also stable, but not directly linkable to a number. |
| Device suffix | 447900123456:5@s.whatsapp.net |
Any JID can carry a device-address suffix (:N). WhatsApp reports messages with the specific device that sent them. |
Rousseau's inbound handler (ResolveInbound in internal/transport/whatsapp/resolve.go) normalises every event to a canonical form before dispatching:
- Strip the device suffix.
447900:5@s.whatsapp.netbecomes447900@s.whatsapp.net. This lets allowlists written as bare user JIDs match regardless of which linked device sent the message. - Substitute LID for the account holder's phone JID in self-chat. When the account holder is the sender (
IsFromMe=true), WhatsApp reports the sender as the account's LID (a privacy hash), not the phone JID. Rousseau substitutes the account's own JID so operators can allowlist<phone>@s.whatsapp.netand have self-chat testing route correctly. - Drop unparseable senders. Empty
UserorServerfields — discovered byFuzzResolveInbound— cannot be routed safely. The message is silently skipped rather than passed to the handler as a malformed From.
Self-chat gotcha
When you send a message to yourself in WhatsApp (to test the bot), the sender field arrives as your LID. If you allowlisted your phone JID, the naive lookup would miss. Rousseau's substitution — if evt.Info.IsFromMe && ownID != nil { from = ownID.ToNonAD() } — fixes this.
Loop prevention
IsFromMe=true also fires for messages sent by this linked device (rousseau's outbound replies echoing back). The transport drops those when the device ID matches:
if evt.Info.IsFromMe && ownID != nil && evt.Info.Sender.Device == ownID.Device {
return Resolved{Skip: SkipOwnDevice}
}
Messages from the account's other linked devices (e.g. the primary phone testing "message yourself") carry IsFromMe=true but a different device ID — those are handled normally.
Allowlist regex patterns
The --allow flag takes exact strings, not regexes — rousseau performs a case-insensitive equality check in router.go. If you want pattern matching, use the config file with pattern mode (the same as approval policies):
whatsapp:
allowlist:
- "447900123456@s.whatsapp.net"
- "447900654321@s.whatsapp.net"
For groups (<hash>@g.us), add them the same way. To allow everyone from a given country code, you would need a custom Router.Allow implementation — the built-in enforcer does not do prefix matching by design.
Reply header
Every outbound message is prefixed with a header so the sender knows which bot they are talking to. The default:
💎 *Rousseau Agent*
<message body>
WhatsApp renders *text* as bold. Override in config:
whatsapp:
reply_header: "🤖 *Coding bot*\n\n"
Set to a single space " " to disable the prefix entirely.
Voice-note transcription
Inbound voice notes are transcribed via whisper.cpp when the operator opts in. Off by default because it requires the whisper CLI to be installed.
whatsapp:
voice:
enabled: true
binary: whisper
model: base.en
language: en
extra_args:
- --threads
- "4"
| Field | Effect |
|---|---|
enabled |
Toggle. When off, audio messages are logged and skipped. |
binary |
Whisper CLI executable. Empty defaults to whisper. |
model |
Passed to --model (base.en, small, medium). |
model_path |
Explicit .bin path. Takes precedence over model. |
language |
Passed to --language. Empty auto-detects. |
extra_args |
Appended to every invocation. |
The transcribed text is handed to the agent as if the user had typed it.
Container deployment
The reference Podman Quadlet unit (docker/rousseau-agent.container) mounts the state directory read-write so the pairing survives restarts:
Volume=%h/.local/share/rousseau:/home/rousseau/.local/share/rousseau:rw,Z
Network=pasta gives the container a rootless egress-only stack. Whatsmeow needs no elevated capabilities; DropCapability=all is safe.
Voice-note transcription flow
When a voice note arrives, the standard resolver returns SkipEmptyText (no text content). Dispatch detects this specifically for audio messages and — if a Transcriber is configured — proceeds through this path:
Inbound audio message
│
├── Downloader.Download(ctx, audioMsg)
│ • bytes []byte, mimetype string, err error
│ • Logs whatsapp.audio_downloaded on success
│
├── Transcriber.Transcribe(ctx, audio, mimetype)
│ • Returns plain-text transcription
│ • Logs whatsapp.transcribed with duration
│
└── Re-enter handleTextMessage with the transcription as `Body`
If no transcriber is configured, the daemon logs whatsapp.audio_ignored reason=transcriber_not_configured and drops the message. Voice notes never trigger a "silence" reply — an empty inbound produces an empty outbound.
Media downloads
The Downloader interface is small on purpose:
type Downloader interface {
Download(ctx context.Context, msg DownloadableAudio) (bytes []byte, mimetype string, err error)
}
Currently only audio download is wired. Image and video downloads are on the roadmap — they arrive as waProto.ImageMessage / VideoMessage and would need a corresponding DownloadableMedia interface. Track docs/GAP_ANALYSIS_2026.md for the plan.
Typing indicators
The handler wraps every reply in SendPresence(Composing, Paused) calls so the sender sees the "…is typing" indicator while the model thinks. Both calls have a 5-second timeout and are best-effort — a presence failure never blocks the reply itself.
Failure modes
| Symptom | Fix |
|---|---|
| QR reprints on every restart | Pairing has been revoked from the phone; delete whatsapp.db and re-pair. |
| WhatsApp reconnect loop | Check clock skew against pool.ntp.org — whatsmeow's handshake is time-sensitive. |
| Inbound messages ignored | Verify the sender is in the --allow list; check logs for router.transport.rejected. |
| Meta bans the number | Do not run on a personal number. The protocol is unofficial. |
| Self-chat "hello" not routed | Self-chat uses LID; rousseau substitutes to phone JID for allowlist matching. Verify ownID is initialised — the daemon logs whatsapp.connected when it is. |
| Voice notes silently dropped | Either whatsapp.voice.enabled: false or the whisper binary is missing. Log line: whatsapp.audio_ignored. |
| Every reply comes back to me twice | Loop prevention is off. Ensure you are running a recent build; the fix landed in ResolveInbound early in the whatsmeow multi-device rollout. |
Troubleshooting
QR is printed but the phone app rejects it
Three common causes: (1) a partially-completed prior pairing left whatsapp.db in a state whatsmeow cannot reuse — delete the file and re-scan; (2) the clock is skewed by more than 30 seconds (common in containers without NTP) — check with timedatectl status; (3) an older whatsmeow version can miss a Meta protocol update.
whatsapp.connected then whatsapp.disconnected in a loop
Clock skew, or Meta has invalidated the pairing. Check whatsapp.logged_out events in the log — that's the definitive signal.
Voice notes arrive but never get transcribed
The transcriber binary is not resolvable. Check whatsapp.voice.binary and whatsapp.voice.model_path — both must point at real files (or binary must be on PATH).
Allowlist regex not matching
Rousseau's allowlist is exact-string, not regex. To match a range of senders, list each one explicitly or add a custom router.
Reply header shows up as literal * characters
The recipient's client does not render WhatsApp Markdown. This is a client-side rendering issue; use plain text if your recipients are on older clients.
Related pages
- Getting Started: First Transport — end-to-end walkthrough.
- User Guide: Voice Mode — voice-note deep dive.
- Configuration — the
whatsappconfig block. - Transports — the other eight transports.
- Deployment — running WhatsApp in a Podman container.
Further reading
internal/transport/whatsapp/client.go— connect, QR pairing, event pump.internal/transport/whatsapp/resolve.go— LID/JID normalisation and self-chat handling.internal/transport/whatsapp/dispatch.go— inbound message dispatch with voice-note branching.internal/transport/whatsapp/whisper.go— reference whisper-cpp transcriber.internal/cli/whatsapp.go— CLI wiring, store DSN, transcriber selection.