---
name: whatsapp
description: WhatsApp messaging channel — Baileys QR pairing, DM/group policy enforcement, inbound routing, business hours gating, outbound messaging, document sending, conversation browsing.
tools:
  - name: whatsapp-login-start
    publicAllowlist: false
    adminAllowlist: false
  - name: whatsapp-login-wait
    publicAllowlist: false
    adminAllowlist: false
  - name: whatsapp-status
    publicAllowlist: false
    adminAllowlist: false
  - name: whatsapp-disconnect
    publicAllowlist: false
    adminAllowlist: false
  - name: whatsapp-send
    publicAllowlist: false
    adminAllowlist: false
  - name: whatsapp-send-document
    publicAllowlist: false
    adminAllowlist: false
  - name: whatsapp-config
    publicAllowlist: false
    adminAllowlist: false
  - name: whatsapp-activity
    publicAllowlist: false
    adminAllowlist: false
  - name: whatsapp-conversations
    publicAllowlist: false
    adminAllowlist: false
  - name: whatsapp-messages
    publicAllowlist: false
    adminAllowlist: false
  - name: whatsapp-conversation-graph-state
    publicAllowlist: false
    adminAllowlist: false
  - name: whatsapp-group-info
    publicAllowlist: false
    adminAllowlist: false
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 messaging with mention gating and per-group activation
- 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 graph view — the writer-known cypher lives in the platform route so the agent never composes its own. `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 `:Message:WhatsAppMessage` rows attached to the `:Conversation` (sessionKey-keyed) plus the resolved `sessionId`, the cypher string, and a divergence header reporting graph-row count vs in-memory count. Use when the question is "what got persisted" — never compose the cypher manually |
| `whatsapp-group-info` | Group metadata from WhatsApp servers — subject, description, participants with admin flags, creation date. Requires active connection |

## Group access — three-way mention recognition

When per-group activation is `mention`, the agent fires only if the inbound message's `mentionedJids` contains an entry equal to the connected account's identity. Three orthogonal equalities are checked: (1) **selfJid** (`<digits>:<n>@s.whatsapp.net`) extracted from creds at pair time; (2) **selfLid** (`<digits>@lid`) extracted from `creds.me.lid` when the session paired under WhatsApp's LID addressing; (3) **selfPhone** via `jidToE164` for the residual case. LID-addressed groups deliver mentions as `<self-lid>@lid` and rely on the second equality — without it, every self-@-mention silently routes to `access=blocked(mention-gating)`. Older paired sessions without a stored LID degrade to two-way matching (selfJid + selfPhone) — additive, never blocks. The boot-time `[whatsapp:lid] resolved self-lid accountId=<id> selfLid=<lid>` line confirms the resolver hit; `[whatsapp:lid] FAIL self-lid-resolution` indicates the older creds shape (predicate stays correct, just narrower).

## Live persistence

Every `messages.upsert` event (both `notify` and `append`, both `fromMe` directions) writes a `:Message:WhatsAppMessage` row to Neo4j attached to the sessionKey-keyed `:Conversation`. A single capture site at `platform/ui/app/lib/whatsapp/manager.ts` covers inbound, outbound (Baileys echoes agent-sent messages back through `messages.upsert` with `fromMe=true`), and owner-mirror — without touching `outbound/send.ts`. `messageId` namespace is `whatsapp-live:<waName>:<remoteJid>:<msg.key.id>` where `<waName>` is the Baileys credential dirname (e.g. `default`); distinct from the `:ConversationArchive` `:Section` chunks written by the source-agnostic `conversation-archive` skill (parent `:ConversationArchive` keyed on `conversationIdentity`, plus `:Section` children) — live and archive live in disjoint shapes. Persist failures are loud (`[whatsapp-persist] FAIL …`) and never block dispatch — silent loss is the worse failure mode.

**`accountId` contract.** `n.accountId` on every `:Conversation`, `:Person`, and `:Message:WhatsAppMessage` row stamped by this plugin is the **platform-side UUID** resolved by [`resolvePlatformAccountId()`](../../ui/app/lib/whatsapp/platform-account-id.ts) from `data/accounts/<uuid>/account.json` — NOT the Baileys credential dirname (which is only used as the `messageId`/`sessionKey` namespace token). The boot-time line `[whatsapp-persist] resolved-account-id waname=<dir> uuid=<uuid>` records the resolution. Doctrine: see `.docs/neo4j.md` "Account isolation invariant" — every writer that stamps `n.accountId` must verify the value against `${DATA_ROOT}/accounts/<id>/account.json` before write. The helper loud-throws on zero or multi accounts (Phase 0 single-account invariant), aborting the WhatsApp connection start before any write can occur. The same boot-validated identity (`process.env.ACCOUNT_ID`) backs the central live floor at [`writeNodeWithEdges`](../../lib/graph-write/src/index.ts) — any write whose `accountId` differs from the spawning process's `ACCOUNT_ID` is rejected by the gate; the WhatsApp helper is the writer-side discipline, the gate is the universal floor. The env itself is stamped onto the brand systemd unit by `buildMaxyUnitFile` and re-validated at every Hono boot (`[graph-health] account-id-env present=true matches-on-disk=true`) — see `.docs/neo4j.md` "Two boot-time surfaces" (added 2026-05-07).

## 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 — messaging policies, limits, group config, 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`.
