WhatsApp¶
Two routes bring WhatsApp into cap_items:
- the live connector (below): WhatsApp Desktop on your Mac is a linked device and keeps every chat in a local store; the host poll reads it every 10 minutes, read-only, and a Settings picker chooses the conversations;
- the manual export import (bottom of this page): the one-shot
capture_agent.backfill whatsapp --exportover a chat you exported by hand.
The two never dedupe against each other: live rows are keyed on WhatsApp's
message id (wa:<stanza id>), export rows on a hash of the export line. Pick
one route per period.
Live connector (host)¶
Like iMessage, this connector runs on the host Mac, not in the container
scheduler — see iMessage / SMS for the host_only partition
and the LaunchAgent. It reads
~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ChatStorage.sqlite
(plus ContactsV2.sqlite and LID.sqlite for names and phone numbers behind
WhatsApp's privacy ids) with a read-only connection. No credentials, no
network calls, nothing written to the store.
Prereqs¶
- WhatsApp Desktop (the native macOS app from the App Store) installed and linked to your phone. The Mac receives messages while it stays linked; WhatsApp unlinks a device whose phone has been offline for about 14 days.
- The iMessage LaunchAgent installed and running
python3.11(modules/capture/agent/host/HOST_POLL.md). The same run polls WhatsApp; there is no second LaunchAgent. macOS may ask once whetherpython3.11may access data from other apps — allow it. Full Disk Access, already granted forchat.db, covers the Group Container. - Voice notes: the same
JARVIS_WHISPER_MODELsetting as iMessage; the.opusfiles are decoded byafconvertand transcribed locally.
The cap_connectors row¶
The picker creates and edits it; this is what it writes:
{
"kind": "whatsapp",
"enabled": true,
"secret_ref": "",
"config": {
"db_dir": "~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared",
"since": "2026-01-01",
"groups": ["<group jid>"],
"blocked": ["<direct chat jid>"],
"purge": []
},
"owner_addresses": ["<your number, digits>"]
}
- One-to-one chats are on unless listed in
blocked; groups are off unless listed ingroups. Broadcast lists, status and channels are never read. since(YYYY-MM-DD, default2026-01-01) is the capture-since date: older messages are ignored, even when WhatsApp's history sync inserts them later.purgeholds conversations switched off in the picker whose captured items are still being deleted (see below).owner_addressesis informational — your own messages are recognised by the store's from-me flag — and is kept for the export import.
Cursor¶
The connector row's cursor is {"pk", "stanza_id", "drained", "catchup_after"}.
pk/stanza_id anchor the forward pass (rows past this point in the store)
— the health card's "PK N · M groups" reads them. drained lists the
enabled chats that have been read up to pk since the since date; a chat
leaves drained the moment it is switched off, so re-enabling it starts a
fresh catch-up from the since date rather than picking up mid-history.
catchup_after remembers where a chat's catch-up stopped when a poll's
CAPTURE_POLL_LIMIT cut it short, so the next poll resumes there instead of
re-reading from the beginning. "Import all history" empties drained (and
resets since), so every enabled chat catches up again — dedupe by
external_id keeps the re-read free of duplicates.
Settings › Connectors › WhatsApp chats¶
The picker lists every conversation the Mac's WhatsApp knows (the host poll
mirrors the list into cap_whatsapp_chats every 10 minutes), with a search
field and a switch per conversation:
- Switching a conversation on reads it from the since date at the next poll (a group ticked today still gets its January messages).
- Switching a conversation off asks for confirmation, then blocks it and deletes everything already captured from it at the next poll — the row shows "Removing…" until then. People created from its participants and notes the brain routine already wrote are not touched.
- Capture since moves the date for conversations not yet read.
- Import all history re-reads every enabled conversation from the beginning of the store. Messages already captured are not duplicated. On a large store this takes a few hours of 10-minute polls; run the poll by hand in a loop to finish sooner (below). If a Mac poll was running at the moment you pressed it, the date shows 2009-01-01 but nothing is re-read: press Import all history once more.
First run¶
- Open Settings › Connectors › WhatsApp chats and Save your number (or flip any switch): that creates the connector row.
- Run one poll now. The poll needs the PocketBase connection env
(
POCKETBASE_URL,DEV_USER_EMAIL,DEV_USER_PASSWORD) that the installed LaunchAgent carries and your shell does not, so the simplest shakedown is to kick the LaunchAgent and read its log:
launchctl kickstart -k gui/$(id -u)/com.jarvis.capture.imessage-poll
tail -n 20 /tmp/jarvis-capture-imessage-poll.log /tmp/jarvis-capture-imessage-poll.err
To run it by hand instead, export those variables first (the values are in
~/Library/LaunchAgents/com.jarvis.capture.imessage-poll.plist), then from
the repo the LaunchAgent uses:
PYTHONPATH=agent-runtime/src:modules/capture/agent python3.11 -m capture_agent.poll --host
Either way the output carries capture poll: {...} and whatsapp maintain:
{...} (counts only). Within a minute the picker lists your chats.
3. Tick the groups you want. The first drain reads 200 store rows per poll
(CAPTURE_POLL_LIMIT); repeat the command above to finish faster.
What is captured¶
| Store message | Captured as |
|---|---|
| Text, text with a link preview | body |
| Photo, video, document, sticker, gif | caption as body, one attachment ref (the local file path) |
| Voice note | attachment ref, transcript as body when Whisper is configured |
| Deleted message, group event, call, location, contact card, poll, view-once | not captured |
| Reaction, edit made after the poll read the message | not captured |
Senders resolve phone-first: a phone JID, or a privacy-id (@lid) contact
found in the Mac's WhatsApp address book, becomes a phone handle — the same
handle the iMessage connector produces for that number, so one person links
both channels. An unsaved @lid sender keeps their push name as a
whatsapp handle. Groups carry the group subject as the item title; the
thread id is the chat's JID.
Why not a bridge or the Cloud API¶
An unofficial linked-device client (whatsmeow, Baileys) would run without the Mac, but it breaks WhatsApp's terms and risks a ban on your personal number, adds a Go/Node service and a session store to protect. The Business Cloud API only receives messages sent to a business number and has no groups. The store poll needs neither; its only constraint is that the Mac must be awake for the poll to run — a gap is a delay, not a loss, as long as the Mac stays linked.
Backfill from an export (manual)¶
What it does¶
Parses a per-chat WhatsApp "Export Chat" archive — the _chat.txt file
(iOS or Android) — filtered by date, and normalizes each message into the
same cap_items shape as every other connector. No network, no credentials.
Prereqs¶
Export the chat from the WhatsApp app: open the chat → ⋮ / contact name →
More → Export Chat → choose "Without Media" or "Include Media" → save the
result. You'll get either a single _chat.txt file or a folder containing
_chat.txt plus attachments.
Run it¶
python -m capture_agent.backfill whatsapp --export <path to _chat.txt or its containing dir> --since 2026-01-01
--since filters to messages on or after that date (YYYY-MM-DD). If
--export points at a directory, _chat.txt inside it is preferred; if
there's no such file, every .txt file in the directory is parsed.
Verify¶
The command prints a summary when it finishes, e.g. capture whatsapp
backfill: {'ingested': N, 'skipped_dupe': 0, 'unrouted': N, 'entities_created':
N, 'errors': 0}. Check cap_items in the PocketBase admin for the new rows.
Notes on the export format¶
- Two different mechanisms handle export tokens — don't confuse them.
- Whole-message system notices are dropped entirely. Author-less notices —
the end-to-end-encryption banner, business-account notices, and
group subject/description/icon changes — are matched against a fixed
phrase list and the whole line is skipped, so they never become a
cap_itemsrow. The media-omitted and "edited" markers (image omitted,video omitted,<This message was edited>) genuinely have no localized variants in the export. The group-change phrases are matched in English (the export's observed locale) plus a best-effort set of French variants — so a group-change notice in a language other than English or French may not be recognized as a system notice. - Media/attachment tokens are stripped in place. On an actual message the exporter emits an attachment or omitted-media token; the connector removes just that token and keeps the rest of the line as the message body (see the next note).
- Whole-message system notices are dropped entirely. Author-less notices —
the end-to-end-encryption banner, business-account notices, and
group subject/description/icon changes — are matched against a fixed
phrase list and the whole line is skipped, so they never become a
- A caption on an attached/omitted media message stays in the body,
mid-message. The connector strips only the attachment token itself
(
<attached: name>on iOS,name (file attached)on Android) or the<kind> omittedmarker — any caption text on the same message is left in place around where the token was.
Errors¶
Per-message parsing failures are logged and skipped so one malformed line
never aborts the whole import — re-running the same command later is safe,
since cap_items dedupes by external_id and already-imported messages are
left alone.