# WhatsApp Channels

WhatsApp connects via Baileys multi-account:

| Provider | Use Case | Connection |
|----------|----------|------------|
| **baileys** | All WhatsApp accounts | WebSocket via QR code (linked device) |

## Reply-only posture

The agent has **no tool to initiate a WhatsApp message to an arbitrary number.** The `whatsapp-send` and `whatsapp-send-document` MCP tools were removed after an agent-initiated cold first-contact send (to a number with no prior conversation) tripped WhatsApp's anti-automation guard and restricted the operator's account. Initiating is intentionally unavailable, not merely gated — a soft approval gate already existed and did not prevent the incident.

What the agent **can** still do: reply within an existing chat. On a native admin channel session the whatsapp-channel server (`platform/services/whatsapp-channel/`) exposes two reply tools, both reply-only: `reply` (text) and `reply-document` (one or more files delivered as WhatsApp documents). `reply-document` POSTs to `POST /wa-channel/reply-document` → `sendWhatsAppDocument`, the one send core that owns path validation, the 100 MB ceiling, the canonical log, and per-file reconciliation. 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 if the agent skips the explicit tool. File delivery is refused for a sender with no live conversation — same reply-only posture as text. (The removed `whatsapp-send-document` route tool and the bridge path `SendUserFile` → `file-delivery-bridge.ts` → `POST /api/whatsapp/send-document` are the legacy webchat/email bridge surface, not the native WhatsApp channel.) Replying within your chats is what WhatsApp permits; starting new chats from a linked device is what it penalises.

## Per-account observe socket (account manager)

Each sub-account can pair its own WhatsApp number as a linked device, separate from the house socket. This is intended for an account manager's phone: the agent reads that WhatsApp, but the phone's inbound is never treated as instructions to the agent.

A socket is made observe-only by setting `dispatchInbound: false` on the account (via `whatsapp-config action: set-dispatch-inbound accountId: <own account> dispatchInbound: false`, or at pair time). On an observe socket, every inbound message is stored, recorded, persisted to the graph, and readable by the sub-account's agent through `whatsapp-conversations`, `whatsapp-messages`, and the attachment read path, exactly as any other account. What changes is dispatch: no inbound spawns an agent turn. A third-party DM, a fromMe owner mirror, and a self-chat command are all suppressed at one choke point in `manager.ts`, logged as `[whatsapp:observe] op=dispatch-suppressed`. The default is `dispatchInbound: true` (every account dispatches), so an install is unchanged until an account is explicitly made observe.

Pairing an observe socket is self-service: a sub-account session may pair and manage only its own account. The lifecycle routes (`login/start`, `login/wait`, `disconnect`, `reconnect`) and `set-dispatch-inbound` scope to the target account (a house admin may act on any account; a sub-account only on its own, refused otherwise). The house socket and the `accountManagers` dispatch map are unchanged.

Outbound on an observe socket is `whatsapp-reply`: reply-only into an existing conversation, over that account's own socket. It refuses any recipient with no prior conversation, so it can never start a new chat. This is the same reply-only, never-cold-contact posture the whole channel keeps, applied to the observe socket. `whatsapp-send-admin` still rides the house socket and still requires a registered recipient.

The `whatsapp-status` output stamps each account's resolved mode (`<accountId>:up:dispatch` or `<accountId>:down:observe`), so an observe account that came up dispatching, or lost its mode across a boot, is a visible mismatch. A standing safety net at the router logs `[whatsapp:observe] op=BREACH` if any dispatch ever reaches it from an observe account; that line must never appear.

## Observation-only in groups

The agent **never responds in a group chat.** Every inbound group message is stored, recorded, and persisted to the graph (observation is fully preserved — `list-groups`, `whatsapp-group-info`, conversations, and the `/whatsapp` reader all still work), but it is never dispatched to a responding session and never produces an outbound message into the group. `checkGroupAccess` is a structural observe-only deny — there is no group policy, allowlist, per-group activation, or @mention setting to turn responses on. A belt invariant in the manager's `reply` closure refuses any `@g.us` recipient (`op=group-reply-blocked`).

This is the same Meta-policy constraint as the reply-only DM posture above, applied one step further. Replying into a group from an unofficial linked device is automated business-style messaging — the class of behaviour that restricted a live operator account. It is a **Meta-policy constraint, not a Real Agent limitation.**

For agent-driven messaging in groups or initiating conversations, **Telegram is the better channel** — it has no equivalent linked-device automation penalty, so the agent can send and respond freely there. See `platform/plugins/telegram/references/setup-guide.md`.

## Primary Architecture: Multi-Account Baileys

Users link one or more WhatsApp numbers via QR. Each account is an independent Baileys connection with its own credentials, policies, and agent routing. Multiple accounts are optional — most setups use a single account.

WhatsApp Business **app** vs personal WhatsApp is irrelevant from the platform's perspective — Baileys connects to both identically via QR. The Business app just adds business profile, catalog, and labels in the phone UI.

**Linked device note:** The primary phone must come online periodically (~every 14 days) to keep the Baileys linked-device session alive. It does NOT need to be on 24/7.

## Managing Accounts via UI

The Setup page (`/setup`) supports multi-account management in its status dashboard (shown after initial setup is complete):

- **Add Account:** Click "+ Add WhatsApp Account" below the status dashboard, enter a name (e.g. "Business"), and scan the QR code to link
- **Relink:** Each account has its own relink button (rotate icon) that shows a per-account QR code inline
- **Remove:** Each account (except the last one) has a remove button (X icon) that disconnects and removes the account from config
- **Per-account QR:** QR codes appear inline under the specific account being linked

The initial first-run wizard flow stays single-account. Multi-account is managed from the status dashboard that appears once setup is complete.

## Multi-Account Config Example

```yaml
channels:
  whatsapp:
    adminPhones:
      - "<admin-phone>"    # E.164 phone the user messages from
    accounts:
      default:
        provider: baileys
        name: "Main"
```

Accounts are linked via QR in the Channels UI. Outbound routing (`src/web/outbound-routing.ts`) selects the right account automatically:

| Target | Account | Reason |
|--------|---------|--------|
| Admin phones | Account with admin binding | Admin's registered number |
| Self-chat | Account with selfChatMode | Admin talking to themselves |
| Customer DMs | Account with open DM policy | Dedicated agent number |
| Inbound replies | Same account that received | Closures capture the right provider |

## Baileys Risk Profile

Baileys uses the same WebSocket protocol as WhatsApp Web/Desktop linked devices. Meta cannot blanket-block it without breaking their own official clients for hundreds of millions of users.

**Responsibility model:** The platform and its other users are not at risk — bans are per-number, targeting individual accounts for abusive behaviour. Users should be told: *"Don't use this for bulk messaging or spam. Meta may ban your number for abusive patterns. Normal business conversations are fine."*

- Normal conversational use (replies, scheduling, quotes) = effectively zero risk
- Ack reactions, typing indicators, and reply delays mimic real linked-device behaviour
- Risk increases only with spam patterns: mass messaging, rapid-fire sends to unknown contacts, broadcasting
- If a user's number is banned, it's between them and Meta — other users are unaffected

## WhatsApp Outbound Policy — No Automated Broadcasts

**WhatsApp's Terms of Service explicitly prohibit automated bulk/broadcast messaging.** Violations result in permanent number bans with no appeal.

The platform enforces this at multiple levels:

1. **Broadcast action blocked** — The `message broadcast` action (which sends to multiple targets) always excludes WhatsApp as a target channel, regardless of config. This is a hard block that cannot be overridden.
2. **Agent tool suppression** — When an agent session is on WhatsApp, the `broadcast` action is removed from the message tool schema entirely. Agents cannot invoke it.
3. **Broadcast disabled by default** — `tools.message.broadcast.enabled` defaults to `false`. Even when enabled for other channels (Discord, Telegram, Slack), WhatsApp remains blocked.

**What IS allowed on WhatsApp:**
- Replying to incoming messages (the core use case)
- Sending messages to a single known contact via the `send` action
- Heartbeat/scheduled messages to the admin's own number
- Owner outbound mirroring (detecting manual WhatsApp Web sends)

**What is NOT allowed:**
- Sending the same message to multiple WhatsApp contacts in a loop
- Any form of automated outreach to contacts who have not messaged first
- Using the broadcast action with WhatsApp as a target channel
- Building "campaign" or "blast" workflows that target WhatsApp contacts

## DM Policy & Access Control

**Precise definitions — these terms are NOT interchangeable:**

| Term | Definition | Code |
|------|-----------|------|
| **Self phone** | The WhatsApp account's own phone number (the phone that scanned the QR code). Self-chat only. | `phonesMatch(senderPhone, selfPhone)` in `access-control.ts` |
| **Admin phones** | Phones listed in `adminPhones` in `account.json`. The user's personal phone is auto-registered on QR link; additional phones are added via `whatsapp-config action: "add-admin-phone"`. Their admin session is scoped to the **house** account. `adminPhones` is authoritative only on the account that owns the WhatsApp paired socket (the house): `add-admin-phone` is refused on any other account, and a list left on a non-socket (sub-)account is inert and purged at boot with `[admin-identity] op=purge-nonsocket-adminphone`. To answer "is this phone a manager?" read the house account's `adminPhones` + `accountManagers` only — never a sub-account's list. | `isAdminPhone()` in `access-control.ts` — iterates `adminPhones` |
| **Account managers** | Phones in the `accountManagers` map (`account.json`, phone → sub-account binding), added via `whatsapp-config action: "add-account-manager"`. Scoped to the **bound sub-account**, not the house. Disjoint from `adminPhones` — a phone is a house admin or a sub-account manager, never both. Each binding has a **mode**: `active` (default) is a full admin session that can reply and manage the sub-account; `passive` is a constrained requirement-intake spawn (only `work-create`, no reply). | `managedAccountFor()` in `access-control.ts` resolves the sub-account + mode **once**; `checkDmAccess` returns `effectiveAccountId` + `passive`, and `ensureChannelSession` consumes them without re-resolving |
| **Public/unknown** | Any phone that is not self, not in `adminPhones`, and not in `accountManagers`. Subject to DM policy gating. | Everything else in `checkDmAccess()` |

**Critical distinction:** The *self phone* is the paired device's number (the WhatsApp account Real Agent controls). The *admin phone* is the user's personal phone (the phone they message *from*). These are typically different numbers. If the user's personal phone is not in `adminPhones` or `accountManagers`, their messages route as public — not admin.

**Passive (requirement-intake) account managers.** Bind a phone as passive to turn its WhatsApp messages into tasks for a sub-account without giving it an interactive agent:

```
whatsapp-config action: "add-account-manager" phone: "+441234567890" managesAccount: "<sub-account-uuid>" mode: "passive"
```

A passive-bound phone that DMs a requirement gets exactly one `:Task` filed under the sub-account, and nothing back. The spawn is a fresh, dedicated one-shot process confined by construction: its only tool is `work-create`, no WhatsApp reply server is attached, and it holds no house authority. The sender receives no acknowledgement — the person who set up the intake follows up with them separately. Omit `mode` (or pass `mode: "active"`) for the default full-admin manager, which is unchanged. `list-account-managers` tags passive bindings with `(passive)`. The lockdown is verifiable from the journal: `[wa-passive] op=spawn … replyServer=false tools=[mcp__plugin_work_work__work-create]`.

**Account-manager scoping — the two-account split.** A designated manager's inbound spawns an admin session scoped to their sub-account (its graph, skills, and `${USER_ID}` attribution). Replies and file delivery still go out over the **house's** paired WhatsApp device — the only WhatsApp socket — so the sub-account scope never redirects the transport. The connection (house) account is kept for `startNativeFileFollower` and the reply path. A stale binding (a phone wrongly also on `adminPhones`) is surfaced at boot by `account-manager-drift` and never silently mis-routes.

**No per-recipient deliverability restriction.** The single paired line can deliver to any number regardless of which account owns the socket. Sub-account scope (from account-manager binding) decides which session runs the turn — its cwd, graph, and tools — and never which numbers a message can reach. To reach an account manager proactively or on a schedule, a scheduled dispatch (`agentDispatch`) accepts the manager as a valid `destination` and delivers over this same house line; there is no "point the line at the sub-account" step, because socket ownership does not gate the recipient.

**Reader display of a manager's thread — additive, house-stored.** A manager's inbound persists to the message store under the **house** account (the socket owner), not the sub-account it routed to — the write path is house-anchored and unchanged. So the operator Conversations reader, which scopes store rows to the viewed account, would otherwise show a manager's WhatsApp thread only in the **house** view. When a **sub-account** is viewed (`multiAccount` and the viewed account is not the house), the reader additionally lists every house-stored thread whose counterparty phone matches an `accountManagers` entry bound to that sub (`managerPhonesForAccount` + `phonesMatch`, the same match primitive as the gate). These extra rows carry `accountId=<house>`, so opening one streams from the house store file via `/store-stream` exactly as stored. This is **display only and additive**: the thread still appears in the house view (its own store, untouched); nothing is moved. Single-account and house views are unchanged. The join is observable on the reader's pull-based `op=list` line: `houseResolved=<8|null> managerPhonesBound=<n> managedStoreRows=<m>` — `managerPhonesBound>0 managedStoreRows=0` is a phone-format or house-config-read failure, `houseResolved=null` on a `multiAccount=true` list means the house is unresolvable and every sub silently shows none. The agent read tools `whatsapp-conversations`, `whatsapp-messages`, and `whatsapp-conversation-graph-state` project the same way: on a sub-account session they read the durable house store filtered to that sub-account's bound manager phones, so the agent recalls exactly the threads the operator reader shows for that sub-account. Each logs `projected=<bool> storeAccount=<house|self> managerPhonesBound=<n>`.

**HTTP-route caller-scope — reads and the house socket are house-scoped.** The loopback routes decide access from platform-stamped headers the MCP tool forwards from its spawn env (`ACCOUNT_ID` → `x-maxy-caller-account`, `HOUSE_ADMIN_SCOPE` → `x-maxy-house-admin-scope`), never from a caller-controlled body param — the agent cannot forge them, and an absent signal fails closed. Two families share one decision (`authorizeRecallRead`): a house admin (house scope set) is authorised; otherwise the caller's own account must equal the requested account; otherwise denied. **Read routes** (`/conversations`, `/messages`, `/conversation-graph-state`) scope on the requested `accountId`, so a sub-account may read only its own recall and never pass an explicit `accountId=<house>` to read the house store. **The house-socket routes** — `/config` (every action: `add`/`remove`/`list-account-manager`, `add`/`remove`/`list-admin-phone`, `set`/`get`/`list-public-agent`, `update-config`, `get-config`, `schema`, `list-groups`), `/status`, `/login/start`, `/login/wait`, `/disconnect`, `/reconnect` — all act on the install's single house-owned socket, so each resolves the house/sole account and gates against it: a client sub-account session may neither read nor mutate the house WhatsApp config or connection, a house admin may. The two internal callers of `/config` both forward these headers: the `whatsapp-config` agent tool, and the operator-only `account_roster` (its house session carries the scope, so the account-manager column keeps resolving). A denial logs `[whatsapp-read-tool] op=authz-deny requested=… caller=…` for a read and `[whatsapp:api] op=authz-deny route=… caller=… houseAdmin=…` for a house-socket route, and returns HTTP 403 with no side effect.

**Recall surfaces the attachment, not just the text.** A recalled message that carried media now includes an `attachment` reference on its read-tool row (`whatsapp-messages` and both modes of `whatsapp-conversation-graph-state`): `attachmentId`, `filename`, `mimeType`, a `sizeBytes` (bytes, as stored — the line renders it as KB/MB), an absolute on-disk `path` to the stored bytes, and a `readableTextPath` when an extracted-text sibling exists. The reference resolves against the same store account the route read the thread from (the house account when projecting a sub-account), so the path always points at the house-stored bytes, never the sub-account's own empty uploads dir. The read-tool output renders it as an indented `[attachment: <name> (<mime>, <size>); Read: <path>]` line, and the agent reads that path directly (an image renders when Read). Inbound WhatsApp media runs best-effort text extraction (the earlier `skipDocToText` opt-out was retired): a convertible container (xlsx, docx, ods, and the rest of `CONVERTIBLE_DOC_MIME`) gets an `.extracted.txt` sibling, so `readableTextPath` is populated for those; an image or other non-convertible type has no sibling and surfaces the bytes `path` alone. Observable on each read route's result line as `withAttachments=<n>`; a record that names an attachment whose bytes are missing under the store account logs `[whatsapp-read-tool] op=attachment-miss attachmentId=<8> storeAccount=<id>`, which separates a genuine house-store gap from a sub-account mis-resolution.

**Single source of truth + fail-closed.** The effective account is resolved **exactly once**, in the gate: `checkDmAccess` returns `effectiveAccountId` (the house account for owner/`adminPhones`/public, the bound sub-account for a manager), and that value threads through the inbound payload → gateway → `ensureChannelSession`, which spawns into it **without re-reading** the `accountManagers` map. There is no second resolution and no `?? accountId` house fallback: if a manager's bound sub-account is not a valid account, the inbound is **rejected** (no session spawned, no reply), never routed to the house. This closes the escalation where a divergence between two independent map reads handed a scoped manager a house-owner admin session. Observable signals: `op=account-manager-route … effectiveAccount=… source=gate` on a routed manager inbound; `op=account-manager-reject … reason=unresolved-effective-account` on the fail-closed drop; a standing `op=escalation-tripwire` belt that can only fire if a future change reintroduces the divergence.

**The scheduler path fails closed too.** A scheduled dispatch (`POST /api/channel/schedule-inject`) reaches the same spawn machine without going through the gate, resolving its own effective account via `effectiveAccountFor`. That resolver carries the same single-source + fail-closed shape: a non-manager destination scopes to the house (an owner/admin's real scope), a valid manager scopes to the bound sub-account, and a manager whose bound sub-account is not a valid account resolves to nothing — the route **rejects** (`op=schedule-account-manager-reject … reason=unresolved-effective-account`, HTTP 403, no spawn, no reply), never routing to the house. There is no `?? accountId` fallback on this path either. Telegram scheduled dispatch is house-only by construction (no account-manager routing), so there is nothing to fail closed there.

**Schedule-time destination validation is a distinct, earlier layer.** The `effectiveAccountFor` resolution above runs at *fire* time to scope the spawn. Separately, when a schedule is *created or updated* (and re-checked every heartbeat by the standing audit), the scheduler validates the WhatsApp `agentDispatch` destination against the same house authority. It reads the authoritative `adminPhones` + `accountManagers` from the socket-owning **house** account — never the event's own `account.json`, whose manager map is inert — via the one shared `validateAgentDestination`. The destination's registered account (the house for an admin, the bound sub-account for a manager) must equal the account that created the schedule; the house/owner may target any registered admin or manager, a sub-account is locked to its own manager(s). A refusal names the true cause: `not-registered` (in no house list) or `cross-account destinationAccount=… scheduleAccount=…` (registered to a different account). Create-time and fire-time emit the identical reason codes, so a schedule that passes creation but later drifts is explained rather than silently dropped. This closes the bug where the validator read a sub-account's own (empty) manager map and refused a genuinely registered manager whose binding lives on the house.

**Socket-key resolution — the reply transport.** The account id that names a *spawn scope* (a platform account UUID) is a different identifier from the key the *socket* is stored under (the credential dirname — `"default"` on a legacy single-account install that was never re-keyed to the UUID). These coincide when the credential dir is named for the UUID and diverge otherwise. The inbound reply path never trips on this: it closes over the live connection object directly. A scheduled dispatch has no live connection to close over, so it resolves the socket by asking the manager — `resolveSocket(accountId)` accepts **either** the platform UUID or the connection map key and returns the one connection that owns the socket (the manager holds the `accountId ↔ platformAccountId` bijection). This is the single resolver both the reply path and the scheduler go through, so the id `getSocket` is called with is always a key the connection map actually holds. A null resolution is **two distinct faults, never conflated**: `op=socket-key-miss accountId=… presentKeys=…` means no connection is registered under that id — an account-keying mismatch, the socket may be alive under a different key, and re-pairing is the wrong advice; a *disconnected* result means the connection exists but its transport is down. Reporting a key miss as "WhatsApp disconnected" is the bug that once told an operator to re-sign-in a connected line.

**`dmPolicy` behaviour (per-account):**

| Policy | Self phone | Admin phones (in `adminPhones`) | Public/unknown |
|--------|-----------|-------------------------------|----------------|
| `"open"` | Allowed | Allowed | Allowed |
| `"allowlist"` | Allowed | Allowed | Allowed only if in `allowFrom` (wildcard `"*"` counts) |
| `"disabled"` | Allowed | Allowed | **Blocked** |

Admin phones always bypass DM policy — the `isAdminPhone()` check runs before any policy-specific logic. Account-manager phones bypass it too (the `accountManagers` check runs immediately after, before the policy switch). Self phone is always allowed regardless of policy.

**Schema constraint:** `dmPolicy: "open"` requires `allowFrom` to include `"*"` (enforced by Zod validation).

**Key file:** `access-control.ts` — `checkDmAccess()` gates DMs by the policy above; `checkGroupAccess()` is a structural observe-only deny (always `allowed:false reason:group-observe-only`) — no group policy is consulted.

**Config path:** Per-account at `whatsapp.accounts.{accountId}.dmPolicy` in `account.json`. Falls back to `whatsapp.dmPolicy`. Changes are conversational — the admin agent writes to config via `whatsapp-config action: "update-config"`.

For config field definitions (field names, types, valid values), use `whatsapp-config action: schema`.

## Owner Outbound Mirroring

When the business owner sends a WhatsApp message directly via WhatsApp Web (not through the agent), Baileys detects the echo with `key.fromMe = true`. The inbound monitor (`src/web/inbound/monitor.ts`) checks `access.isOwnerOutbound` and calls `mirrorOwnerReplyToSession()`, which writes a `[Owner reply] {body}` delivery-mirror into the public agent's session transcript via `appendAssistantMessageToSessionTranscript`. This ensures the public agent knows about the owner's direct intervention when the customer next replies.

The same path is used when an **admin agent** sends to a customer via the `message` tool (cross-agent echo): `fireCrossAgentEcho` in `src/infra/outbound/cross-agent-echo.ts` resolves the public agent's session key and delegates to `mirrorOwnerReplyToSession`. For established sessions the mirror is written to disk immediately; for first-contact customers with no session yet, an ephemeral system event is queued instead.

### Agent-sent echo suppression

Every outbound Baileys message echoes back as a `fromMe` `messages.upsert` event. To prevent the monitor from treating agent-sent echoes as manual owner messages, all send paths must track message IDs via `trackAgentSentMessage(id)` in `src/web/inbound/dedupe.ts`. The monitor then checks `isAgentSentMessage(id)` before calling `mirrorOwnerReplyToSession`.

There are two send paths — both must track IDs:
- **IPC send API** (`src/web/inbound/send-api.ts`): used by the message tool, gateway RPC, and `sendPoll`. Tracks IDs after `sock.sendMessage`.
- **Auto-reply closures** (`monitor.ts` lines 390-401): the `reply()` and `sendMedia()` functions on `WebInboundMessage`, used by `deliverWebReply`. Track IDs after `sock.sendMessage`.

The dedupe cache (`agentSentMessages`) has a 2-minute TTL and 500-entry cap, which is sufficient because Baileys echoes arrive within seconds.

**Key files:**
| File | Purpose |
|------|---------|
| `src/web/inbound/monitor.ts` | Detects `fromMe` echoes, calls `mirrorOwnerReplyToSession`; auto-reply `reply`/`sendMedia` closures |
| `src/web/inbound/owner-mirror.ts` | `mirrorOwnerReplyToSession()` — canonical implementation |
| `src/web/inbound/access-control.ts` | `isOwnerOutbound` flag for `fromMe` DMs |
| `src/web/inbound/send-api.ts` | IPC send path — tracks agent-sent message IDs |
| `src/web/inbound/dedupe.ts` | `trackAgentSentMessage` / `isAgentSentMessage` — echo suppression cache |
| `src/infra/outbound/cross-agent-echo.ts` | Adapter: agent tool → `mirrorOwnerReplyToSession` |

## Self-chat command lane

The companion-device link means the operator's paired phone cannot DM the agent — a message typed from the paired number arrives as `fromMe=true` with upsert type `append`, which the manager's append branch historically dropped before dispatch. The operator's **self-chat** (the message-yourself thread, `remoteJid` == the account's own jid/lid) is the exception: the agent never writes there first, so it is an unambiguous admin command surface.

The discriminator lives in `platform/ui/app/lib/whatsapp/inbound/self-chat.ts` (`evaluateSelfChatCommand`) and dispatches only when **all** of:

1. `msg.key.fromMe === true`;
2. `remoteJid` is the self surface — `selfJid`, `selfLid`, or `${selfPhone}@s.whatsapp.net`, with device suffixes (`447…:12@s.whatsapp.net`) normalised before comparison;
3. the message id is not in the agent-sent cache (`isAgentSentMessage`) — suppresses the agent's own self-chat reply echoes within the 2-minute TTL;
4. the message timestamp (Unix seconds, normalised to ms) is **strictly after** `conn.lastConnectedAt` — excludes history backfill replayed on reconnect, and is the restart-safe backstop when the in-memory echo cache has expired or been cleared;
5. it is not an in-session duplicate (`isDuplicateInbound`);
6. it has non-empty extracted text.

A match dispatches via `buildSelfChatDispatch` (`manager.ts`): `agentType: "admin"`, raw command text (no `[Owner reply]` prefix), `senderPhone = selfPhone`, the admin cacheKey (`whatsapp:<accountId>`), `reply`/`composing` closures bound to the self jid so the agent's answer returns to the self-chat, and **no `isOwnerMirror` flag** — the inbound route drops owner-mirror payloads before the gateway, and a self-chat message is a command expecting a reply, not a mirror. The reply goes out through `sendTextMessage`, which tracks the id for clause-3 echo suppression.

`fromMe` in any **non-self** thread is unchanged: it remains the `[Owner reply]` mirror described above, never a command.

**Log grammar** (one decision lineage per self-chat message, greppable by `msgId=`):

```
[whatsapp:self-chat] op=seen account=… msgId=… tsMs=… connAtMs=… fromMe=true
[whatsapp:self-chat] op=skip account=… msgId=… reason=echo|backfill|duplicate|no-text
[whatsapp:self-chat] op=dispatch account=… msgId=… bytes=… agentType=admin
```

Failure signatures: an `op=dispatch` whose `tsMs ≤ connAtMs` (backfill wrongly dispatched — must never appear; the `op=seen` line carries both fields so this is checkable per message); back-to-back `op=dispatch` with no operator-originated message between (self-reply loop); a self-chat message the operator confirms sending with no `op=seen` at all (silent drop upstream of the discriminator).

## Offline Message Recovery

Baileys is a WhatsApp Web protocol client. When the gateway is offline, WhatsApp's servers hold incoming messages and push them back as history (`append`-type `messages.upsert` events) when Baileys reconnects — so the system self-recovers automatically on restart. These replayed messages are stored in the UI message cache and marked as read, but **not** dispatched to the agent (no auto-reply on catch-up). The self-chat command lane (above) does not weaken this: replayed history fails its strictly-after-`lastConnectedAt` timestamp clause and skips with `reason=backfill`. The recovery window is determined entirely by WhatsApp's server-side delivery queue (approximately 30 days); `syncFullHistory: false` only prevents requesting full chat history on first pairing and does not restrict this window.

## Voice Notes

### Inbound (transcription)

Inbound voice notes are transcribed to text before reaching the agent. The pipeline runs in `manager.ts` after `downloadInboundMedia` succeeds for audio messages:

1. **ffmpeg** converts OGG Opus → WAV (16 kHz, mono, PCM s16le)
2. **whisper.cpp** (`/opt/whisper.cpp/build/bin/whisper-cli` with `ggml-base` model) transcribes WAV → raw text

The raw whisper transcript is set on `extracted.text` so both agent paths receive identical content. On any failure, the pipeline degrades silently — the agent sees the standard media placeholder instead of transcribed text.

**Source:** `platform/ui/app/lib/stt/index.ts` (transcription)

**Log tag:** `[stt]`

### Outbound (PTT)

Outbound TTS audio is sent as native WhatsApp PTT (Push-To-Talk) voice notes. The pipeline:

1. **TTS output format:** `resolveOutputFormat("whatsapp")` returns Opus (OGG container) instead of MP3 (`src/tts/tts.ts`)
2. **ffmpeg normalization:** `normalizePttAudio()` re-encodes to WhatsApp-native format — OGG Opus, 48 kHz, mono, 64 kbps, VoIP mode (`src/media/ptt-normalize.ts`)
3. **Baileys metadata:** `music-metadata` and `audio-decode` npm packages compute `seconds` (duration) and `waveform` fields for the PTT message. Without these, WhatsApp renders audio as a generic file attachment.
4. **Send:** `ptt: true` flag on the audio message, no caption (voice IS the content)

**System dependency:** ffmpeg must be on PATH. Installed automatically by `install.sh` (fresh) and `postinstall.js` (upgrades). Best-effort — if ffmpeg is unavailable, the original audio is sent as-is.

**Key files:**
| File | Purpose |
|------|---------|
| `src/media/ptt-normalize.ts` | ffmpeg re-encoding to WhatsApp-native OGG Opus |
| `src/web/auto-reply/deliver-reply.ts` | Auto-reply path (calls normalizePttAudio for audio) |
| `src/web/inbound/send-api.ts` | Baileys send wrapper (sets `ptt: true`) |
| `src/tts/tts.ts` | TTS output format resolution (Opus for WhatsApp) |

## Session Credentials (Baileys Auth)

Baileys stores WhatsApp session credentials (linked-device state) on disk. The auth directory contains `creds.json` (session keys) and Signal protocol pre-keys.

**Path resolution** (`src/web/accounts.ts` → `resolveWhatsAppAuthDir`):

| Priority | Path | When |
|----------|------|------|
| 1. Explicit config | `channels.whatsapp.accounts.{id}.authDir` | User overrides in config |
| 2. Default | `~/.maxy/credentials/whatsapp/{accountId}/` | Normal operation |

**Diagnostics on the Pi:**

```bash
# List all WhatsApp auth directories
ls -la ~/.maxy/credentials/whatsapp/

# Check if credentials exist for an account
ls ~/.maxy/credentials/whatsapp/default/creds.json

# Check credential freshness (should be recent if just linked)
stat ~/.maxy/credentials/whatsapp/default/creds.json
```

If `creds.json` is missing, the session was never linked or was deleted. If it exists but WhatsApp rejects it (401), the session was invalidated (cleared from phone, expired after 14 days offline, or account banned).

**Key files:** `src/web/accounts.ts` (resolveWhatsAppAuthDir), `src/config/paths.ts` (resolveOAuthDir, resolveStateDir), `src/web/session.ts` (enqueueSaveCreds — writes creds on update).

## Pairing Lifeline (diagnosing a link that never finalises)

When a pairing attempt fails — the phone accepts the code and shows "Continue on WhatsApp Web" but the link never completes — the socket-close `408` is the symptom, not the cause. Every step of one pairing attempt is logged under a single correlation key (`cid`, the short login id) across `[whatsapp:login]`, `[whatsapp:session]`, and `[whatsapp:reconcile]`, so one attempt is diagnosable from the log alone.

```bash
# One attempt's full ordered lifeline (grep the platform server log, then filter by cid):
grep -aE '\[whatsapp:(login|session|reconcile)\]' <server log> | grep 'cid=<id>'
```

The single decision point is whether an `op=pair-success` line appears between `op=code-issued` and `op=close`. Absent + `op=close … kind=qr-refs-ended-408` → the completion signal never returned (beyond the Pi). When no account signature is captured, the login now terminates on `op=pairing-incomplete … reason=no-account-signature` rather than reconnecting a half-registered credential into a `401`; the operator sees an actionable message ("WhatsApp accepted the code but did not finish linking … open WhatsApp, Linked Devices, remove existing devices, then ask for a new code") that points at the phone's linked-device limit or a stale companion — the account-side cause a reconnect cannot fix. A dirty `op=creds-pre registered=true hasAccount=false` → Pi-side. A half-registered credential folder (pairing requested, never completed) is reconciled on each `/status` pass: an in-flight or freshly-written one is preserved and logged `[whatsapp:reconcile] op=half-registered … inFlight=<bool>`, while an aged, abandoned one (no live login, creds untouched past the 5-minute pairing window) is deleted once, logged `op=discard-half-registered`, so the next pairing starts from an empty folder.

Full grammar, success/failure signatures, and the `kind` table: [`.docs/whatsapp-pairing-lifeline.md`](../../../../.docs/whatsapp-pairing-lifeline.md).

## Disconnect Reasons

Baileys disconnect errors carry a numeric status code mapped to `DisconnectReason`. The monitor translates these to user-facing messages via `humanizeWhatsAppError()` in `src/web/auto-reply/monitor.ts`.

| Status | Baileys Reason | Meaning | Auto-reconnect? |
|--------|---------------|---------|-----------------|
| 401 | loggedOut | Session removed from phone or expired | No — re-link required |
| 401 + "conflict" | connectionReplaced (server override) | Another WhatsApp Web session took over | Yes |
| 403 | forbidden | Number banned or restricted | No |
| 408 | connectionLost / timedOut | Network interruption or timeout | Yes |
| 428 | connectionClosed | Server closed the connection | Yes |
| 440 | connectionReplaced | Another session replaced this one | Yes |
| 500 | badSession | Corrupted session data | No — re-link required |
| 515 | restartRequired | Server requests reconnection | Yes |

**Conflict vs logout (401 ambiguity):** WhatsApp's server can send a stream error with code 401 and reason "conflict" — Baileys normally maps "conflict" to 440, but the server's explicit `code` attribute overrides the mapping. The monitor checks for "conflict" in the error message to distinguish this from a true logout.

## Terminal Logout: Visibility and Remedy

A `loggedOut` (401, `device_removed`) or a `conflict` (login taken over elsewhere) is **terminal**: the manager stops reconnecting (`classifyDisconnect` returns `shouldRetry: false`) and the link will NOT come back on its own. The cause is a WhatsApp-account-side action — the phone unlinked the device, or another login replaced this one — not something the gateway can recover from. The only remedy is re-pairing (the connect-whatsapp skill); waiting does nothing.

The dead state is reported at two surfaces so it is visible without a reply having to fail first:

- **Standing health signal.** On a terminal disconnect the manager sets `terminalReason` on the account connection (distinct from `sessionStuckReason`, which is the fast-reconnect-churn give-up). `/api/health` reports `whatsapp.any_terminal = true` and the account's `terminalReason`; `whatsapp-status` shows the account Disconnected with the reason (`Session logged out — re-link required`). `terminalReason` clears automatically once a re-pair establishes a fresh connection.
- **Reply failures are correctly described.** The native-channel `reply` tool no longer returns a bare `gateway returned 500`. On a terminal link it returns `reply: WhatsApp logged out — re-link required` (the agent must NOT promise an automatic recovery — there is none until re-pairing); a transient blip during reconnection stays a retryable `reply: WhatsApp disconnected — cannot reply`. The gateway carries the distinction as a `terminal` boolean in the `/wa-channel/reply` failure body, and the `op=reply-failed` log line records `terminal=true|false`.

What is NOT handled here: the end user who triggered the reply receives no delivery-failure notice over WhatsApp, and there is no outbound retry queue — a dropped reply on a dead link is lost (the agent is made aware of the failure, but the sender is not). That is a separate concern; file a task if it is wanted.

## Reconnection: Setup Failures vs Runtime Disconnects

> **Authoritative source:** The current runtime connection lifecycle — including reconnect bounding (`STABLE_UPTIME_MS`, `MAX_RECONNECT_ATTEMPTS`), platform-side init queries (fetchBlocklist, fetchPrivacySettings, intentional fetchProps skip), and the `/api/health` WhatsApp field — is documented in [`.docs/whatsapp.md`](../../../../.docs/whatsapp.md) under "Connection Lifecycle", "Init Query Visibility", and "Reconnect Bounding and Escape". The content below describes the historical disconnect-reason mapping which still applies; for anything about how the loop retries, aborts, or runs its init sequence, read the `.docs` file first.

The reconnection loop in `monitorWebChannel` handles two distinct failure modes:

| Failure Mode | When | Example | Handling |
|-------------|------|---------|----------|
| **Runtime disconnect** | After a successful connection drops | 408 (connectionLost), 428 (connectionClosed) | `listener.onClose` resolves → backoff retry |
| **Setup failure** | Connection never establishes | 405 (Method Not Allowed), 503 (service unavailable) | `monitorWebInbox` throws → caught by try-catch → same backoff retry |

Both paths use the same backoff policy (2s → 30s, 12 max attempts) and the same loggedOut detection. A 401 status only triggers logout if the error message does NOT contain "conflict" (see Disconnect Reasons above). Setup failures log as `"web reconnect: connection setup failed"` instead of `"web reconnect: connection closed"`.

Without the setup-failure handling, a transient WhatsApp server rejection (e.g., 405 during a protocol update) would kill the channel permanently — the error would escape the reconnection loop, and only a gateway restart could recover.

## Provider Architecture

```
src/web/
├── outbound-routing.ts  # Multi-account outbound routing logic
├── active-listener.ts   # Per-account listener registry (listActiveWebAccountIds, etc.)
├── outbound.ts          # Outbound send (routes through outbound-routing)
├── providers/
│   ├── types.ts         # WhatsAppProvider interface
│   ├── factory.ts       # createWhatsAppProvider()
│   └── baileys/         # BaileysProvider (wraps monitorWebInbox)
└── auto-reply/
    ├── monitor.ts       # Baileys reconnection loop (monitorWebChannel)
    └── monitor/
        └── on-message.ts # Message handler

extensions/whatsapp/src/
└── channel.ts           # Channel plugin
```
