decided, and the exact plan to build a native C++ WhatsApp client into detz.
Research archive: [`../../research/whatsapp-automation/`](../../research/whatsapp-automation/)
(sources and three conclusions). Every number below was measured on 2026-10-27
on this machine unless a line says otherwise.
## 1. Goal
One sentence must work in detz:
> "Create screenshot and explain what is genius about it and send to Niggel on WhatsApp"
The flow breaks into four steps. Three of them already exist:
| Step | Owner | Exists |
|---|---|---|
| take a screenshot | `shell` tool (`grim`, `scrot`, `import`) | yes |
| explain it | `describe_image` tool (src/agent/tools_core.cpp) | yes |
| resolve "Niggel" to a WhatsApp JID | `WaContacts` (this spec) | no |
| send text plus image | `whatsapp_send` tool over `WaClient` (this spec) | no |
The missing part is one module: a WhatsApp companion device written in C++.
## 2. Answers to the questions asked
### 2.1 Best Python libraries for a normal phone number
There is no Python library that speaks the current WhatsApp protocol itself.
Measured on the cloned repositories:
| Library | Language of the protocol code | How it works | Verdict |
|---|---|---|---|
| neonize (Apache-2.0, 759 stars, 62 commits in 90 days) | **Go** (whatsmeow) | 4362 lines of Go export 93 C functions (`//export`), 14918 lines of Python call them through `ctypes` | best "Python" option, but it is whatsmeow with a Python skin |
| whatsfly | Go (whatsmeow) | same idea, PyPI 0.0.1, linux 32 bit only | early, not usable |
| whatsapp-mcp (lharries, MIT) | Go (whatsmeow) | Go bridge with HTTP `/api/send`, Python MCP server with `send_message`, `send_file` | usable shortcut, see section 10 |
| yowsup (GPL-3.0) | Python | old phone-registration protocol (`e1.whatsapp.net:443`), last commit 2021-12-14, README warns that registration gives an instant ban | dead, do not use |
| pywhatkit, Astra, wa-automation, whalesong | Python | drive WhatsApp Web in a real browser | browser, rejected |
| Supbot2 | Python | drives the Android app with Appium | needs a phone or emulator, rejected |
| pywa and other Cloud API clients | Python | official Meta Business Cloud API | needs a business number; the number cannot stay on the normal app, rejected for a personal number |
Conclusion: transpiling Python gets us nothing. The real implementations are:
| Project | Language | License | Activity (commits in last 90 days) | Size (non-blank, non-test, non-generated) |
|---|---|---|---|---|
| whatsmeow (tulir) | Go | MPL-2.0 | 78 | 24,301 lines, plus 107,816 generated protobuf lines |
| libsignal-protocol-go (tulir), used by whatsmeow | Go | **GPL-3.0** | not measured | 5,416 |
| wa-go (jfelipesjc) | Go | **MIT** | 17, created 2026-06-26, 1 star | 23,278 (wire 2778, signal 1625, keys 311, media 517, client 14379) |
| `WaClient` | owns the above; connect, login, keepalive, reconnect, event loop on one thread | whatsmeow `client.go`, `keepalive.go`, `connectionevents.go` |
| `WaContacts` | name to JID: user aliases first, then contacts learned from push names, then fuzzy match with `detz::FuzzyMatcher` (floor 0.3) | none, detz design |
| media encrypt, upload | build | wa-go `media_vectors.json`, then one live send |
| usync, prekey upload, send node shape | build | whatsmeow source, then one live send |
| **XEdDSA sign** | **deep research** | port the Edwards25519 field/scalar/point subset (BSD-3, from filippo.io/edwards25519) or find a permissive C implementation; verify against wa-go `keys/testdata/verify_sig.mjs` style vectors |
| **current client version and 405 policy** | **deep research** | how often whatsmeow bumps `waVersion`; whether to fetch `client_revision` at start (adds one HTTPS call, kills the treadmill) |
| **LID addressing** | **deep research** | when the server hands out `@lid`, how sessions map phone JID to LID; read whatsmeow `send.go` and `store` LID tables |
| **ban behaviour** | **deep research, measured only with a sacrificial number** | no maintainer publishes heuristics; one vendor article names account age, message rate and reports |
| contact names without app-state | build | push names in incoming messages plus user aliases; app-state sync is v2 |
## 7. User flow (the customer view)
Two commands, nothing else to configure:
```
detz --whatsapp-link # shows a QR in the terminal, scan in WhatsApp > Linked devices
detz --whatsapp-link 31612345678 # prints an 8-character code to type on the phone instead
```
Then, in any detz session:
```
> remember that Niggel is 31687654321 on WhatsApp
> create a screenshot, explain what is genius about it and send it to Niggel on WhatsApp
```
The first line calls `whatsapp_alias`. The second runs `shell` (screenshot),
`describe_image`, then `whatsapp_send{to:"Niggel", text, image}`. `whatsapp_send`
resolves the name through `WaContacts`; when the best match is not exact, the
tool result names what it resolved to, the same note pattern as `@agent`
mentions. `detz --whatsapp-unlink` logs the device out and deletes its rows.
State lives in the profile's vault (SQLite, the existing `store`), not in a new
file. The link step is the only step that touches the phone.
Agent tools:
| Tool | Arguments | Result |
|---|---|---|
| `whatsapp_send` | `to` (name, alias or number), `text`, optional `image` (path) | message id, resolved JID and name, `phash` match |
| `whatsapp_contacts` | optional `query` | known names and JIDs |
| `whatsapp_alias` | `name`, `number` | stored alias, after a usync check that the number is on WhatsApp |
## 8. Caps (all static, per CLAUDE.md "everything gets a hard cap")
# GetQRChannel, Connect, print each event; expect "code" events within seconds
go run .
```
## 11. Phases
| Phase | Content | Exit test |
|---|---|---|
| 0 | MCP shortcut (section 10) | the sentence works once, live |
| 1 | `WaProto*`, `WaCrypto`, `WaXeddsa`, `WaSocket`, `WaNoise`, codec, tokens | all wire and crypto golden vectors green; live: QR events from the real server |
| 2 | `WaStore`, `WaPairing`, `WaPrekeys`, login | live: link by QR and by code, restart stays logged in |
| 3 | `WaSignal`, `WaUsync`, `WaSender`, `WaReceiver` | Signal vectors green; live: text to a peer and its reply decrypted |
| 4 | `WaMedia`, `WaContacts`, `WaTools`, CLI flags | live: the full sentence, then the MCP shortcut is removed |
Size guide (measured Go source to transpile, non-blank, non-comment): whatsmeow
files on the 1:1 path (handshake, pair, pair-code, prekeys, send, message,
| Key receipt | `wa_receiver.*`, `wa_proto.*` (`parse_protocol_message`), `WaClient::handle_message` | Decrypts `<enc>` from our own account (pkmsg via X3DH responder, msg via session), unpads, stores each APP_STATE_SYNC_KEY_SHARE key in `wa_appstate_key`. Own means our phone number or our LID (whatsmeow `IsFromMe`); other senders are acked and ignored as before. Every arrival is logged before the filter, so a trace shows dropped mail too. An empty share (keys without key data, openclaw/wacli PR 443) is reported and ends the wait without storing; a consumed peer message gets a `peer_msg` protocol receipt. |
| Key request | `WaClient::request_app_state_keys`, `send_peer_message`, `encode_app_state_key_request` | Peer message to the primary phone (category `peer`, meta + enc, device-identity only on a prekey message), at most `kWaAppStateKeyIdsMax` ids, one request per key id per `kWaAppStateKeyRequestInterval` (24 h), stored in `wa_appstate_keyreq`. The send is verified (Signal session must exist, the server ack must carry no error); when it fails the 24 h marks are rolled back so the next sync asks again (whatsmeow PR 1268). |
| Notifications, receipts | `WaClient::handle_notification`, `handle_receipt` | `server_sync` re-syncs the contact collection (other collections are named and skipped); `encrypt` with fewer than `kWaMinPrekeys` left refills prekeys; a retry receipt (the phone could not decrypt our message) is logged loudly. Notification syncs never nest inside a sync (guard + one more pass). |
| Key expansion, MACs, values | `wa_appstate.*` (`WaAppStateKeys`, `WaAppStateCrypto`) | HKDF-SHA256 `WhatsApp Mutation Keys` to 160 bytes; value MAC HMAC-SHA512 truncated to 32; index, snapshot and patch MACs HMAC-SHA256; AES-256-CBC values. `WaCrypto::hmac_sha512` added. |
| LTHash | `wa_appstate.*` (`WaLtHash`) | 128-byte state, HKDF-SHA256 info `WhatsApp Patch Integrity` (whatsmeow, not BLAKE2b; see research conclusion `appstate-lthash-hkdf-not-blake2b`). |
| Patch fetch and apply | `WaAppStateSync::sync` | iq `w:sync:app:state`, `return_snapshot` at version 0, `version=N` otherwise; external blobs via `WaMedia::download` (`md-app-state`); snapshot MAC, patch MAC, index and value MACs checked before anything is stored. |
| Contacts | `WaAppStateSync::apply_contact` | SET stores jid = index[1], name = fullName else firstName; empty name is counted and skipped; REMOVE deletes the row. |
| Push-name learning | `WaClient::handle_message`, `push_name_candidate`, `WaStore::put_contact_if_absent` | Every incoming message carries the sender's self-reported name in `notify` (whatsmeow `message.go`); group senders come from `participant`, the PN alternate is preferred when present. Stored only when the jid has no name yet, so a saved, imported or aliased name always wins; truncated to `kWaPushNameMax` (128). No server query returns names for regular users (`GetUserInfo` usync has status, picture, devices, LID and business verified names only), so this plus VCF/alias/app-state is the complete name-resolution stack. |
| Login hook | `WaClient::login` calls `sync_app_state` after prekeys | Never fails the login (errors are logged). If a key is missing, requests it, waits up to `kWaAppStateKeyWait` s for a key share, then syncs once more. |
| Long sync | `detz --whatsapp-sync` (`WaWizard::sync`) | Logs in, re-syncs with a `kWaSyncOnlineWait` (300 s) key wait for a sleeping phone, reports the contact count. Exit 0 with contacts, 1 without, 2 when not linked or login failed. |
| VCF fallback | `detz --whatsapp-import FILE` (`WaVcf`) | Imports a .vcf exported on the phone (FN else N, TEL lines, international digits) into the same contact search. Loud skips, capped at `kWaContactsMax`, file capped at `kWaVcfFileMax`. |
## 15. Incident WA-2026-10-27b: link ended in 401, no picture, no contacts
Everything here was measured on 2026-10-27/28 against the live server and the user's phone.
| Symptom | Root cause | Fix | Evidence |
|---|---|---|---|
| `Linked, but the login confirmation did not complete`; later login `failure 401` | The decoder printed a server-only JID (empty user) as `@s.whatsapp.net`; the pairing replies copied that into `to`, so the server refused `pair-device-sign` and never registered the device | decoder returns the bare server name; encoder writes bare server names as JID pairs; replies use `WaConsts::kServerJid` as whatsmeow does | captured `pair-success` decoded (`from=@s.whatsapp.net`), live login probe 401 before, `logged in` after re-link; test `server-only JID round-trips` |
| wizard never reported the real login result | `run_until` returned at once because `paired_event_` stayed set | `run_until` is stateless; `WaClient::await_pair_restart` + `WaClient::login`; one fresh pairing after a 401/403 (`kWaLinkRetries`) | wizard source |
| `user 7m` CPU while linking | a closed socket made every receive return instantly and the loops spun until their deadline | `WaSocket` clears `connected_` on close, every loop checks `WaClient::socket_open()` | probe after the 401: 1.7 s, 0.01 s CPU (was 20 s of spinning) |
| PNG could not be sent | the `whatsapp_send` tool had no image argument and `send_image` was a stub | `WaMedia` (HKDF `WhatsApp Image Keys`, AES-CBC, 10-byte HMAC, `media_conn`, HTTPS upload), `send_image`, DeviceSentMessage copy to our other devices, tool argument `image` | 8/8 wa-go golden vectors byte-exact; live: image to own number acknowledged by the server |
| Nigel not found | the phone address book reaches a companion only through app-state sync, which did not exist; and the phone only pushes history and keys to a companion that declares `DeviceProps.historySyncConfig`, which registration omitted | section 14 (`WaAppState`, `WaReceiver`); `WaDeviceProps` now sends `historySyncConfig` (field 5, whatsmeow values); peer key request encrypted to the phone's LID identity | unit tests; the live address book needs a link made with this build |
| name lookups were exact or one fuzzy guess | contact table only, one `FuzzyMatcher::Best` | `WaStore::search_contacts`: FTS5 trigram candidates + fuzzy rank; ambiguity (two scores within 0.08) is reported, never guessed; the global `search` tool lists matching contacts | tests `contact search: ...` |
| no send limits | caps were specified, not enforced | `wa_send_log`, `kWaSendPerMinute` 10 and `kWaSendPerDay` 200 enforced in `whatsapp_send` | test `send log counts sends in the window` |
Gaps: the contact sync and the address-book key share are not verified against a phone yet; a newly linked device
must be created with this build for the phone to send history. Set `DETZ_WA_TRACE=1` to log every node's tag and
DeviceProps (including `historySyncConfig`), the peer node shape, the key
request bytes and the padding all match. whatsmeow answers the open questions
this way:
- A peer message needs a Signal session with the phone first:
`encryptMessageForDevice` with no bundle and no session returns
`ErrNoSession` and the request never leaves the process (hence whatsmeow PR
1268, which rolls back the 24 h mark on send failure, and Volt-Chat PR 5,
which adds an explicit session setup).
- Own senders are the PN user or the LID user (`parseMessageSource`); no
`recipient` attribute is needed. The `<ib dirty type=account_sync>` node
needs no answer (whatsmeow `handleIB` leaves it alone).
- The `jurisdiction` and `encryption-metadata` nodes in `pair-success` are
server-side account metadata; whatsmeow `handlePairSuccess` ignores every
child it does not know, as we do. Not related to key sharing.
Fixes in this build (all local; the phone side is still unseen):
| Gap | Fix | Evidence |
|---|---|---|
| a failed key request still stamped the 24 h mark, blocking the retry | `clear_app_key_request_time`, rollback on send failure (whatsmeow PR 1268) | test `key request rollback` |
| a peer send was trusted without checking the session or the ack | session verified after the prekey fetch, ack error attribute checked and logged (Volt-Chat PR 5) | code, log lines |
| device-identity rode on every peer message, whatsmeow only on pkmsg | `build_peer_message_node` matches whatsmeow | test `peer message helpers` |
| key shares from our LID were silently dropped (PN-only filter) | `is_own_sender` accepts PN or LID, every arrival is logged before the filter | test `peer message helpers` |
| an empty key share looked like no answer | stored/empty counted separately, the wait ends, `peer_msg` receipt sent (openclaw/wacli PR 443) | test `empty key share` |
| notifications and receipts were ignored | `server_sync` re-syncs, `encrypt` refills prekeys, retry receipts logged loudly | test `peer message helpers` |
| 20 s online is short for a sleeping phone | `detz --whatsapp-sync` stays online 300 s (`kWaSyncOnlineWait`) | wizard source |
| no fallback when the phone never answers | `detz --whatsapp-import FILE` imports a .vcf into the contact search | test `vcf import` |
What to run now: link fresh if needed, then `detz --whatsapp-sync` with
WhatsApp open on the phone (no battery restriction). If no `<message>` arrives
within the 300 s, the phone is gating on its side; import the .vcf and retry
the sync another day. Research: `research/whatsapp-automation/`