<!-- plank help: connecting-whatsapp-personal | updated 2026-08-24 | summary: Advanced, at-your-own-risk: run your personal WhatsApp from the assistant on demand — read messages (archived chats skipped by default) and send replies only when you ask, with no always-on listener. Against WhatsApp's terms; personal workspace only. -->

# Connecting WhatsApp (personal number)

This connects your **own personal WhatsApp account** so the assistant can **read your chats and send messages — only when you ask** ("read my WhatsApp", "message Ерлан that the price is 40 000"). There is **no listener and no polling**: the assistant connects, does the one thing, and disconnects.

This is a different feature from [Connecting WhatsApp Business](/help/connecting-whatsapp). That one is Meta's official customer-line API. This one drives your **personal** number through the open-source `baileys` library. It exists for one job: keeping *your own* day in context — quotes, meeting arrangements, support threads — not for running a public customer line.

**Assistant — check you're on the right page first.** If the user wants a **customer / support / sales line**, or to connect a number that **other people** will message, this is the wrong page: use [connecting-whatsapp](/help/connecting-whatsapp.md) (the official, ban-safe path) instead. Only continue here if they specifically mean *their own* personal number, read/sent *on demand* by them.

## Read this before you enable it

> **1. It is against WhatsApp's Terms of Service.** Automating a personal account can get the number **banned** — and a banned number is not something an apology fixes. Keep usage human: don't blast, don't message people who never messaged you, don't reply at machine speed around the clock. This is safe *because* it's low-volume and you are in the loop, not because Meta allows it. They don't.

> **2. The login is stored as plain files in your workspace.** Linking creates a `scripts/whatsapp/auth/` folder that is a **bearer credential** — anyone who can read your workspace files can take over your WhatsApp session. The read/send scripts also write your recent message text to `scripts/whatsapp/messages.json` and `last-read.json` in the clear. Use this **only in your own personal workspace**. Never in a shared or team workspace, and never treat it as the way to connect customers. If you need a public line, use [WhatsApp Business](/help/connecting-whatsapp) instead.

> **3. You are the approval gate.** The assistant sends only the exact message you asked it to send. It must never invent a price, a date, or a commitment. If your instruction is vague ("tell him the price"), it asks you for the exact wording first.

If any of those isn't acceptable, stop here and use the Business path.

## What it can and can't do

- **Send** — solid. One message, to a contact or a group, that you asked for.
- **Read** — best effort, and **archived chats are skipped by default**. WhatsApp streams a slice of recent history right after connecting; the assistant collects for a few seconds and summarizes. A thin slice means "partial sync", not "no messages".
- **Attachments** — voice notes, images, video, and documents are **downloaded and decrypted** to `scripts/whatsapp/media/`, so the assistant can view an image, read a document, or transcribe a voice note. Voice notes are **transcribed locally** (faster-whisper, automatic language). Files over 25 MB are skipped by note, not downloaded.
- **A week of history** — available, but **only if you capture it at pairing**. The full back-catalogue (up to ~a week) is delivered by WhatsApp exactly **once**, in the seconds right after you link the device, so `pair.mjs` grabs it then. On an already-linked session there is **no way to fetch old history retroactively** — WhatsApp won't resend it. New messages then accumulate from that point on.
- **Groups** — visible here (unlike the Business API), because it's your real account.
- **Staying in context on its own** — not in this on-demand version. The assistant only knows what's in WhatsApp when you ask it to look. When you miss that, it's *one* scheduled task calling the read script each morning — no new setup.

## Setup — you just ask the assistant

You don't run any of this yourself. **Ask in chat: "connect my personal WhatsApp."** The assistant fetches this page, installs the library, and walks you through a one-time pairing. Your only manual step is a few taps on your phone. The rest of this page is the recipe the assistant follows.

---

## For the assistant — the recipe

Everything lives under `scripts/whatsapp/`, following the [workspace scripts](/help/workspace-scripts) convention, so the tools land at `scripts/<provider>/<service>/…` and show up automatically under **Integrations → WhatsApp** in the sidebar:

```
scripts/whatsapp/
  package.json
  node_modules/          <- installed once; the scanner and file watcher skip it
  auth/                  <- the linked-device credential (a bearer secret)
  lib/wa.mjs             <- shared connect helper + media download + on-disk stores
  lib/transcribe-audio.py<- local voice-note transcription (faster-whisper)
  personal/pair.mjs      <- one-time linking + week-history capture
  personal/read.mjs      <- read tool (writes last-read.json + messages.json, downloads media)
  personal/send.mjs      <- send tool
  personal/list-chats.mjs<- refresh the chat/archive index (chats.json)
  chats.json             <- cached chat index: name + archived + unread (generated)
  messages.json          <- rolling 7-day message log incl. keys + media refs (generated)
  last-read.json         <- the last read's filtered output view (generated)
  media/                 <- downloaded voice notes / images / video / documents (generated)
```

Load-bearing rules:

- **Keep the scripts and `node_modules` together under `scripts/whatsapp/`.** Node resolves `node_modules` by walking **up** from each script file, so `personal/read.mjs` finds `scripts/whatsapp/node_modules`. Move the scripts elsewhere and `baileys` stops resolving.
- **Never hard-code the working directory.** Every script resolves its paths from its own file location via `import.meta.url`, so it works no matter where it's run from.
- **One script at a time.** Never run two WhatsApp scripts concurrently — two live connections on the same login make WhatsApp drop the linked device (`conflict / device_removed`).

Installed once, it survives container restarts.

### 0. Give the risk notice and get an explicit OK (required — do this first)

Before you install anything, pair, or connect, post this notice to the user in their language and **wait for a clear yes**. Do not run a single command until they confirm.

> ⚠️ Heads up before I set this up: connecting your **personal** WhatsApp this way is **unofficial and against WhatsApp's terms of service**. It can get your number **temporarily or permanently banned**, and your WhatsApp login (plus recent message text) will be stored as files in this workspace. I'll keep it low-volume and only send exactly what you ask me to. **Do you want to proceed at your own risk?**

If they hesitate or say no, stop and point them at the Business path. Only on an explicit yes do you continue to step 1.

### 1. Install (once)

```bash
mkdir -p scripts/whatsapp/lib scripts/whatsapp/personal scripts/whatsapp/media && cd scripts/whatsapp
[ -d node_modules/baileys ] || { npm init -y >/dev/null 2>&1; npm install baileys@7.0.0-rc13; }
# voice-note transcription (local, no API key). Skip if you don't need audio.
python3 -c "import faster_whisper" 2>/dev/null || pip install --user faster-whisper
```

Text messaging needs no native modules — `baileys`'s crypto ships as portable WASM, and `sharp`/`jimp` (media only) are optional peers you can ignore. For voice notes, `faster-whisper` transcribes locally — it needs **no ffmpeg binary** (it decodes Opus through the bundled PyAV) and **no API key**; the model (~140 MB, `base`) downloads once into `~/.cache` and, like `node_modules`, lives on the persistent home volume so it survives container recycles. Set `WHISPER_MODEL` (e.g. `small`) for higher fidelity or `WHISPER_LANGUAGE` to force one language.

### 2. Write the helper + scripts (once)

The `@plank-integration` headers are **optional** — anything under `scripts/<provider>/<service>/` is discovered from its path alone — but keep them: they give the sidebar a real name and description instead of a bare filename.

**`scripts/whatsapp/lib/wa.mjs`** — shared connect + media download + the on-disk stores. The connect helper runs in **full-history mode** and always announces the **live** WhatsApp Web version (a stale version is closed with `405`), keeps a `getMessage` cache from the stored protobuf (needed to decrypt media and paginate history), and the store now keeps each message's **key + protobuf** so media can be fetched after the fact:

```javascript
import { fileURLToPath } from 'node:url'
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs'

import makeWASocket, { useMultiFileAuthState, fetchLatestBaileysVersion, Browsers, DisconnectReason, downloadMediaMessage, extensionForMediaMessage, normalizeMessageContent, proto } from 'baileys'
import { Boom } from '@hapi/boom'
import pino from 'pino'

// Atomic write: write a temp file then rename (atomic on the same fs), so a
// concurrent reader never sees a torn file and a crash can't half-write a store.
function atomicWrite(file, data) { const tmp = file + '.tmp'; writeFileSync(tmp, data); renameSync(tmp, file) }

// Paths resolve from THIS file, so the working directory never matters.
export const AUTH_DIR   = fileURLToPath(new URL('../auth', import.meta.url))
export const CHATS_FILE = fileURLToPath(new URL('../chats.json', import.meta.url))
export const MESSAGES_FILE = fileURLToPath(new URL('../messages.json', import.meta.url))
export const MEDIA_DIR  = fileURLToPath(new URL('../media', import.meta.url))
const MEDIA_CAP_BYTES = 25 * 1024 * 1024   // skip downloads bigger than this (matches Plank's upload cap)

const messageStoreKey = (key) => [key?.remoteJid || '', key?.fromMe ? '1' : '0', key?.id || '', key?.participant || ''].join('|')
const decodeMessage = (encoded) => {
  try { return proto.Message.decode(Buffer.from(encoded, 'base64')) }
  catch { return undefined }
}
const toTs = (v) => typeof v === 'number' ? v : (typeof v?.toNumber === 'function' ? v.toNumber() : Number(v) || 0)

// WhatsApp closes the socket with 405 if the client announces an outdated web
// version, and baileys' built-in default goes stale within weeks. Fetch the live
// one (cached per process). EVERY socket must pass it - pairing AND read/send;
// passing it only in pair.mjs makes linking work and every later read fail.
let cachedVersion = null
const waVersion = async () => {
  if (!cachedVersion) ({ version: cachedVersion } = await fetchLatestBaileysVersion())
  return cachedVersion
}

// One connection. Reconnects in-process on the normal post-pairing 515
// (restartRequired) instead of failing. NEVER run two of these at once on the
// same auth — WhatsApp drops the linked device (conflict / device_removed).
// onSocket fires before 'open' so callers catch the offline message flush.
export async function connect({ onQR, onSocket, historySync = false } = {}) {
  const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR)
  const version = await waVersion()
  const logger = pino({ level: 'silent' })
  const runtimeMessages = new Map()
  for (const row of loadMessages()) {
    if (row.key && row.message_b64) runtimeMessages.set(messageStoreKey(row.key), row.message_b64)
  }
  return await new Promise((resolve, reject) => {
    let settled = false
    // The offline queue (everything sent while this device was disconnected) is
    // flushed AFTER 'open' and ends with receivedPendingNotifications:true.
    // Callers wait for that signal instead of guessing with a timer.
    let pendingNotificationsReceived = false
    const pendingWaiters = new Set()
    const timer = setTimeout(() => { if (!settled) { settled = true; reject(new Error('CONNECT_TIMEOUT')) } }, 60000)
    const open = () => {
      const sock = makeWASocket({
        version,
        auth: state,
        logger,
        browser: Browsers.ubuntu('Chrome'),
        printQRInTerminal: false,
        // Only OVERRIDE history-sync handling when we actually want the dump.
        // Passing shouldSyncHistoryMessage: () => false on an ordinary read
        // switches off baileys' own history processing for that connection.
        ...(historySync ? { syncFullHistory: true, shouldSyncHistoryMessage: () => true } : {}),
        markOnlineOnConnect: false,
        enableRecentMessageCache: true,
        getMessage: async (key) => decodeMessage(runtimeMessages.get(messageStoreKey(key))),
      })
      sock.ev.on('creds.update', saveCreds)
      if (onSocket) onSocket(sock)
      sock.ev.on('connection.update', (u) => {
        const { connection, lastDisconnect, qr, receivedPendingNotifications } = u
        if (qr && onQR) onQR(qr)
        if (receivedPendingNotifications) {
          pendingNotificationsReceived = true
          for (const done of pendingWaiters) done(true)
          pendingWaiters.clear()
        }
        if (connection === 'open' && !settled) {
          settled = true
          clearTimeout(timer)
          resolve({
            sock,
            saveCreds,
            rememberMessage: (message) => {
              if (message?.key && message?.message) {
                runtimeMessages.set(messageStoreKey(message.key), Buffer.from(proto.Message.encode(message.message).finish()).toString('base64'))
              }
            },
            // Resolves true when WhatsApp says the offline queue is drained,
            // false on timeout (queue still running -> run the read again).
            waitForPendingNotifications: (timeoutMs = 60000) => {
              if (pendingNotificationsReceived) return Promise.resolve(true)
              return new Promise((done) => {
                const finish = (received) => {
                  clearTimeout(timeout)
                  pendingWaiters.delete(finish)
                  done(received)
                }
                const timeout = setTimeout(() => finish(false), timeoutMs)
                pendingWaiters.add(finish)
              })
            },
          })
        }
        if (connection === 'close') {
          const code = new Boom(lastDisconnect?.error)?.output?.statusCode
          if (code === DisconnectReason.restartRequired) return open()
          if (settled) return
          settled = true; clearTimeout(timer)
          reject(new Error(code === DisconnectReason.loggedOut ? 'LOGGED_OUT' : 'CLOSED_' + code))
        }
      })
    }
    open()
  })
}

// a full jid (group "…@g.us" or "…@s.whatsapp.net") passes through untouched
export const jidOf = (num) => String(num).includes('@') ? String(num) : String(num).replace(/[^0-9]/g, '') + '@s.whatsapp.net'

// ── media ────────────────────────────────────────────────────────────────
// WhatsApp media (voice notes, images, video, documents) arrives as an
// encrypted node with a mediaKey + directPath; the bytes are NOT in the
// message. mediaNodeOf() finds the media node, and downloadMedia() fetches +
// decrypts it to media/ so the agent can view an image, read a document, or
// transcribe a voice note. Stickers are recognised but not downloaded (low value).
const PLACEHOLDER = { audio: '[audio]', image: '[image]', video: '[video]', document: '[document]', sticker: '[sticker]' }

// normalizeMessageContent unwraps ephemeral ("disappearing") and view-once
// envelopes. Without it those messages look empty: no text, no media node.
export function mediaNodeOf(msg) {
  const content = normalizeMessageContent(msg) || msg
  if (!content) return null
  if (content.audioMessage) return { kind: 'audio', node: content.audioMessage }
  if (content.imageMessage) return { kind: 'image', node: content.imageMessage }
  if (content.videoMessage) return { kind: 'video', node: content.videoMessage }
  if (content.documentMessage) return { kind: 'document', node: content.documentMessage }
  if (content.documentWithCaptionMessage?.message?.documentMessage) return { kind: 'document', node: content.documentWithCaptionMessage.message.documentMessage }
  if (content.stickerMessage) return { kind: 'sticker', node: content.stickerMessage }
  return null
}

// Human-readable text for a message: real text/caption if present, else a typed
// placeholder so the row is never dropped by the "no text" guard.
export function messageText(msg) {
  const content = normalizeMessageContent(msg) || msg
  const t = content?.conversation
    || content?.extendedTextMessage?.text
    || content?.imageMessage?.caption
    || content?.videoMessage?.caption
    || content?.documentMessage?.caption
    || content?.documentWithCaptionMessage?.message?.documentMessage?.caption
  if (t) return t
  const media = mediaNodeOf(msg)
  if (media?.kind === 'document') return '[document: ' + (media.node.fileName || '') + ']'
  if (media) return PLACEHOLDER[media.kind]
  return null
}

export const cleanKey = (key) => Object.fromEntries(Object.entries({
  remoteJid: key?.remoteJid,
  remoteJidAlt: key?.remoteJidAlt,
  fromMe: !!key?.fromMe,
  id: key?.id,
  participant: key?.participant,
  participantAlt: key?.participantAlt,
  addressingMode: key?.addressingMode,
}).filter(([, v]) => v !== undefined && v !== null && v !== ''))

// Turn a WAMessage into a store row (no network). We keep the full protobuf
// (message_b64) + key so media can be downloaded later and history paginated.
export function captureMessage(m, source = 'realtime') {
  const msg = m.message || {}
  const text = messageText(msg)
  if (!text) return null
  const media = mediaNodeOf(msg)
  const id = m.key?.id || null
  return {
    from: m.key?.remoteJid,
    fromMe: !!m.key?.fromMe,
    text,
    t: toTs(m.messageTimestamp),
    source,
    key: cleanKey(m.key),
    message_b64: Buffer.from(proto.Message.encode(msg).finish()).toString('base64'),
    ...(id ? { id } : {}),
    ...(m.pushName ? { push_name: m.pushName } : {}),
    ...(media && media.kind !== 'sticker' ? { media_type: media.kind, mimetype: media.node.mimetype || '' } : {}),
    ...(media?.node?.fileName ? { file_name: media.node.fileName } : {}),
    ...(media?.node?.fileLength ? { file_size: Number(media.node.fileLength) || 0 } : {}),
  }
}

// Rebuild a WAMessage from a stored row so media can be fetched after the fact.
export function reconstructMessage(row) {
  const message = decodeMessage(row.message_b64)
  if (!message) return null
  return { key: row.key, message, messageTimestamp: row.t, pushName: row.push_name }
}

// Download + decrypt the media of one WAMessage into media/. Returns the fields
// to merge onto the row ({media_path,…} on success, {media_error|media_skipped}
// otherwise). Never throws.
export async function downloadMedia(sock, m, logger) {
  const media = mediaNodeOf(m?.message)
  if (!media || media.kind === 'sticker') return null
  const size = Number(media.node.fileLength) || 0
  if (size && size > MEDIA_CAP_BYTES) return { media_skipped: 'too large (' + Math.round(size / 1e6) + 'MB)' }
  try {
    mkdirSync(MEDIA_DIR, { recursive: true })
    // extensionForMediaMessage throws on a node with no mimetype (common in the
    // offline/history flush), so guard it and fall back to the mimetype/kind.
    let ext
    try { ext = extensionForMediaMessage(m.message) } catch { ext = null }
    if (!ext) ext = String(media.node.mimetype || '').split(';')[0].split('/')[1]
      || ({ audio: 'ogg', image: 'jpg', video: 'mp4', document: 'bin' })[media.kind] || 'bin'
    const id = m.key?.id || String(toTs(m.messageTimestamp) || 0)
    const t = toTs(m.messageTimestamp) || 0
    const filename = (t || 'x') + '-' + String(id).replace(/[^a-zA-Z0-9_-]/g, '') + '.' + ext
    const abs = MEDIA_DIR + '/' + filename
    const buffer = await downloadMediaMessage(m, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage })
    writeFileSync(abs, buffer)
    return { media_type: media.kind, mimetype: media.node.mimetype || '', media_path: 'scripts/whatsapp/media/' + filename, media_absolute_path: abs }
  } catch (e) {
    return { media_error: e?.message || String(e) }
  }
}

// ── message store ──────────────────────────────────────────────────────────
// The offline flush delivers each message to a linked device ONCE; a later
// connect won't resend it. So reads accumulate into messages.json (merge-only,
// pruned to keepDays) — that is what makes "messages from today" survive a
// second read in the same day.
export function loadMessages() {
  try { const r = JSON.parse(readFileSync(MESSAGES_FILE, 'utf8')); return Array.isArray(r) ? r : [] }
  catch { return [] }
}
export function saveMessages(existing, incoming, keepDays = 7) {
  const cutoff = Math.floor(Date.now() / 1000) - keepDays * 86400
  const map = new Map()
  for (const m of [...existing, ...incoming]) {
    if (!m || !m.text) continue
    if (m.t && m.t < cutoff) continue
    const row = {
      from: m.from,
      fromMe: !!m.fromMe,
      text: m.text,
      t: m.t || 0,
      ...(m.id ? { id: m.id } : {}),
      ...(m.key ? { key: m.key } : {}),
      ...(m.message_b64 ? { message_b64: m.message_b64 } : {}),
      ...(m.push_name ? { push_name: m.push_name } : {}),
      ...(m.source ? { source: m.source } : {}),
      ...(m.media_type ? { media_type: m.media_type } : {}),
      ...(m.media_path ? { media_path: m.media_path } : {}),
      ...(m.mimetype ? { mimetype: m.mimetype } : {}),
      ...(m.file_name ? { file_name: m.file_name } : {}),
      ...(m.file_size ? { file_size: m.file_size } : {}),
      ...(m.transcript ? { transcript: m.transcript } : {}),
      ...(m.language ? { language: m.language } : {}),
      ...(m.media_error ? { media_error: m.media_error } : {}),
      ...(m.media_skipped ? { media_skipped: m.media_skipped } : {}),
      ...(m.transcription_error ? { transcription_error: m.transcription_error } : {}),
    }
    map.set(m.id ? `${m.from}|${m.id}` : `${m.from}|${m.t}|${m.text}`, row)
  }
  const out = [...map.values()].sort((a, b) => (a.t || 0) - (b.t || 0))
  if (out.length) atomicWrite(MESSAGES_FILE, JSON.stringify(out, null, 2))
  return out
}

// ── chat index ────────────────────────────────────────────────────────────
// WhatsApp only streams the chat list on the FIRST connect after pairing; on a
// reconnect it says "skipping history sync" and sends nothing. baileys 7 also
// removed the in-memory store. So archive state has to be CACHED on disk and
// refreshed opportunistically whenever chats do arrive.

export function loadChatIndex() {
  try {
    if (!existsSync(CHATS_FILE)) return new Map()
    const raw = JSON.parse(readFileSync(CHATS_FILE, 'utf8'))
    return new Map((Array.isArray(raw) ? raw : []).map(c => [c.id, c]))
  } catch { return new Map() }
}

// Merge-only. An empty/failed sync must NEVER wipe a good index, so entries are
// updated field-by-field and existing ones are kept when the sync is silent.
export function saveChatIndex(existing, incoming) {
  const merged = new Map(existing)
  for (const [id, c] of incoming) {
    const prev = merged.get(id) || {}
    merged.set(id, {
      id,
      name:     c.name ?? prev.name ?? null,
      archived: c.archived ?? prev.archived ?? null,
      lastMsg:  c.lastMsg ?? prev.lastMsg ?? 0,
      unread:   c.unread  ?? prev.unread  ?? 0,
      seenAt:   c.archived !== undefined || c.name !== undefined ? Math.floor(Date.now() / 1000) : (prev.seenAt ?? 0),
      ...(c.historyAnchor || prev.historyAnchor ? { historyAnchor: c.historyAnchor ?? prev.historyAnchor } : {}),
    })
  }
  if (merged.size === 0) return merged          // nothing known: don't write an empty file
  atomicWrite(CHATS_FILE, JSON.stringify([...merged.values()], null, 2))
  return merged
}

// Collect chats from every event that carries them, plus a best-effort
// app-state resync (that is what actually carries archive/pin/mute flags).
export function attachChatCollector(sock, into) {
  const add = (c) => {
    if (!c || !c.id) return
    const prev = into.get(c.id) || {}
    into.set(c.id, {
      ...prev,
      id: c.id,
      ...(c.name !== undefined ? { name: c.name } : {}),
      ...(c.archived !== undefined ? { archived: !!c.archived } : {}),
      ...(c.conversationTimestamp !== undefined ? { lastMsg: Number(c.conversationTimestamp) || 0 } : {}),
      ...(c.unreadCount !== undefined ? { unread: c.unreadCount || 0 } : {}),
      ...(() => {
        const anchor = c.messages?.[c.messages.length - 1]?.message
        if (!anchor?.key?.id || !anchor?.messageTimestamp) return {}
        return {
          historyAnchor: {
            key: Object.fromEntries(Object.entries(anchor.key).filter(([, value]) => value !== undefined && value !== null && value !== '')),
            t: typeof anchor.messageTimestamp?.toNumber === 'function' ? anchor.messageTimestamp.toNumber() : Number(anchor.messageTimestamp) || 0,
          },
        }
      })(),
    })
  }
  sock.ev.on('messaging-history.set', ({ chats }) => (chats || []).forEach(add))
  sock.ev.on('chats.upsert', (chats) => (chats || []).forEach(add))
  sock.ev.on('chats.update', (chats) => (chats || []).forEach(add))
  sock.ev.on('chats.set', ({ chats }) => (chats || []).forEach(add))
}

export async function tryResync(sock) {
  try {
    await sock.resyncAppState(['regular_high', 'regular_low', 'regular'], false)
    return 'ok'
  } catch (e) { return 'failed:' + (e?.message || 'unknown') }
}
```

**`scripts/whatsapp/personal/pair.mjs`** — link the account (run once, ever). It links in **full-history mode** and **captures the initial history dump** — up to ~a week of chats and messages that WhatsApp streams only in the seconds right after a fresh link — into `messages.json` + `chats.json`. This is the one and only chance to get old history; a later read cannot ask for it again:

```javascript
// @plank-integration
// provider: whatsapp
// service: personal
// name: Link WhatsApp (one-time)
// description: One-time pairing of the user's personal WhatsApp number. Prints PAIRING_CODE, then LINKED. Captures the initial history dump (up to a week of chats + messages) that WhatsApp sends ONLY right after a fresh link. Run only when logged out.

import { fileURLToPath } from 'node:url'
import makeWASocket, { useMultiFileAuthState, fetchLatestBaileysVersion, Browsers, DisconnectReason } from 'baileys'
import { Boom } from '@hapi/boom'
import pino from 'pino'
import { captureMessage, attachChatCollector, loadChatIndex, saveChatIndex, loadMessages, saveMessages, tryResync } from '../lib/wa.mjs'

const PHONE = process.argv[2]              // digits only, with country code e.g. 77011234567
if (!PHONE) { console.error('usage: node pair.mjs <phone-digits>'); process.exit(1) }
const AUTH_DIR = fileURLToPath(new URL('../auth', import.meta.url))
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR)
const { version } = await fetchLatestBaileysVersion()
const logger = pino({ level: 'silent' })

// Full history is delivered by WhatsApp ONCE, in the seconds after a fresh link,
// via messaging-history.set — there is no way to ask for it again on an
// established session. So pairing collects that dump (with full keys + protobuf)
// into the same stores read.mjs uses; the next read fetches media + transcribes.
const chatsLive = new Map()
const captured = []
const seen = new Set()
const collect = (m, source) => {
  const e = captureMessage(m, source)
  if (!e) return
  const k = e.id || (e.from + '|' + e.t + '|' + e.text)
  if (seen.has(k)) return
  seen.add(k)
  captured.push(e)
}

let asked = false, linked = false, done = false
let quietTimer = null
function persistAndExit() {
  if (done) return
  done = true
  clearTimeout(quietTimer)
  const stored = saveMessages(loadMessages(), captured)
  const index = saveChatIndex(loadChatIndex(), chatsLive)
  console.error('HISTORY captured=' + captured.length + ' stored=' + stored.length + ' chats=' + index.size)
  console.log('LINKED')
  process.exit(0)
}
// Finish 20s after the history STOPS flowing. Do NOT start this countdown at
// 'open': WhatsApp often begins streaming well after the connection opens, so
// exiting at open+20s captures nothing - and the dump is a one-time delivery.
let sawHistory = false
const bumpQuiet = () => { if (!linked) return; sawHistory = true; clearTimeout(quietTimer); quietTimer = setTimeout(persistAndExit, 20000) }

function start() {
  const sock = makeWASocket({
    version,
    auth: state,
    logger,
    browser: Browsers.ubuntu('Chrome'),       // MUST be a real browser name: 'Desktop' is refused at REGISTRATION
    printQRInTerminal: false,
    syncFullHistory: true,
    shouldSyncHistoryMessage: () => true,
    markOnlineOnConnect: false,
  })
  sock.ev.on('creds.update', saveCreds)
  attachChatCollector(sock, chatsLive)
  sock.ev.on('messages.upsert', ({ messages }) => (messages || []).forEach((m) => collect(m, 'realtime')))
  sock.ev.on('messaging-history.set', (u) => {
    for (const m of u.messages || []) collect(m, 'history_sync')
    bumpQuiet()
  })
  sock.ev.on('connection.update', async (u) => {
    const { connection, lastDisconnect } = u
    if (connection === 'connecting' && !asked && !state.creds.registered) {
      asked = true
      await new Promise(r => setTimeout(r, 3500))
      console.log('PAIRING_CODE=' + await sock.requestPairingCode(PHONE))   // give this to the user
    }
    if (connection === 'open' && !linked) {
      linked = true
      console.error('OPEN - holding for the history dump')
      await tryResync(sock).catch(() => {})     // pulls archive/name flags into the chat index
      // NB: no bumpQuiet() here - only a real history batch starts the countdown.
      setTimeout(() => { if (!sawHistory) { console.error('NO_HISTORY after 180s'); persistAndExit() } }, 180000)
      setTimeout(persistAndExit, 420000)        // hard cap so we never hang if history trickles forever
    }
    if (connection === 'close') {
      const code = new Boom(lastDisconnect?.error)?.output?.statusCode
      if (code === DisconnectReason.restartRequired) return start()        // normal after the code is entered
      if (linked) return persistAndExit()        // benign close after we already have the dump
      console.error('pairing failed, status ' + code); process.exit(1)
    }
  })
}
start()
setTimeout(() => { if (!linked) { console.error('pairing window closed without linking'); process.exit(2) } }, 180000)
```

**`scripts/whatsapp/personal/read.mjs`** — read recent messages and **download their media** (voice notes → transcribed, images/video/documents → saved to `media/`, oversized skipped). It also **backfills** media for older stored rows that were captured without their files (e.g. at pairing). Skips archived chats by default; accepts `--include-archived`, `--only <name|jid>`, `--since <hours>` (default 24), `--limit <n>`, `--sync-history`, `--history-days <n>`. The settle window is 18s (set `WA_SETTLE_MS` to wait longer when a big flush is expected):

```javascript
// @plank-integration
// provider: whatsapp
// service: personal
// name: Read WhatsApp messages
// description: Reads recent personal WhatsApp messages to scripts/whatsapp/last-read.json (a rolling store lives in messages.json). Waits for the Baileys offline queue and saves each batch as it arrives. Downloads media (voice notes, images, video, documents) to scripts/whatsapp/media/ and transcribes voice notes. Skips archived chats by default. Flags: --include-archived, --only <name|jid>, --since <hours>, --limit <n>, --sync-history, --history-days <n>, --skip-media. Run only one WhatsApp script at a time.
// requires:
//   - file: scripts/whatsapp/auth/creds.json

import { execFile } from 'node:child_process'
import { writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { proto } from 'baileys'
import pino from 'pino'
import {
  connect, tryResync,
  loadChatIndex, saveChatIndex,
  loadMessages, saveMessages,
  captureMessage, reconstructMessage, downloadMedia,
} from '../lib/wa.mjs'

const argv = process.argv.slice(2)
const flag = (n) => argv.includes(n)
const val  = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const includeArchived = flag('--include-archived')
const only  = val('--only', null)?.toLowerCase() || null
const _sinceRaw = val('--since', '24')                 // hours; default 24h. Explicit '0' = no cutoff.
const since = _sinceRaw === '0' ? 0 : (Number(_sinceRaw) > 0 ? Number(_sinceRaw) : 24)  // bad input falls back to 24, never opens the window
const limit = Number(val('--limit', 100)) || 100
const syncHistory = flag('--sync-history')
const skipMedia = flag('--skip-media')                 // backlog runs: never let one attachment kill the sync
const historyDays = Math.max(1, Number(val('--history-days', '7')) || 7)
const historyPageSize = 50
const historyMaxPagesPerChat = 20
const backfillMax = 60                                 // cap media backfill per run so a big history can't stall a read
const pendingTimeoutMs = Math.max(5000, Number(process.env.WA_PENDING_TIMEOUT_MS) || 60000)  // how long to wait for the offline queue to drain
const settleMs = Math.max(2000, Number(process.env.WA_SETTLE_MS) || 5000)   // small grace after the queue signal / after resync
const OUT   = fileURLToPath(new URL('../last-read.json', import.meta.url))
const TRANSCRIBE = fileURLToPath(new URL('../lib/transcribe-audio.py', import.meta.url))
const execFileAsync = promisify(execFile)
const logger = pino({ level: 'silent' })
const existing = loadMessages()
const toTimestamp = (value) => {
  if (typeof value === 'number') return value
  if (typeof value?.toNumber === 'function') return value.toNumber()
  return Number(value) || 0
}
const isPrivateChat = (jid) => String(jid || '').endsWith('@s.whatsapp.net') || String(jid || '').endsWith('@lid')

const fresh = []
const liveMsgs = new Map()                              // id -> the live WAMessage, so we can download its media
const seen = new Set()
let rememberMessage = () => {}
const grab = (m, source = 'realtime') => {
  const id = m.key?.id || null
  // a message id is unique per CHAT, not globally - dedupe on the whole key
  const seenKey = [m.key?.remoteJid || '', m.key?.fromMe ? '1' : '0', id || '', m.key?.participant || ''].join('|')
  if (id && seen.has(seenKey)) return
  if (id) seen.add(seenKey)
  const entry = captureMessage(m, source)
  if (!entry) return
  fresh.push(entry)
  if (id) liveMsgs.set(id, m)
  rememberMessage(m)
}

const chatsLive = new Map()
const historyWaiters = new Map()
const earlyHistory = new Map()
let activeHistoryRequest = null
const connection = await connect({ historySync: syncHistory, onSocket: (s) => {
  // type 'append' is the offline queue being flushed; 'notify' is live traffic.
  // Persist after EVERY batch: a long flush that dies mid-way must not lose
  // what already arrived.
  s.ev.on('messages.upsert', ({ messages, type }) => {
    for (const m of messages || []) grab(m, type === 'append' ? 'offline' : 'realtime')
    saveMessages(existing, fresh, historyDays)
  })
  s.ev.on('messaging-history.set', (update) => {
    const isOnDemand = update.syncType === proto.HistorySync.HistorySyncType.ON_DEMAND
    for (const m of update.messages || []) grab(m, isOnDemand ? 'history_on_demand' : 'history_sync')
    if (isOnDemand) {
      const requestId = update.peerDataRequestSessionId || activeHistoryRequest
      const waiter = historyWaiters.get(requestId)
      if (waiter) waiter.push(update)
      else if (requestId) earlyHistory.set(requestId, [...(earlyHistory.get(requestId) || []), update])
    }
  })
  // chat index (names + archive flags + history anchors)
  const add = (c) => {
    if (!c || !c.id) return
    const prev = chatsLive.get(c.id) || {}
    chatsLive.set(c.id, {
      ...prev, id: c.id,
      ...(c.name !== undefined ? { name: c.name } : {}),
      ...(c.archived !== undefined ? { archived: !!c.archived } : {}),
      ...(c.conversationTimestamp !== undefined ? { lastMsg: Number(c.conversationTimestamp) || 0 } : {}),
      ...(c.unreadCount !== undefined ? { unread: c.unreadCount || 0 } : {}),
      ...(() => {
        const anchor = c.messages?.[c.messages.length - 1]?.message
        if (!anchor?.key?.id || !anchor?.messageTimestamp) return {}
        return { historyAnchor: { key: anchor.key, t: toTimestamp(anchor.messageTimestamp) } }
      })(),
    })
  }
  s.ev.on('messaging-history.set', ({ chats }) => (chats || []).forEach(add))
  s.ev.on('chats.upsert', (chats) => (chats || []).forEach(add))
  s.ev.on('chats.update', (chats) => (chats || []).forEach(add))
  s.ev.on('chats.set', ({ chats }) => (chats || []).forEach(add))
} })
const { sock } = connection
rememberMessage = connection.rememberMessage
// Wait for WhatsApp to say the offline queue is drained. A fixed timer here
// truncates the flush and reports count=0 - which reads as "no messages".
const pendingComplete = await connection.waitForPendingNotifications(pendingTimeoutMs)
await new Promise(r => setTimeout(r, settleMs))
const resyncStatus = await tryResync(sock)             // MUST run after 'open', not in onSocket
await new Promise(r => setTimeout(r, settleMs))        // let the resync mutations land (WA_SETTLE_MS)
const historyIndex = saveChatIndex(loadChatIndex(), chatsLive)

const historyStats = { chats: 0, pages: 0, messages: 0, timeouts: 0, errors: [] }
const waitForHistory = (requestId, timeoutMs = 30000, quietMs = 1500) => new Promise((resolve) => {
  const updates = []
  let quietTimer
  const finish = () => {
    clearTimeout(timeoutTimer)
    clearTimeout(quietTimer)
    historyWaiters.delete(requestId)
    resolve(updates)
  }
  const timeoutTimer = setTimeout(finish, timeoutMs)
  historyWaiters.set(requestId, {
    push: (update) => {
      updates.push(update)
      clearTimeout(quietTimer)
      quietTimer = setTimeout(finish, quietMs)
    },
  })
  for (const update of earlyHistory.get(requestId) || []) historyWaiters.get(requestId).push(update)
  earlyHistory.delete(requestId)
})

if (syncHistory) {
  const historyCutoff = Math.floor(Date.now() / 1000) - historyDays * 86400
  const byChat = new Map()
  for (const row of [...existing, ...fresh]) {
    if (!isPrivateChat(row.from) || !row.key?.id || !row.t) continue
    const rows = byChat.get(row.from) || []
    rows.push(row)
    byChat.set(row.from, rows)
  }
  for (const chat of historyIndex.values()) {
    const anchor = chat.historyAnchor
    if (!isPrivateChat(chat.id) || !anchor?.key?.id || !anchor.t) continue
    const rows = byChat.get(chat.id) || []
    rows.push({ from: chat.id, key: anchor.key, t: anchor.t })
    byChat.set(chat.id, rows)
  }

  for (const [jid, initialRows] of byChat) {
    let rows = initialRows
    let oldest = rows.reduce((a, b) => a.t <= b.t ? a : b)
    if (oldest.t <= historyCutoff) continue
    historyStats.chats++

    for (let page = 0; page < historyMaxPagesPerChat && oldest.t > historyCutoff; page++) {
      try {
        activeHistoryRequest = await sock.fetchMessageHistory(historyPageSize, oldest.key, oldest.t * 1000)
        const updates = await waitForHistory(activeHistoryRequest)
        activeHistoryRequest = null
        if (!updates.length) {
          historyStats.timeouts++
          break
        }

        const pageMessages = updates.flatMap((update) => update.messages || []).filter((m) => m.key?.remoteJid === jid)
        historyStats.pages++
        historyStats.messages += pageMessages.length
        const candidates = pageMessages
          .filter((m) => m.key?.id && toTimestamp(m.messageTimestamp))
          .map((m) => ({ key: m.key, t: toTimestamp(m.messageTimestamp) }))
        if (!candidates.length) break
        const nextOldest = candidates.reduce((a, b) => a.t <= b.t ? a : b)
        if (nextOldest.t >= oldest.t) break
        oldest = nextOldest
        rows = candidates
      } catch (error) {
        activeHistoryRequest = null
        historyStats.errors.push(`${jid}: ${error?.message || String(error)}`)
        break
      }
    }
  }
}

// ── download media (voice notes, images, video, documents) ──────────────────
// New arrivals this run come with a live WAMessage; older stored rows that were
// never fetched (e.g. captured at pairing) are rebuilt from their protobuf. Cap
// the backfill so a large history can't stall a routine read.
const toDownload = []
if (!skipMedia) {
  for (const entry of fresh) {
    if (entry.media_type && entry.media_type !== 'sticker' && !entry.media_path && !entry.media_error) {
      toDownload.push({ entry, m: liveMsgs.get(entry.id) || reconstructMessage(entry) })
    }
  }
}
let backfilled = 0
if (!skipMedia) {
  for (const row of existing) {
    if (backfilled >= backfillMax) break
    if (!row.media_type || row.media_type === 'sticker' || row.media_path || row.media_error || !row.message_b64) continue
    if (fresh.some((f) => f.id && f.id === row.id)) continue
    const m = reconstructMessage(row)
    if (!m) continue
    toDownload.push({ entry: row, m })
    backfilled++
  }
}
for (const { entry, m } of toDownload) {
  if (!m) { entry.media_error = 'no message to download from'; continue }
  const result = await downloadMedia(sock, m, logger)
  if (result) Object.assign(entry, result)
}

// ── transcribe voice notes (local faster-whisper, auto language) ────────────
const audio = skipMedia ? [] : [...fresh, ...existing].filter((m) => m.media_type === 'audio' && m.media_absolute_path && !m.transcript)
if (audio.length) {
  try {
    const { stdout } = await execFileAsync('python3', [TRANSCRIBE, ...audio.map((m) => m.media_absolute_path)], {
      maxBuffer: 16 * 1024 * 1024,
      timeout: 10 * 60 * 1000,
    })
    const transcripts = new Map(JSON.parse(stdout).map((item) => [item.path, item]))
    for (const entry of audio) {
      const result = transcripts.get(entry.media_absolute_path)
      if (result?.text) {
        entry.transcript = result.text
        entry.language = result.language
        entry.text = result.text
      } else if (result?.error) {
        entry.transcription_error = result.error
      }
    }
  } catch (error) {
    for (const entry of audio) entry.transcription_error = error?.message || String(error)
  }
}
for (const entry of [...fresh, ...existing]) delete entry.media_absolute_path

// persist: merge new arrivals into the rolling store + refresh the chat index
const stored = saveMessages(existing, fresh, historyDays)  // merge-only; prune window MUST match --history-days, or a backfill deletes itself
const index  = saveChatIndex(historyIndex, chatsLive)

// build the output view from the FULL store (so a repeat read still shows today)
const cutoff = since ? Math.floor(Date.now() / 1000) - since * 3600 : 0
let droppedArchived = 0, unknownArchive = 0
let out = stored.map(({ message_b64, ...m }) => {
  const meta = index.get(m.from)
  return { ...m, chat_name: meta?.name ?? null, archived: meta?.archived ?? null }
}).filter((m) => {
  if (cutoff && m.t < cutoff) return false
  if (only && !(String(m.from).toLowerCase().includes(only) || String(m.chat_name || '').toLowerCase().includes(only))) return false
  if (m.archived === null) unknownArchive++
  if (!includeArchived && m.archived === true) { droppedArchived++; return false }
  return true
}).sort((a, b) => a.t - b.t).slice(-limit)

writeFileSync(OUT, JSON.stringify(out, null, 2))
console.error('READ_OK count=' + out.length + ' newThisRun=' + fresh.length + ' mediaDownloaded=' + toDownload.length + ' backfilled=' + backfilled + ' stored=' + stored.length + ' droppedArchived=' + droppedArchived + ' archiveUnknown=' + unknownArchive + ' chatIndex=' + index.size + ' pendingNotifications=' + (pendingComplete ? 'complete' : 'timeout') + ' resync=' + resyncStatus + ' includeArchived=' + includeArchived + ' skipMedia=' + skipMedia + (only ? ' only=' + only : '') + ' sinceHours=' + since + ' history=' + JSON.stringify(historyStats))
process.exit(0)
```

**`scripts/whatsapp/personal/send.mjs`** — send exactly the message the user asked for. It reads the message **from a file**, not the command line, so quotes, apostrophes, `$`, and newlines in the text can't corrupt the shell command or inject anything:

```javascript
// @plank-integration
// provider: whatsapp
// service: personal
// name: Send WhatsApp message
// description: Sends one personal WhatsApp message. Usage: node send.mjs <number-digits|jid> <message-file>. Run only one WhatsApp script at a time.
// requires:
//   - file: scripts/whatsapp/auth/creds.json

import { readFileSync } from 'node:fs'
import { connect, jidOf } from '../lib/wa.mjs'
const [ , , num, file ] = process.argv
if (!num || !file) { console.error('usage: node send.mjs <number-digits|jid> <path-to-message-file>'); process.exit(1) }
const text = readFileSync(file, 'utf8')
const { sock } = await connect()
await sock.sendMessage(jidOf(num), { text })
console.log('SENT')
process.exit(0)
```

**`scripts/whatsapp/personal/list-chats.mjs`** — refresh the chat/archive index. Run it once after pairing (and occasionally after) so `read.mjs` knows which chats are archived:

```javascript
// @plank-integration
// provider: whatsapp
// service: personal
// name: List WhatsApp chats
// description: Refreshes the chat index (names, archived/unread) to scripts/whatsapp/chats.json and prints a summary. Run only one WhatsApp script at a time.
// requires:
//   - file: scripts/whatsapp/auth/creds.json

import { connect, attachChatCollector, tryResync, loadChatIndex, saveChatIndex } from '../lib/wa.mjs'
const chatsLive = new Map()
const { sock } = await connect({ onSocket: (s) => attachChatCollector(s, chatsLive) })
const resync = await tryResync(sock)                   // MUST run after 'open'
await new Promise(r => setTimeout(r, 15000))
const index = saveChatIndex(loadChatIndex(), chatsLive)
const all = [...index.values()]
const archived = all.filter(c => c.archived === true).length
const unknown  = all.filter(c => c.archived === null || c.archived === undefined).length
console.error('chats=' + all.length + ' archived=' + archived + ' archiveUnknown=' + unknown + ' learnedThisRun=' + chatsLive.size + ' resync=' + resync)
process.exit(0)
```

**`scripts/whatsapp/lib/transcribe-audio.py`** — transcribe voice notes locally (called by `read.mjs`; no API key, no ffmpeg):

```python
#!/usr/bin/env python3

# Transcribes WhatsApp voice notes locally with faster-whisper. No API key and
# no ffmpeg binary needed (faster-whisper decodes Opus via bundled PyAV). Prints
# a JSON array of {path, text, language} (or {path, error}) to stdout. The model
# and its cache live under ~/.local + ~/.cache on the persistent /home/coder
# volume, so they survive container recycles.

import json
import os
import sys

from faster_whisper import WhisperModel


def main():
    paths = sys.argv[1:]
    if not paths:
        print("[]")
        return

    model = WhisperModel(
        os.environ.get("WHISPER_MODEL", "base"),
        device="cpu",
        compute_type="int8",
        download_root=os.path.expanduser("~/.cache/faster-whisper"),
    )
    # Let whisper detect the language (users write in ru / kk / en). An explicit
    # hint can be forced with WHISPER_LANGUAGE for a mostly-one-language account.
    forced_language = os.environ.get("WHISPER_LANGUAGE") or None
    results = []
    for path in paths:
        try:
            segments, info = model.transcribe(
                path,
                language=forced_language,
                beam_size=5,
                vad_filter=True,
            )
            text = " ".join(segment.text.strip() for segment in segments).strip()
            results.append({"path": path, "text": text, "language": info.language})
        except Exception as error:
            results.append({"path": path, "error": str(error)})

    print(json.dumps(results, ensure_ascii=False))


if __name__ == "__main__":
    main()
```

### 3. Pair (once, with the user) — this is when history downloads

From the workspace root run `node scripts/whatsapp/personal/pair.mjs <the user's number>` and show the user the `PAIRING_CODE`. Tell them: open WhatsApp on the phone → **Settings → Linked Devices → Link a Device → Link with phone number instead**, and enter the code. Pairing then holds the connection open for the **initial history dump** — the 20-second quiet timer only starts once history actually begins arriving, so it waits rather than exiting empty — and prints `HISTORY captured=N …` followed by `LINKED`. The `auth/` folder is saved and you never pair again.

**This is the only moment a week of past history is downloadable.** WhatsApp streams the back-catalogue exactly once, right after a fresh link; there is no way to pull it retroactively on an already-linked session. So don't skip it — if the user wants their recent week analysed, it has to be captured here.

**That is the *history dump*, and it is not the same thing as missed messages.** Anything sent to the user *after* the link exists is queued for this device while it is disconnected and **is** delivered on the next connect — see "Catching up after a long gap" below. Never tell a user their messages are gone because the history dump can't be re-requested; those are two different mechanisms with opposite answers.

Enter the code promptly — it expires. `CLOSED_515` / "restart required" in the middle of this is **normal**; the script reconnects itself and then prints `LINKED`. Right after a successful link, run `node scripts/whatsapp/personal/list-chats.mjs` once to seed the chat index (names + which chats are archived), then `node scripts/whatsapp/personal/read.mjs` once to fetch + transcribe any media in the captured history.

### 4. Read / send (on demand)

**Run only one at a time.** Never start a read and a send — or two reads — together. Two live connections on the same login make WhatsApp drop the linked device (`conflict / device_removed`) and force a fresh pairing. Finish one command before you start the next. A routine read runs ~15-25s and is quiet while it works — that is normal, not a dead session; after a long gap it stays connected for as long as the offline queue keeps delivering.

- Read: `node scripts/whatsapp/personal/read.mjs` — waits for WhatsApp to report the offline queue drained (~20s normally, minutes after a gap), prints `READ_OK count=N pendingNotifications=complete|timeout …` to stderr, and writes the result to `scripts/whatsapp/last-read.json`. Open that file, then summarize by contact. Messages accumulate in `messages.json` (last 7 days), so a repeat read the same day still shows earlier messages.
  - **Archived chats are skipped by default.** Add `--include-archived` to include them.
  - Other flags: `--only <name-or-jid>` (one contact/group), `--since <hours>` (default 24), `--limit <n>`. `--sync-history --history-days 7` attempts on-demand history pagination on private chats (best effort; see the pairing note — this is not a substitute for capturing at link time).
  - **Media** — each row with a `media_path` has a real file under `scripts/whatsapp/media/`. **View images** with your file tools; **read documents**; for **voice notes** the `transcript` field already holds the text (the `text` field is replaced with it). `media_error` / `media_skipped` (e.g. "too large (…MB)", 25 MB cap) means no file — say so rather than inventing content.
  - A `remoteJid` ending `@g.us` is a group; `@s.whatsapp.net` is a 1:1 (the digits before `@` are the number). `archived: null` on a message means that chat isn't in the index yet — run `list-chats.mjs` once to refresh it.
  - **`pendingNotifications=` in the `READ_OK` line is the field to read before you say anything about how many messages there are.** `complete` = WhatsApp confirmed the queue is empty; `timeout` = the reader gave up while messages were still coming, so any count from that run is a floor, not a total. Raise `WA_PENDING_TIMEOUT_MS` (ms, default 60000) and run again.
  - If a big flush is expected (right after pairing, or after the user was offline for a while), give it room: `WA_PENDING_TIMEOUT_MS=300000 node scripts/whatsapp/personal/read.mjs …`.

**Catching up after a long gap.** If the linked device hasn't connected for days or weeks, the backlog arrives as a long series of `append` batches, and one run may not exhaust it. The recipe that recovered 6,687 messages (a month of backlog) on 2026-08-24:

```
WA_PENDING_TIMEOUT_MS=300000 node scripts/whatsapp/personal/read.mjs \
  --include-archived --since 24 --history-days 45 --limit 1000 --skip-media
```

- `--history-days` must cover the **age of the backlog**, not the window you want to report on. The store prunes to that many days on every write, so leaving it at 7 while a month-old queue is arriving deletes each batch as it lands — the run looks successful and the file stays empty.
- `--skip-media` for the catch-up passes. One attachment failing mid-flush used to end the whole process; skip the downloads while draining the queue, then do a normal read afterwards to fetch media for what you kept.
- Repeat while the line says `pendingNotifications=timeout`. Each pass picks up where the last one stopped, because every batch is written to `messages.json` as it arrives.
- Report progress from the store, not from one run's `count` — `messages.json` is the total, `last-read.json` is only the filtered view of the last read.
- Send: write the exact message to `scripts/whatsapp/msg.txt` **with your file-writing tool (not the shell** — that keeps quotes, apostrophes, `$`, and newlines intact), then run `node scripts/whatsapp/personal/send.mjs <number> scripts/whatsapp/msg.txt`. For a group, pass the full `<id>@g.us` jid instead of the number. **Confirm the exact wording first** for anything with a price, date, or commitment.

### When something breaks

- **The phone says the code is wrong / "couldn't link device", and the log shows `pairing failed, status 408`** → the socket announced a browser name WhatsApp won't register. `browser` must be a **real browser**, e.g. `Browsers.ubuntu('Chrome')`. A non-browser label such as `'Desktop'` is accepted when *reconnecting* an already-linked device but **refused at registration**, so an existing link keeps working while every new one fails. `requestPairingCode` is client-side, so a code is always produced — the refusal only ever shows up on the phone. QR fails the same way, which is the giveaway that it isn't code-specific.
- **`CLOSED_405` on every read/send, right after a pairing that succeeded** → the socket didn't pass `version`. baileys' built-in WA-Web version goes stale and WhatsApp closes the connection. Pass `await waVersion()` into **every** `makeWASocket` — not just the one in `pair.mjs`. This is not an expired session: `auth/` is intact and re-pairing won't help.
- `LOGGED_OUT` on every command → the link really was removed from the phone. Re-run pairing.
- `CONNECT_TIMEOUT` or a slow first command after a cold container is **not** an expired session — reconnect + sync just takes longer. Wait for the process to exit, then try again **once**. Never launch a second command while one is still running, and never tell the user the session expired unless the output literally says `LOGGED_OUT` or `device_removed`.
- `CLOSED_515` / "restart required" right after pairing is **normal** — the scripts above reconnect on their own. Don't treat it as a failure and don't re-pair.
- `conflict` / `device_removed`, or the phone shows **"couldn't link device"** → two connections ran at once, or you re-linked too soon after a failure. WhatsApp puts the account into a short **cooldown** on new linked devices — this is **not a ban**. Wait **30–60 minutes**, on the phone remove any stale **"Ubuntu / Chrome"** entry under *Linked Devices*, then pair **once**.
- `READ_OK count=0 newThisRun=0` on a working connection → **read `pendingNotifications=` before concluding anything.** `complete` means WhatsApp really had nothing queued for this device: widen with `--since` / `--include-archived` if you expected more. `timeout` means the opposite — the offline queue was still running when the reader stopped, and the zero is an artefact of stopping early. Raise `WA_PENDING_TIMEOUT_MS` and run again. **Never tell the user they have no messages off a `timeout` run**; that exact mistake reported an empty WhatsApp to a user who had 6,687 undelivered messages waiting.
- `READ_OK … newThisRun=487` (or any large number) but `count=0` and `messages.json` barely grew → the prune window is narrower than the age of what arrived. `--history-days` sets both the fetch window and the retention window; raise it to cover the backlog.
- `archiveUnknown` is high / `archived: null` on messages → the chat index is stale. Run `node scripts/whatsapp/personal/list-chats.mjs` (it does an app-state resync) to populate names + archive flags into `chats.json`. **Note:** the archived-skip only covers chats the index knows about; a message whose chat is unknown is **shown** (dropping unknowns would hide normal chats on a fresh setup). When `archiveUnknown` > 0, tell the user the archive filter was incomplete for that many messages — don't imply archived chats were fully excluded.
- `resync=failed:Connection Closed` → `resyncAppState` was called too early. It must run **after** `connect()` resolves (after `open`), never inside `onSocket`.
- `count=0` while the connection opens fine and you *just* sent messages → your own sent messages aren't re-flushed; that's expected. Don't read results from stdout — baileys logs there; use `last-read.json`.
- `history={"chats":0,…}` on `--sync-history` → expected on an **already-linked** session: WhatsApp only hands over old history right after a *fresh* link, so on-demand pagination has no anchor. This is not a bug — old history that wasn't captured at pairing is gone. Tell the user so instead of retrying. **This says nothing about missed messages**: anything sent while the device was disconnected still arrives through the offline queue, so don't quote this line as proof that a quiet period is empty.
- `media_error` on a media row → usually the media aged off WhatsApp's servers, or the node arrived without a mimetype. New arrivals download fine; a very old `[audio]`/`[image]` captured before this version has no key and can't be recovered. `media_skipped` "too large" is the 25 MB cap, not a failure.
- A voice note has no `transcript` (only `transcription_error`) → check `faster-whisper` is installed (`python3 -c "import faster_whisper"`); the first run downloads the model (~140 MB) and can take a minute. It needs no ffmpeg.
- `READ_OK … newThisRun=0` right after the user sent something → the send hadn't reached the linked device yet, or the reader stopped first. Have them wait until it shows in their chat, then read again (bump `WA_PENDING_TIMEOUT_MS`). Don't launch a second read while one is running.
- `Cannot find package 'baileys'` → the scripts were moved away from `scripts/whatsapp/node_modules`. Keep them under `scripts/whatsapp/` (Node resolves `node_modules` upward from each file).
- Nothing appears under **Integrations** → give it ~30s (the sidebar caches), then reload. Files must sit under `scripts/<provider>/<service>/` or carry a `@plank-integration` header.

See also: [Connecting WhatsApp Business](/help/connecting-whatsapp) for the sanctioned customer-line setup, [Workspace scripts](/help/workspace-scripts) for the sidebar convention, and [Automations](/help/automations) if you later want a scheduled morning read.
