---
name: whatsapp
description: WhatsApp messaging channel — Baileys QR pairing, DM policy enforcement, inbound routing, business hours gating, conversation browsing. Reply-only in DMs except agent-initiated sends to a registered admin phone (whatsapp-send-admin); observation-only in groups (the agent never responds in a group).
tools:
  - name: whatsapp-login-start
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
  - name: whatsapp-login-wait
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
  - name: whatsapp-status
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: whatsapp-disconnect
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
  - name: whatsapp-config
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: whatsapp-activity
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: whatsapp-conversations
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: whatsapp-messages
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: whatsapp-conversation-graph-state
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: whatsapp-group-info
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: whatsapp-send-admin
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
  - name: whatsapp-reply
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
metadata: {"platform":{"embed":["admin"]}}
mcp:
  command: node
  args:
    - ${PLATFORM_ROOT}/lib/mcp-spawn-tee/dist/index.js
    - ${PLATFORM_ROOT}/plugins/whatsapp/mcp/dist/index.js
  env:
    MCP_SPAWN_TEE_NAME: whatsapp
    LOG_DIR: ${LOG_DIR}
    PLATFORM_ROOT: ${PLATFORM_ROOT}
    ACCOUNT_ID: ${ACCOUNT_ID}
    SESSION_ID: ${SESSION_ID}
    PLATFORM_PORT: ${PLATFORM_PORT}
mcp-manifest: auto
---

# WhatsApp

Activate when the user asks about WhatsApp — connecting, linking, checking status, managing accounts, DM policies, group policies, or sending messages.

## What this unlocks

- QR-code-based WhatsApp pairing via Baileys (linked device) — `whatsapp-login-start` returns the QR as a scannable Unicode block printed on the PTY surface (the only operator surface); the operator scans it directly from the terminal output
- Multi-account support (optional, multiple linked numbers)
- Inbound message routing — sender-keyed `claude` PTY sessions via the loopback claude-session-manager (admin phone → admin agent, public → resolved public agent); a "typing…" presence is sent on receipt as an ack while the agent turn runs
- Group observation — group messages are stored, persisted, and browsable, but the agent never responds in a group (observation-only)
- DM policy enforcement (open, allowlist, disabled)
- Conversation browsing — list active conversations, read message history, inspect group metadata
- Inbound voice note transcription (OGG Opus → text via local whisper.cpp)
- Outbound voice note normalization (OGG Opus via ffmpeg)
- Auto-reconnection with exponential backoff
- Business hours gating — public messages gated by `OpeningHoursSpecification` from Neo4j, with optional after-hours auto-reply via the `afterHoursMessage` config field

## Conversation Browsing

Four tools for reading WhatsApp conversation context. The first two read from the in-memory ring buffer (recent inbound, ~500 per JID, ephemeral). `whatsapp-conversation-graph-state` reads the durable on-disk store — the read logic lives in the platform route so the agent never composes its own query. `whatsapp-group-info` queries the live WhatsApp servers.

| Tool | Returns |
|------|---------|
| `whatsapp-conversations` | JIDs with recent message activity, conversation type (group/DM), last message timestamp, message count |
| `whatsapp-messages` | In-memory ring-buffer messages for a specific JID — sender, body, timestamp, reply context. Supports optional `limit` for most recent N messages |
| `whatsapp-conversation-graph-state` | Persisted WhatsApp messages from the on-disk store (no longer Neo4j). Pass `jid` for one conversation (plus the resolved `sessionId` and a divergence header vs in-memory count). Omit `jid` for every sender across the account in one call, each resolved to its `:Person` name. Use when the question is "what got persisted" or "who has messaged us that never got a reply session". Defaults to the calling session's own account (`ACCOUNT_ID`); for a sub-account session this returns the house-store threads whose counterparty is bound to that sub-account(read-time projection — the store itself stays house-keyed) |
| `whatsapp-group-info` | Group metadata from WhatsApp servers — subject, description, participants with admin flags, creation date. Requires active connection |

## Group access — observation-only

The agent never responds in a group. `checkGroupAccess` ([`inbound/access-control.ts`](../../ui/app/lib/whatsapp/inbound/access-control.ts)) is a structural observe-only deny — it always returns `allowed:false reason:group-observe-only`, so every group inbound ends at the manager's `!accessResult.allowed` return before dispatch. There is no group policy, allowlist, per-group activation, or @mention setting; setting any of those keys is a `.strict()` schema rejection, not a silently-dead key. A belt invariant in the manager's `reply` closure refuses any `@g.us` recipient and logs `op=group-reply-blocked` — that line is the leak signature if a dispatch path ever leaks.

Observation is fully preserved: group messages are stored in the ring buffer, recorded as activity, and persisted to the graph at the access-independent capture site, and remain visible via `whatsapp-conversations`, `whatsapp-group-info`, `list-groups`, and the `/whatsapp` reader. This is a Meta-policy constraint (automated messaging from an unofficial linked device is penalised, the same class as the reply-only DM posture); for agent-driven group messaging, Telegram is the recommended channel.

## Native channel reply surface

A native admin channel session runs a per-sender `whatsapp-channel` MCP server (`platform/services/whatsapp-channel/`) exposing two reply-only tools: `reply` (text) and `reply-document` (one or more files delivered as WhatsApp documents). `reply-document` POSTs to the gateway's `POST /wa-channel/reply-document`, which funnels through `sendWhatsAppDocument` — the single send core that owns account-dir path validation, the 100 MB ceiling, the canonical `[whatsapp:outbound] sent document …` log, and per-file reconciliation (`op=reply-document-reconciled` on delivery, `file-delivery-unreconciled` on a miss — no silent failure). A bare `SendUserFile` is also caught and delivered through the same core by a read-only JSONL follower on the session, so an attachment reaches the sender even when the agent skips the explicit tool. Both surfaces are reply-only: a file reply to a sender with no live conversation is refused. See [channels-whatsapp.md](references/channels-whatsapp.md) and `.docs/whatsapp-inbound-lifeline.md`.

## Agent-initiated admin send

`whatsapp-send-admin` is the one outbound path that does **not** require a bound inbound conversation, so a webchat session or a scheduled run can push a text summary over WhatsApp. It is hard-gated to the cold-send-ban carve-out: the destination must already be a **registered recipient** — on the house `whatsapp.adminPhones` list (managed via `whatsapp-config add-admin-phone`) **or** a registered account manager in `whatsapp.accountManagers` (managed via `whatsapp-config add-account-manager`). Both lists are read from the socket-owning house account (`resolveAccount`), and the send always rides the house's paired socket. A destination in neither list is refused with `[whatsapp:outbound] op=send-refused reason=not-admin-phone` and no send is attempted — never a cold first contact. An **account-manager** phone (in `accountManagers`, disjoint from `adminPhones`) **is** a valid `send-admin` destination regardless of its binding mode: the operator registered it, so it is not a cold stranger. `passive` governs the manager's **inbound** behaviour only (no auto-reply, intake-only) and never gates this outbound send. A successful send logs its admit basis (`op=send-admin-ok via=admin-phone|account-manager`). Delivery reuses `sendTextMessage`, so the sent id is tracked for echo suppression and the send is recorded as outbound activity. Text only; for files use the bound `reply-document` surface.

## Self-chat command lane

The agent links as a companion device on the operator's own WhatsApp account, so the operator cannot DM the agent from the paired handset — those messages arrive as `fromMe=true` / upsert type `append`. The operator's **self-chat** (message-yourself thread) is the command lane from that handset: the `append` branch in `manager.ts` evaluates a six-clause discriminator (`inbound/self-chat.ts`) — fromMe, self-surface jid (selfJid/selfLid/`selfPhone@s.whatsapp.net`, device-suffix normalised), not an agent-sent echo, timestamp strictly after `lastConnectedAt` (excludes reconnect history backfill; restart-safe backstop for the 2-min echo cache), not a duplicate, non-empty text — and dispatches a match to the **admin** agent with raw text; the reply lands back in the self-chat. Every self-chat `fromMe` message emits one `[whatsapp:self-chat] op=seen` line followed by `op=dispatch` or `op=skip reason=echo|backfill|duplicate|no-text`, so a dropped command is never silent. `fromMe` in any other thread is unchanged (`[Owner reply]` mirror). See [channels-whatsapp.md](references/channels-whatsapp.md) §Self-chat command lane.

## Live persistence

Every `messages.upsert` event (both `notify` and `append`, both `fromMe` directions) is captured at a single site in `platform/ui/app/lib/whatsapp/manager.ts` and split two ways: the sender's `:Person` node is MERGEd in Neo4j (identity linkage, shared with the rest of the platform's People model — unchanged from before), and the message content itself is appended to an on-disk JSONL store at `~/.<brand>/data/whatsapp-messages/<platformAccountId>/<canonicalConversationJid>.jsonl` — one file per **contact**, one line per message. `:Message:WhatsAppMessage` nodes and their `:PART_OF`/`:SENT`/`:NEXT` edges no longer exist; message ordering is a `dateSent` sort at read time instead of a graph edge. `:Conversation` nodes are unaffected — they remain the shared session anchor for the agent-path `:UserMessage`/`:AssistantMessage` writer too. Persist failures are loud (`[whatsapp-persist] FAIL …`) and never block dispatch.

**Conversation-key canonicalisation.** The store file is keyed on the *canonical* conversation JID, not the raw wire `remoteJid`. WhatsApp addresses one contact both by a linked-identity JID (`<lid>@lid`) and a phone-number JID (`<number>@s.whatsapp.net`), so inbound and in-thread replies accrete under the LID while number-addressed outbound sends echo under the PN — keying on the raw `remoteJid` splits one contact across two files. `resolveCanonicalConversationJid` (`normalize.ts`) resolves each message's `remoteJid` to a single key via Baileys' own LID→PN mapping (`getPNForLID`) — the phone-number JID when the LID resolves to one, else the raw JID unchanged (LID-only contacts stay LID-keyed; group and PN JIDs are already canonical) — before `appendMessage` chooses the file. The keying decision is logged on every store write: `[whatsapp-message-store] op=key raw=<jid> canonical=<jid> resolved=<lid2pn|none>`, so a future split (canonical diverging across a contact's records) is greppable at write time. `messageId` keeps the **raw** wire JID — `whatsapp-live:<waName>:<remoteJid>:<msg.key.id>` — so idempotency is stable across the canonicalisation boundary: a replay of the same wire message keeps its `messageId` regardless of which store file it lands in. Because a `@lid` remoteJid resolves to its PN key only once Baileys' LID mapping warms, a message's live delivery and its later history-sync replay can target different files; `appendMessage` therefore dedupes against the raw remoteJid as an alt key (passed by persist when the canonical key differs), so the second write no-ops instead of double-writing across the two files.

**Reader labelling.** A store row is never labelled with the install's own linked (self) number. A 1:1 is labelled by its **counterparty**: a phone-number-keyed conversation (`@s.whatsapp.net`) is addressed by the peer's number, so `buildStoreConversationRows` takes `senderId` straight from the conversation key (`jidToE164(remoteJid)`) — correct whether the earliest record is inbound or an outbound send that stamped the self phone, and needing no runtime self phone at all (so the label is right even before the live connection reports one). A LID-keyed conversation is always inbound-originated, so its earliest record's `senderTelephone` is the contact, never the self phone.

**Historical reconciliation.** Splits created before the canonicalisation fix are collapsed by the one-time `platform/scripts/merge-whatsapp-split-conversations.mjs` — dry-run by default, `--apply` to write, `--account=<uuid>` to scope. It merges each contact's split files into one canonical file (recovering the contact number from the records' resolved `senderTelephone`, with the persisted `whatsapp-lid-map.json` cache as a fallback), idempotent by `messageId`, dropping nothing; it logs per-account merged-file and merged-record counts and asserts one file per contact.

**Search coverage tradeoff.** `/graph`'s BM25/vector search no longer covers WhatsApp message bodies (`graph-search.ts`'s `MESSAGE_FAMILY_LABELS` dropped `WhatsAppMessage`) — this is an accepted, documented consequence of the storage move, not a bug. Every persisted message remains readable via `whatsapp-conversation-graph-state` (all-senders mode) and the `/whatsapp` reader panel's persisted-only rows.

**`accountId` contract.** Unchanged for `:Person` (see below); the on-disk store is keyed by the same platform-side UUID.

**Sub-account recall.** Recall is account-scoped as a read-time projection: a sub-account session sees only the house-store threads whose counterparty is one of its bound account-manager phones (the same filter the operator dashboard uses, shared via `counterpartyBoundToSubAccount`). The write path and the store key are unchanged — messages stay house-keyed; "sub-account scope" is a projection, not a move. `whatsapp-conversation-graph-state` defaults its `accountId` to the calling session's own `ACCOUNT_ID`, so a sub-account recalls its own history with no argument while the house admin may still pass any account id explicitly. Body search is not restored by this projection (it has been absent since the on-disk store cutover).

**Caller-scope enforcement.** The three read tools — `whatsapp-conversation-graph-state`, `whatsapp-conversations`, `whatsapp-messages` — forward the caller's own account (`ACCOUNT_ID`) and its house-admin scope (`HOUSE_ADMIN_SCOPE`) to the route as headers, distinct from the caller-controlled `accountId` argument. The route enforces caller-scope: a house admin reads any account; any other session (a sub-account, or a specialist under any account) may read only its own account and gets a 403 if it passes a different `accountId`. This closes the override where a sub-account could pass an explicit house or sibling id and read that store. It defends only the loopback route against the agent-controlled argument; the route still trusts localhost, so forging these headers on a direct loopback call remains possible — closing that is a separate, tracked follow-up.

## Skills

| Skill | Purpose |
|-------|---------|
| [connect-whatsapp](skills/connect-whatsapp/SKILL.md) | Complete WhatsApp setup — QR pairing, admin phone registration, public agent selection |
| [manage-whatsapp-config](skills/manage-whatsapp-config/SKILL.md) | Guided WhatsApp settings editing — DM policy, limits, acknowledgement |

## References

| Reference | Topics |
|-----------|--------|
| [channels-whatsapp.md](references/channels-whatsapp.md) | Implementation details — Baileys architecture, reconnection, voice notes, outbound policy, disconnect reasons |

Config field definitions (field names, types, valid values) are available on-device via `whatsapp-config action: schema`.
