@agentcomms/whatsapp 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Criss Moldovan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,258 @@
1
+ # @agentcomms/whatsapp
2
+
3
+ WhatsApp for coding agents, **read-only**. An agent can list, read and search the chats WhatsApp for Mac already
4
+ keeps on your Mac, and draft a reply — which comes back as a link that opens WhatsApp with the text filled in.
5
+ **You press send.** The package has no network client, no WhatsApp session and no way to send anything.
6
+
7
+ ```sh
8
+ npm install -g @agentcomms/whatsapp # or run it with npx -y @agentcomms/whatsapp <command>
9
+ ```
10
+
11
+ It needs **macOS with WhatsApp for Mac** installed and signed in, and **Node 22.16 or newer** (it reads with Node's
12
+ own SQLite, which is complete from 22.16; an older Node is refused with what to install).
13
+
14
+ ## What it does, and what it never does, in plain words
15
+
16
+ - **It reads only local files.** Nothing it does to read, search, draft or keep its lists connects to WhatsApp, or
17
+ to anything: no socket, no HTTP, no DNS. Its manifest declares no host (`"hosts": []`). A test builds the published
18
+ bundle and checks that no network module is anywhere in it; another runs those commands and every tool with the
19
+ network cut off, and `mcp install` only with `--print`. Installing is the exception, and it reaches npm, never
20
+ WhatsApp: `npm install`, and `mcp install`, which by default installs this package from npm into agentcomms' own
21
+ runtime (with `--launcher npx`, the client fetches it from npm each time it starts the server).
22
+ - **It never sends, marks as read, reacts, or shows you as online or typing.** There is no code that could. A draft
23
+ is a `whatsapp://send` or `https://wa.me/` link; WhatsApp fills in the message and waits for you.
24
+ - **It never writes to WhatsApp's files.** It copies the message store and its write-ahead log, reads the copy, and
25
+ deletes it. A test checks WhatsApp's folder is byte-for-byte and mtime-for-mtime unchanged after a sync.
26
+ - **It reads one file.** Only `ChatStorage.sqlite` and its log are opened, by exact name. The same folder holds the
27
+ encryption keys that make the Mac a linked device (`Axolotl.sqlite`), contacts and media; none is ever read.
28
+ - **Media is described, never opened**: type, size and file name.
29
+
30
+ **WhatsApp's terms.** WhatsApp forbids unofficial clients and automation, and bans numbers it catches — including
31
+ low-volume, reply-only use. The usual way to build this, a library that logs in as a linked device, is exactly that
32
+ kind of client (and a malicious copy of one, `lotusbail`, stole the sessions of 56,000 installs in December 2025). So
33
+ this does not do it: it reads a file the official app has already written to your Mac, and nothing it does reaches
34
+ WhatsApp's servers. That is a design choice, not legal advice. **Do not pair it with anything that sends** — another
35
+ WhatsApp MCP server, a script that types into WhatsApp — or the protection is gone; `mcp install` warns about any
36
+ other WhatsApp server it finds registered.
37
+
38
+ **What you give up, stated plainly.**
39
+
40
+ - **Your messages are copied into a local index**, in plain text, so an agent can search them. It is owner-only
41
+ (0600 in a 0700 folder, put back if anything loosens it), holds only the chats your lists let agents see, and
42
+ `remove` deletes it. It is **not encrypted**, and it does not have the protection WhatsApp's own folder has:
43
+ macOS asks before an app reads WhatsApp's folder, and it does not ask before one reads this index. Anything that
44
+ runs as you can read it. (Why not encrypted: Node's SQLite cannot open an encrypted database or load one from
45
+ memory, so the index would have to be decrypted to disk for every read — a plaintext copy anyway — and the key
46
+ would sit in a store every program running as you can read.)
47
+ - **macOS will ask for permission to read WhatsApp's data**, for the app this runs in — below. If you grant **Full
48
+ Disk Access** instead, you grant it to your whole terminal or MCP client, which is far broader than WhatsApp.
49
+ - **An agent reads other people's messages to you.** Hide chats with `deny`, or allow only some with `allow`.
50
+ Groups are visible unless you hide them, and a person you have denied is still visible in a group you have not:
51
+ to keep a group from agents, deny the group.
52
+ - **Messages are untrusted.** Anyone with your number can send text meant for the agent. Every message, name and
53
+ file name reaches it inside the untrusted-content envelope, with invisible and bidirectional characters removed
54
+ and counted; the residual risk is a model following an instruction anyway — which is why sending stays with you.
55
+
56
+ ## Getting started
57
+
58
+ In a terminal — these are yours to run, and each refuses an agent:
59
+
60
+ ```sh
61
+ agent-whatsapp add personal/whatsapp # names WhatsApp for Mac's store; macOS may ask: choose Allow
62
+ agent-whatsapp sync --account personal/whatsapp # copy, check, index, delete the copy
63
+ agent-whatsapp status
64
+ agent-whatsapp deny +15555550102 --account personal/whatsapp # optional: a chat agents never see
65
+ agent-whatsapp mcp install --client claude-code --account personal/whatsapp
66
+ ```
67
+
68
+ `mcp install` registers the server with your client, pinned to that account, as a change you approve: at a terminal
69
+ you type `yes` to what it shows; run by an agent it exits `10` with a preview and an approval id, and the same command
70
+ with `--approval <id>` registers it after your yes. Under the `confirm` change policy you approve with
71
+ `agent-whatsapp approve <id>` and a code instead. Restart the client afterwards. From a chat, the core server's
72
+ `comms_server_install` with `channel: "whatsapp"` and `account` is the same change.
73
+
74
+ For WhatsApp Business, add its store with
75
+ `--source ~/Library/Group\ Containers/group.net.whatsapp.WhatsAppSMB.shared/ChatStorage.sqlite`.
76
+
77
+ ## macOS permission
78
+
79
+ macOS protects other apps' data. On recent versions (reported for group containers from macOS 15.2), the first time a
80
+ process reads WhatsApp's folder macOS asks **"<app> would like to access data from other apps"** — where `<app>` is the
81
+ one responsible for the process: the terminal you run the command in, or the MCP client that started the server —
82
+ never Node itself. Until someone answers, the read waits. Older versions may not ask at all, and either allow the read or
83
+ refuse it.
84
+
85
+ - **Allow** fixes it for that app's session. A background MCP server cannot click it, and the answer does not always
86
+ persist for background processes — so run `add` and the first `sync` in a terminal.
87
+ - **Full Disk Access** makes it stick: System Settings → Privacy & Security → Full Disk Access, add the terminal (or the
88
+ MCP client), then quit and reopen it. It is much broader than WhatsApp; revoke it when you no longer need it.
89
+
90
+ The package says which: a refusal comes back as exit `77` / `AUTH_REQUIRED` with the steps and, when the environment
91
+ says which app it is, its name; a dialog nobody answers fails after 12 seconds as exit `75` with "look for the dialog"
92
+ instead of hanging; a missing store says WhatsApp for Mac may not be installed.
93
+
94
+ Only `add` and `sync` (and `status`, unless `--no-check`) touch WhatsApp's folder. `chats`, `read` and `search` open
95
+ only the index — so they cannot raise a dialog, and they keep working, on what was last synced, if WhatsApp is closed,
96
+ updating or gone.
97
+
98
+ ## Commands and tools
99
+
100
+ | Command | MCP tool | What it does |
101
+ |---|---|---|
102
+ | `add <org/whatsapp> [--source]` | — | a person names the store; the first read, when macOS asks |
103
+ | `remove <org/whatsapp>` | — | forget it, its chat lists and its index; WhatsApp's own store is not touched |
104
+ | `allow <chat> --account` | — | let agents see this chat; once any is allowed, only allowed chats are visible |
105
+ | `deny <chat> --account` | — | hide this chat from agents entirely |
106
+ | `clear [chat] --account` | — | take a chat off both lists, or with no chat empty them |
107
+ | `status [--account] [--no-check]` | `whatsapp_status` | what is set up, whether it can be read, what the index holds |
108
+ | `sync --account` | `whatsapp_sync` | copy, check, index, delete the copy |
109
+ | `chats --account [--kind] [--limit]` | `whatsapp_chats` | chats, newest first; status updates only with `--kind status` |
110
+ | `read <chat> --account [--before] [--limit]` | `whatsapp_read` | one chat, newest first |
111
+ | `search <words> --account [--chat] [--sender] [--kind] [--limit]` | `whatsapp_search` | full-text: text, captions, file names, sender and chat names |
112
+ | `draft <to> <text> [--account] [--open]` | `whatsapp_draft` | a `whatsapp://send` and a `https://wa.me/` link; the person sends |
113
+ | `approve <id>` | — | approve a change — registering or pruning this server — at a terminal |
114
+ | `mcp [--account]` | — | the MCP server on stdio, pinned to one account when `--account` is given |
115
+ | `mcp install`, `mcp prune` | `comms_server_install`, `comms_server_prune` (core) | register the server with a client; remove old runtimes |
116
+
117
+ Each command and its tool run the same operation, and `capabilities.json` holds them to it. The commands with no
118
+ tool are the person's on purpose: which file on the Mac an agent reads, and which chats in it, are not an agent's to
119
+ decide, in either direction — `add`, `remove`, `allow`, `deny` and `clear` also refuse an agent at the command line.
120
+ `draft --open` is refused to an agent too: a filled-in message box landing on the screen of someone typing elsewhere
121
+ is one Enter away from sent.
122
+
123
+ `draft` takes a phone number or any id `chats` and `read` print. A group has no number, so its draft comes back as text
124
+ to paste, with the reason; so does a chat with someone who hides their number (`@lid`), a broadcast list and a channel.
125
+ A status update is not a chat anyone writes to, and is refused.
126
+
127
+ A draft cannot be used to find out which chats you hid. While your lists hide anything, a draft goes only to a chat
128
+ an agent could read — in the index, and visible — and a hidden one is refused with the same `NOT_FOUND` as a chat that
129
+ is not there; while they hide nothing, any number is drafted to. With no account named, every account's lists apply.
130
+ And every account's **deny** list applies to every draft, named account or not: a draft is a link to a number, not to
131
+ an account, so a number you denied on one is not drafted to by naming another. A pinned server consults its own
132
+ account's lists only.
133
+
134
+ **A pinned server** (`agent-whatsapp mcp --account personal/whatsapp`, which `mcp install --account` writes) acts on
135
+ that account whether or not a call names it, refuses any other, and names no other account — not in its greeting,
136
+ not in `whatsapp_status`. The pin follows the account through a rename.
137
+
138
+ **Kinds of chat.** From its id: `direct` (`…@s.whatsapp.net`), `hidden-number` (`…@lid`), `group` (`…@g.us`),
139
+ `channel` (`…@newsletter`), `broadcast` (`…@broadcast`) and `status` (WhatsApp's status feed, and one session per
140
+ contact's posts). Status updates are indexed, but `chats` and `search` leave them out unless asked for with
141
+ `--kind status`; one named by its id is read like any other chat. An id none of these match is `unknown`.
142
+
143
+ ## Accounts, and the chats agents may see
144
+
145
+ An account is a record in agentcomms' own `config.json`, beside Gmail's mailboxes and Slack's workspaces, named
146
+ `organisation/whatsapp`. It is read-only by construction (`mode: "read"`, the only mode this channel has), names the
147
+ store by a stable id (`group.net.whatsapp.WhatsApp.shared` for WhatsApp for Mac), and holds no secret: its secret
148
+ reference is `whatsapp:none:<id>`, which names nothing, and `agentcomms secrets migrate` moves nothing for it. Because
149
+ it is core's record, a renamed account's old name is answered with its new one, and `comms_server_install` checks a
150
+ pin against it.
151
+
152
+ Each account can carry two lists, by chat id or phone number. A number on a list needs its country code, written with
153
+ `+` or `00` (`+1 555 555 0102`, `00 1 555 555 0102`): without one, `(555) 555-0102` could be a number in any country,
154
+ and an entry that matched no chat would hide nothing while saying it did, so it is refused. Each entry is looked up in
155
+ the index and the chat it names is shown; one that names no chat there is kept — you may hide a number before it
156
+ writes — and said to match none. (Anywhere a number is taken, `00` means `+`, and a number starting with a single `0`
157
+ is refused as national.)
158
+
159
+ - **deny** — chats an agent must never see. Not listed, searched, read, drafted to or counted; asking for one by id
160
+ gets the same `NOT_FOUND`, word for word, as a chat that does not exist.
161
+ - **allow** — once anything is on it, the only chats an agent sees. Denied wins over allowed.
162
+
163
+ A phone number names a person rather than one chat: their one-to-one chat, their own status posts and, in the status
164
+ feed, the posts they wrote. A status post is checked against its author, so it needs one: a post WhatsApp recorded no
165
+ author for could be anyone's, including someone you denied, so while either list has anything on it, such a post is
166
+ hidden, and `status` says how many (`unattributedStatus`). Your own posts are always shown. An author WhatsApp recorded
167
+ under a hidden-number id (`…@lid`) is matched by that id, not by their number: deny the id as well.
168
+
169
+ A group is a chat, allowed or denied whole: denying someone does not take what they wrote out of a group an agent may
170
+ see, whether or not WhatsApp recorded them as the author. That is a decision, not an oversight. Hiding one person's
171
+ lines would leave the others' replies and quotes describing them; a member can appear under a hidden-number id their
172
+ number does not match; and a group on the allow list would lose everyone who is not on it too. To keep a group from
173
+ agents, deny the group.
174
+
175
+ The lists apply at once to every read on both surfaces, and `sync` applies them too, leaving what they hide out of
176
+ the index — as they are when it finishes: a list changed while a sync runs is read again just before the new index
177
+ replaces the old, and the index is built again if it changed. `status` reports how many chats each list holds, never which.
178
+
179
+ The lists are kept in `whatsapp-chats.json` beside `config.json`, by account id — not in `config.json`. Everything in
180
+ that file is kept through every write, but only a few settings are *judged* when a write loosens something, and a
181
+ deny list there could be emptied by any program that writes it with nobody asked. In a file of its own, written only
182
+ by the person's three commands, and read so that a file that cannot be read shows no chat rather than every chat,
183
+ they stay the person's.
184
+
185
+ ## Moving from the spike
186
+
187
+ If you ran the unpublished spike, its accounts are in `whatsapp-spike.json`. The first command or server start of this
188
+ release moves them into `config.json`, once: the same account id, so the index the spike built is read as it is with no
189
+ new sync (each chat's kind taken from its id, so the spike's `unknown` status sessions read as status updates); the
190
+ chat lists moved before the account appears; a name that is already taken left unmoved and said so, with where its
191
+ index — a plaintext copy of its messages that nothing reads now — was left for you to delete; the
192
+ old file kept as `whatsapp-spike.json.migrated-<time>`; one line in the audit log (`agentcomms audit tail`) and one on
193
+ stderr. It reads neither WhatsApp's store nor the spike's index to do it. A configuration still on the old flat names
194
+ waits until `agentcomms names migrate` has run.
195
+
196
+ ## How the store is read
197
+
198
+ WhatsApp for Mac keeps the store at `~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ChatStorage.sqlite`,
199
+ unencrypted on disk, and holds it open in SQLite's WAL mode while it runs: recent messages sit in
200
+ `ChatStorage.sqlite-wal` until the app folds them into the main file.
201
+
202
+ 1. **Copy, don't open.** The store and its log are copied, byte for byte, into a private folder under agentcomms'
203
+ state directory, owner-only. Nothing opens them with SQLite, takes a lock on them, or touches the app's `-shm`
204
+ file. SQLite's `mode=ro` was rejected because a read-only connection still takes locks and writes read-marks into
205
+ the app's `-shm` file; `immutable=1` because it ignores the log, missing the newest messages, and can read torn
206
+ pages while the app writes.
207
+ 2. **Open once, never through a link.** Each file is opened once, read-only, refusing a symbolic link, and every byte
208
+ is copied from that open file — its name is never opened again, so a name swapped for a link to the key store
209
+ between a check and the copy changes nothing. A file with a second name (a hard link, which could be the key
210
+ store's) or that is not a regular file is refused. The cost: Node's copy, which clones on APFS, takes a name and
211
+ would open it again, so the bytes are copied instead — the store's size on disk until the sync deletes it.
212
+ 3. **Consistent, or not at all.** Each open file is fingerprinted (device, inode, links, size, nanosecond mtime)
213
+ before and after the copy, and each name is looked at again for a file that came, went or was replaced; if
214
+ WhatsApp wrote in between, the copy is discarded and taken again, up to five times, then refused. SQLite then
215
+ checks the copy (`quick_check`).
216
+ 4. **Check the layout before reading.** A missing required table or column refuses the whole sync by name (exit `65`),
217
+ and the previous index is kept as it was. A missing optional part turns off one named feature and is reported.
218
+ 5. **Index, then delete the copy.** The index is rebuilt in a new file and renamed into place, owner-only. The copy is
219
+ deleted whatever happens; one a crash left behind is removed by the next sync. `remove` holds the same lock as
220
+ `sync`: it waits for a sync that is running (up to a minute), then deletes the index that sync wrote with the rest,
221
+ and a sync that was waiting behind it finds the account gone and writes nothing.
222
+
223
+ WhatsApp can change this layout without notice. The reader refuses rather than guess when a required part is gone;
224
+ the risk it cannot catch is a column that keeps its name and changes its meaning, so check a handful of chats by eye
225
+ after a WhatsApp update if anything looks wrong.
226
+
227
+ ## Where the schema comes from
228
+
229
+ Nobody opened a real store to write this — not the owner's, not even for its layout. Every table and column the
230
+ reader uses is one that public, open-source readers of `ChatStorage.sqlite` query by name (commits pinned in
231
+ `src/source/schema.ts`):
232
+
233
+ | Source | What it establishes |
234
+ |---|---|
235
+ | [kenn-io/msgvault](https://github.com/kenn-io/msgvault) `internal/whatsapp/apple.go` | the **macOS** app: `ZWACHATSESSION` (`ZCONTACTJID`, `ZPARTNERNAME`, `ZSESSIONTYPE`, `ZLASTMESSAGEDATE`), `ZWAGROUPMEMBER` (`ZMEMBERJID`, `ZCONTACTNAME`, `ZFIRSTNAME`), `@lid` chats, Core Data seconds since 2001-01-01 |
236
+ | [raycast/extensions](https://github.com/raycast/extensions) `extensions/whatsapp/src/services/readLocalDatabase.ts` | the **macOS** path; `ZSESSIONTYPE = 0` for one-to-one chats |
237
+ | [KnugiHK/WhatsApp-Chat-Exporter](https://github.com/KnugiHK/WhatsApp-Chat-Exporter) `ios_handler.py` | `ZWAMESSAGE` (`ZISFROMME`, `ZMESSAGEDATE`, `ZTEXT`, `ZMESSAGETYPE`, `ZSTANZAID`, `ZGROUPMEMBER`), `ZWAMEDIAITEM`, `ZWAPROFILEPUSHNAME`; epoch 978307200 |
238
+ | [abrignoni/iLEAPP](https://github.com/abrignoni/iLEAPP) `scripts/artifacts/whatsApp.py` | `ZFROMJID`, `ZTOJID`, `ZMEDIAITEM`, `ZGROUPEVENTTYPE`; type 5 is a location |
239
+ | [sepinf-inc/IPED](https://github.com/sepinf-inc/IPED) `ExtractorIOS.java` | `ZWAMEDIAITEM.ZFILESIZE`; `ZTITLE` is missing from older stores |
240
+ | [ForensicWace](https://github.com/Alessiop01/ForensicWace-ServerEdition) `globalConstants.py`, [wa-explorer](https://github.com/ludufre/wa-explorer) `docs/IOS_STORAGE.md` | the `ZMESSAGETYPE` values; `ZVCARDSTRING` holding a media item's MIME type |
241
+
242
+ The macOS app is the iOS app built for the Mac, which is why the iOS readers apply; the first two read the Mac file
243
+ itself. Facts were taken from these projects, not code. Where the sources say nothing — most `ZMESSAGETYPE` numbers
244
+ — the reader reports `unknown:<n>` rather than guess.
245
+
246
+ **What counts as media.** WhatsApp keeps a `ZWAMEDIAITEM` row for much more than media: a reply's quoted message lives
247
+ in it (KnugiHK reads replies from its `ZMETADATA`; iLEAPP counts 1,350 such rows), and the first run against a real
248
+ store found one on most text messages and on every call, with no type, no size and no file. So a row alone is not
249
+ media. A message is media when its type is one ForensicWace and wa-explorer name as media — a photo not yet downloaded
250
+ has no file and is still a photo — or when its row names a stored file (`ZMEDIALOCALPATH`, the test KnugiHK and
251
+ iLEAPP use). Anything else — text, a call, a location — shows no media line and is not counted as media. A call is
252
+ shown as a call, without a duration: both KnugiHK and iLEAPP read call durations from `CallHistory.sqlite`, a separate
253
+ file this reader never opens.
254
+
255
+
256
+ ## Licence
257
+
258
+ [MIT](LICENSE). The bundled dependencies' notices are in `THIRD_PARTY_LICENSES`.