Read-only tooling for extracting messages from a WhatsApp local cache so they can be summarized without GUI automation.
The verified native macOS app stores chat data here:
~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ChatStorage.sqlite
Useful tables:
ZWAMESSAGE: message rows, includingZTEXT,ZMESSAGEDATE,ZISFROMME, and chat foreign keyZCHATSESSION.ZWACHATSESSION: chat metadata, includingZCONTACTJID,ZPARTNERNAME,ZUNREADCOUNT, andZLASTMESSAGEDATE.
Timestamps are Core Data absolute timestamps, converted by adding 978307200 seconds to get Unix time.
Database discovery is platform-aware:
- Set
WHATSAPP_CACHE_DB=/path/to/ChatStorage.sqliteto use any compatible cache explicitly. - macOS native WhatsApp is auto-discovered from
~/Library/Group Containers/.... - Windows and Linux include best-effort candidate paths, but the SQLite schema has only been verified against the native macOS app. If another platform stores the same Core Data-style cache elsewhere, pass
--db.
This project is packaged as a uv Python app. Install/sync the base environment:
uv syncAll primary commands go through one CLI:
uv run wa-agent --helpList recent chats without exporting message text:
uv run wa-agent chats --limit 20Export the last 24 hours as Markdown:
uv run wa-agent export --hours 24Export a specific range as JSON lines:
uv run wa-agent export --since 2026-06-17 --format jsonlFilter one chat by name or JID:
uv run wa-agent export --chat "family" --hours 72The app opens the database with SQLite mode=ro and sets PRAGMA query_only = TRUE; it does not write to WhatsApp's cache.
Legacy scripts under scripts/ remain as compatibility wrappers, but new usage should prefer uv run wa-agent ....
For one-to-one chats, the cache gives us the WhatsApp JID, for example 15550000001@s.whatsapp.net. The helper below resolves a chat name to that JID and opens WhatsApp with a prefilled draft via whatsapp://send:
uv run wa-agent draft "Alex Rivera" "Yes, I got in."This does not auto-send. WhatsApp should open the chat with the message in the composer so you can review and press Send.
Try to open the draft without activating WhatsApp on platforms that support it:
uv run wa-agent draft "Alex Rivera" "I'm in" --backgroundTo inspect the URL without opening WhatsApp:
uv run wa-agent draft "Alex Rivera" "Yes, I got in." --print-url --no-openThe Flask-backed sidecar approach keeps automation outside your active desktop session:
- Reads messages from the WhatsApp cache in SQLite read-only mode.
- Resolves chat names to phone numbers from the cache.
- Sends, when explicitly enabled, through an isolated WhatsApp Web Chromium profile instead of global keyboard or mouse events.
- Runs on
127.0.0.1by default.
Install the optional sender dependency:
uv sync --extra web
uv run playwright install chromiumPair the isolated browser profile once:
uv run wa-agent web-login --browser-channel chromeThis saves the WhatsApp Web session under ~/.whatsapp-agent/whatsapp-web-profile by default. After the QR scan succeeds once, later sends reuse that profile and should not need another QR login unless WhatsApp invalidates the linked device.
Start the read-only sidecar:
uv run wa-agent sidecarCheck health:
curl http://127.0.0.1:8765/healthThe same endpoints are also exposed through whatsapp_agent.backend.app:create_app() for local GUI integration and tests.
Resolve Alex without sending:
curl 'http://127.0.0.1:8765/resolve?target=Alex%20Rivera&message=I%27m%20in'Fetch recent messages for summarization:
curl 'http://127.0.0.1:8765/messages?chat=Alex%20Rivera&hours=168&limit=20'Dry-run a send request:
curl -X POST http://127.0.0.1:8765/send \
-H 'Content-Type: application/json' \
-d '{"target":"Alex Rivera","message":"I'"'"'m in"}'To allow actual sends, start the sidecar explicitly with sending enabled:
uv run wa-agent sidecar --allow-send --headless-send --browser-channel chromeFor stricter confirmation, ask the sidecar to wait until the outgoing message appears in the native WhatsApp cache:
uv run wa-agent sidecar \
--allow-send \
--headless-send \
--browser-channel chrome \
--verify-send-cache \
--trace-sendThen include "send": true in the request:
curl -X POST http://127.0.0.1:8765/send \
-H 'Content-Type: application/json' \
-d '{"target":"Alex Rivera","message":"I'"'"'m in","send":true}'The sidecar intentionally requires both --allow-send at process startup and "send": true per request. This prevents accidental sends during summarization or dry-run testing.
You can also send directly through the isolated WhatsApp Web profile:
uv run wa-agent web-send "Alex Rivera" "I'"'"'m in" --send --headless --browser-channel chromeTo debug a send without trusting the button click alone:
uv run wa-agent web-send "Alex Rivera" "I'"'"'m in" \
--send \
--headless \
--browser-channel chrome \
--verify-cache \
--traceweb-send first verifies that the requested message text is actually loaded into the WhatsApp Web composer. After clicking Send, it waits for the composer to clear. With --verify-cache, it also waits for the native macOS cache to show the outgoing message. If the cache does not update, the command exits non-zero instead of reporting a confirmed send.
If chrome is not available, install Google Chrome or pass an explicit browser path:
uv run wa-agent web-login --browser-executable "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"The same browser setting must be used for web-login, web-send, and sidecar so they share a compatible profile. You can set it once via environment variable:
export WA_AGENT_BROWSER_CHANNEL=chromeIf WhatsApp Web still shows an unsupported-browser page in headless mode, run the sidecar without --headless-send. That still avoids global keyboard/mouse automation, but a browser window may appear while sending.
For a true server-side backend with token-based auth and no browser profile, use the official WhatsApp Business Cloud API. That sends from a business phone number, not from the personal desktop WhatsApp session.
The MVP desktop shell lives under app/ and uses Tauri v2 with Nuxt/Vue:
cd app
npm install
npm run generate
npm run tauri:devThe shell currently checks the local Flask sidecar at http://127.0.0.1:8765/health and exposes a Rust get_app_status command. The Python sidecar is still started separately:
uv run wa-agent sidecarInside the desktop webview, the shell can start a managed sidecar with explicit-send enabled, headless Chrome, and cache verification. It can stop only that managed process. If a sidecar is already listening on 8765, the shell reports it as external and leaves it under external control.
If local port 3000 is occupied, run the shell on the documented alternate port:
cd app
npm run tauri:dev:3001For Rust-only verification without opening the desktop app:
cargo check --manifest-path app/src-tauri/Cargo.tomlThe sidecar only returns CORS headers for known local app origins such as http://localhost:3000, http://127.0.0.1:3000, and Tauri localhost origins. If the dev server must use another local port, add it explicitly:
uv run wa-agent sidecar --allowed-origin http://127.0.0.1:3001The local approval queue uses a separate SQLite database, not WhatsApp's cache:
~/.whatsapp-agent/agent.sqlite
Override it with WA_AGENT_DB=/path/to/agent.sqlite or --agent-db.
If .agent/agent.sqlite exists inside this repo, CLI and sidecar commands started from the repo use that shared DB before falling back to ~/.whatsapp-agent/agent.sqlite. This is the local proxy channel for Claude Work/Cowork sandboxes that cannot reach 127.0.0.1:8765 because of network allowlists. .agent/ is local state and is gitignored.
Workflow ownership:
- Claude/Codex/Claude Work approval plane: permits the agent to run scheduled reasoning jobs and create local WhatsApp task records.
wa-agentCLI/API: narrow write surface for queued WhatsApp intents.- SQLite agent DB: durable proxy channel between agents and the Mac app.
- Desktop GUI: human WhatsApp approval inbox, automation visibility, rejection, and immediate send controls.
- Sidecar: local bridge to WhatsApp cache and guarded WhatsApp Web send path.
The GUI is not the agent integration API. It is the human control plane for queued intents. Agents should use the CLI/API/shared DB to create pending_approval tasks; they should not send directly.
Claude/Codex can own scheduling. For dynamic workflows such as morning triage, let the Claude/Codex scheduler wake up, inspect context, decide what should happen, and create one-off approval tasks. The app-native recurring schedule feature is only a simple fallback for static repeated drafts.
Example scheduled-agent workflow:
uv run wa-agent triage unanswered --hours 24 --format json
uv run wa-agent tasks create "Alex Rivera" "I saw your message and will respond as soon as possible." --created-by claude-morning-triageLocal sidecar equivalent for context gathering:
curl 'http://127.0.0.1:8765/triage/unanswered?hours=24&limit=20&context_limit=3'Claude/Codex decides which tasks to create. The app stores and displays those tasks. Unscheduled sends remain a human-controlled action; approved scheduled tasks send automatically when due through the guarded local sidecar.
Create a pending approval task without sending anything:
uv run wa-agent tasks create "Alex Rivera" "Draft text" --created-by claudeList pending tasks:
uv run wa-agent tasks list --status pending_approval --format jsonApprove or reject a task without sending:
uv run wa-agent tasks approve 1 --detail "reviewed in desktop shell"
uv run wa-agent tasks reject 1 --reason "not needed"The Flask sidecar exposes the same queue through GET /tasks, POST /tasks, POST /tasks/<id>/approve, and POST /tasks/<id>/reject. Approval does not send immediately. If an approved task has scheduled_for in the past, the sidecar's due-send loop can send it automatically; unscheduled approved tasks still require Send Now or POST /tasks/<id>/send.
Optional fallback: create a static recurring schedule that materializes the same draft into pending approval tasks:
uv run wa-agent tasks recurring create "Alex Rivera" "Draft text" --every 1dSupported intervals are minutes, hours, days, and weeks, for example 15m, 1h, 1d, or 1w. Run due schedules manually:
uv run wa-agent tasks recurring runEach run creates at most one pending approval task per due recurring schedule, then advances next_run_at into the future. It never approves tasks. After you approve a scheduled task, the sidecar can send it automatically when scheduled_for <= now. Manage schedules with:
uv run wa-agent tasks recurring list --format json
uv run wa-agent tasks recurring pause 1
uv run wa-agent tasks recurring resume 1The sidecar exposes recurrence endpoints through GET /recurring-tasks, POST /recurring-tasks, POST /recurring-tasks/run, POST /recurring-tasks/<id>/pause, and POST /recurring-tasks/<id>/resume.
Schedule evaluation is explicit:
- A recurring schedule stores
every_secondsandnext_run_at. - If
--next-run-atis omitted, creation setsnext_run_atto now, so the first scheduler run creates one task immediately. uv run wa-agent tasks recurring runselects active schedules wherenext_run_at <= now.- For each due schedule, it creates one
pending_approvaltask withscheduled_for = next_run_at. - It advances
next_run_atby the interval until it is in the future, so missed intervals do not create a large backlog. - Recurring materialization is manual unless the Mac runs the scheduler command, the GUI
Run due nowaction, or a future launchd/Tauri timer. - Approved scheduled task sending is automatic when the sidecar runs with
--allow-sendand--send-due-intervalgreater than zero.
Send an approved task only through the explicit send endpoint:
curl -X POST http://127.0.0.1:8765/tasks/1/send \
-H 'Content-Type: application/json' \
-d '{"send":true}'That endpoint refuses to run unless all three conditions are true: the task status is approved, the JSON request includes "send": true, and the sidecar process was started with --allow-send. On success it marks the task sent; on sender/cache-verification failure it marks the task failed with last_error.
Send due approved scheduled tasks through the batch endpoint:
curl -X POST http://127.0.0.1:8765/tasks/send-due \
-H 'Content-Type: application/json' \
-d '{"limit":20}'The sidecar starts this loop automatically every 15 seconds when --allow-send is enabled. Use --send-due-interval 0 to disable the loop.
The running app does not expose a portable local send API:
- No listening TCP or UDP socket was found for the WhatsApp process.
- The app has URL schemes such as
whatsapp://, but these are deep links for opening WhatsApp or pre-filling UI flows, not a read/write message API. - The app has an
Intents.appexextension supportingINSendMessageIntentandINSearchForMessagesIntent, which is system-mediated Siri/Shortcuts functionality rather than a direct API for this process. MessagingInfraDB_v2/MessagingInfraDatabase.sqlitecontains queue/status tables likechat_queue,e2ee_queue, andmessage_status, but payloads are internal blobs/protobufs and current queues were empty. Inserting rows there is not a supported send path and risks corrupting local state.
For non-GUI sending, the supported route is WhatsApp Business Cloud API. That does not control the currently running personal desktop app session and generally requires a WhatsApp Business account/number. Unofficial WhatsApp Web clients can automate sends by pairing as another device, but they are unsupported and may violate WhatsApp terms or risk account restrictions.