Skip to content

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 --export over 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 whether python3.11 may access data from other apps — allow it. Full Disk Access, already granted for chat.db, covers the Group Container.
  • Voice notes: the same JARVIS_WHISPER_MODEL setting as iMessage; the .opus files are decoded by afconvert and 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 in groups. Broadcast lists, status and channels are never read.
  • since (YYYY-MM-DD, default 2026-01-01) is the capture-since date: older messages are ignored, even when WhatsApp's history sync inserts them later.
  • purge holds conversations switched off in the picker whose captured items are still being deleted (see below).
  • owner_addresses is 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

  1. Open Settings › Connectors › WhatsApp chats and Save your number (or flip any switch): that creates the connector row.
  2. 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_items row. 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).
  • 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> omitted marker — 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.