Skip to content

Instantly share code, notes, and snippets.

@maraoz
Created April 21, 2026 00:26
Show Gist options
  • Select an option

  • Save maraoz/1840592c0671b6a9b9caeb789efaf5bc to your computer and use it in GitHub Desktop.

Select an option

Save maraoz/1840592c0671b6a9b9caeb789efaf5bc to your computer and use it in GitHub Desktop.

protoclaw — WhatsApp AI agent spec

Self-hosted WhatsApp AI agent that runs on a Linux box, polls for unread messages on a schedule, thinks with the Claude Code CLI, and replies (or stays silent). Personality, identity, and memory are loaded from markdown files at runtime; the agent can edit those files to persist learnings across runs.

This document is a reproduction spec: enough detail for a fresh Claude Code session to rebuild the project from scratch.


1. What it is

  • A cron-driven Node agent. Not a daemon.
  • Each run: connects to WhatsApp Web via whatsapp-web.js, fetches new messages from an allow-listed set of chats, assembles a prompt from markdown "soul" files + recent history, spawns the claude CLI, posts the reply to the right chat.
  • Agent can output SKIP_REPLY to stay silent; silence is always valid.
  • Agent has full Read/Edit/Write/Bash tools during its run and can update its own memory file, create new ones, or shell out to local tools (e.g. a speech-to-text binary for voice notes).

2. Hardware & runtime

  • Any Linux host with:
    • Node.js 20+ (22+ recommended).
    • A working claude CLI (Claude Code), authenticated. OAuth / subscription auth is fine; API-key auth works too.
    • Chromium dependencies for Puppeteer (installed transitively by whatsapp-web.js).
  • A dedicated WhatsApp phone number for the agent (not the owner's personal number) — you'll link it via QR.

3. Layout

<project-root>/
├── agent/
│   ├── index.js          # main entrypoint (cron calls this)
│   ├── auth.js           # one-off interactive QR-scan script
│   ├── whatsapp.js       # whatsapp-web.js client factory
│   ├── claude.js         # `claude` CLI subprocess wrapper
│   ├── media.js          # download/save incoming media
│   ├── soul.js           # reads system-prompt markdown files
│   ├── state.js          # per-chat watermarks
│   ├── config.js         # env + paths
│   ├── log.js            # tiny logger
│   └── run.sh            # cron wrapper
├── soul/
│   ├── AGENTS.md         # top-level prompt for the agent
│   ├── SOUL.md           # voice, personality, do/don'ts
│   ├── IDENTITY.md       # name, vibe, self-concept
│   ├── USER.md           # facts about the owner
│   └── MEMORY.md         # persistent memory (agent edits this)
├── contacts.md           # authorized numbers + groups (plaintext ref)
├── state/
│   ├── session/          # whatsapp-web.js LocalAuth data (gitignored)
│   ├── media/            # downloaded images/audio/etc (gitignored)
│   └── last_run.json     # per-chat watermark (gitignored)
├── logs/beto.log         # cron stdout (gitignored)
├── .env                  # AUTHORIZED_NUMBERS etc (gitignored)
├── .env.example          # committed template
├── package.json
└── .gitignore

4. Dependencies

{
  "dependencies": {
    "dotenv": "^16",
    "qrcode-terminal": "^0.12",
    "whatsapp-web.js": "^1.34"
  }
}

5. Environment (.env)

# Comma-separated phone numbers (country code, no +) allowed to DM the agent.
AUTHORIZED_NUMBERS=...

# Comma-separated WhatsApp group IDs (format: 1203...@g.us) allowed to read.
AUTHORIZED_GROUPS=...

MAX_HISTORY_MESSAGES=20

# Absolute path to the claude CLI.
CLAUDE_BIN=/path/to/claude

# Hard timeout per claude invocation, ms.
CLAUDE_TIMEOUT_MS=180000

LOG_LEVEL=info

Any chat not in AUTHORIZED_* is ignored entirely — the agent never reads or replies to it.


6. WhatsApp session

  • Use whatsapp-web.js LocalAuth with dataPath: state/session.
  • First run: node agent/auth.js prints a QR; scan with the agent's dedicated number (WhatsApp → Settings → Linked Devices → Link a device).
  • Important: auth.js must handle SIGINT gracefully — call client.destroy() before exiting so LocalAuth flushes the session keys to disk. If you kill the process abruptly right after ready, the Chrome profile may be half-written and subsequent runs will re-request a QR.
  • After auth, index.js uses the saved session headlessly.

Recommended Puppeteer args: --no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage --disable-gpu.

Pin the WA Web version via webVersionCache pointing at a wppconnect-team/wa-version snapshot — WA Web ships breaking changes frequently and unpinned versions silently break.


7. Core loop (index.js)

  1. Acquire lockfile at /tmp/<project>.lock (stale-PID recovery). If another run is in progress, exit silently.
  2. Initialize WA client, wait for ready event (or 120s timeout → fail the run).
  3. Load state from state/last_run.json: { watermarks: { [chatId]: unixTs }, last_run_ts }.
  4. Load system prompt once per run: concatenate soul/AGENTS.md, SOUL.md, IDENTITY.md, USER.md, MEMORY.md, contacts.md with # === NAME.md === separators.
  5. Fetch chats → filter to authorized.
  6. For each authorized chat:
    • Fetch the last N messages (see §8 for the critical workaround).
    • Filter to messages with ts > watermark && !fromMe.
    • If empty, bump watermark to the latest seen ts and continue.
    • For new messages with media, download/save them (see §10) and rewrite the body with a [image at <path>] marker.
    • Build chat-section prompt (chat name, type, full history, new messages flagged).
    • Spawn claude (§9). Capture stdout.
    • If output is empty or exactly SKIP_REPLY → log skip, bump watermark.
    • If output matches a claude rate-limit error pattern (e.g. /^LLM request rejected/i, /out of extra usage/i) → log, do not send, leave watermark un-advanced so it retries next cycle.
    • Else → send to the chat, wait for server ack (msg.ack ≥ 1) with a ~10s timeout, bump watermark.
  7. Release lock, client.destroy(), exit. Destroying before ack is what caused a "sent but never delivered" bug — don't skip the ack wait.

Per-chat failures are caught; they don't abort the whole run. The watermark is only advanced on success to prevent missing messages after a transient failure.


8. The fetchMessages gotcha (critical)

whatsapp-web.js's Chat.fetchMessages({ limit }) internally calls a WA Web routine that, on recent WA Web builds, fails with Cannot read properties of undefined (reading 'waitForChatLoading'). Both DMs and groups hit this.

Workaround: bypass fetchMessages and read the raw in-memory message store via pupPage.evaluate:

await client.pupPage.evaluate(async (chatId, limit) => {
  const wid = window.Store.WidFactory.createWid(chatId);
  const chat = window.Store.Chat.get(wid);
  if (!chat) return [];

  // Trigger WA's own message-loading path (doesn't hit the broken function).
  try { await window.Store.Cmd.openChatBottom({ chat }); } catch (_) {}

  // Give WA a beat to populate.
  for (let i = 0; i < 20; i++) {
    if (chat.msgs && chat.msgs.getModelsArray().length >= Math.min(limit, 3)) break;
    await new Promise(r => setTimeout(r, 150));
  }

  let msgs = chat.msgs.getModelsArray().filter(m => !m.isNotification);
  msgs.sort((a, b) => a.t > b.t ? 1 : -1);
  if (msgs.length > limit) msgs = msgs.slice(msgs.length - limit);
  return msgs.map(m => ({
    id: m.id?._serialized,
    timestamp: m.t,
    body: m.body || m.caption || '',
    fromMe: !!m.id?.fromMe,
    from: m.from?._serialized,
    author: m.author?._serialized,
    notifyName: m.notifyName,
    hasMedia: !!(m.mediaData || m.isMedia),
    type: m.type,
  }));
}, chatId, limit);

For re-hydrating a whatsapp-web.js Message instance (needed for downloadMedia()), use client.getMessageById(serialized) — that code path uses Store.Msg.get which is safe.


9. Invoking claude

Spawn the CLI as a subprocess:

claude --print \
       --dangerously-skip-permissions \
       --output-format stream-json \
       --verbose \
       --add-dir <project-root> \
       --append-system-prompt <systemPrompt>
  • Send the chat-section prompt via stdin. Do not pass it as a trailing argv — --add-dir is variadic and will eat it.
  • Strip any CLAUDECODE* / CLAUDE_CODE_* env vars from the child's environment (the outer shell is often itself a Claude Code session and leaks these; nested instances misbehave).
  • Stream-json output arrives line-by-line ({ type: "assistant" | "tool_use" | "text" | "result", ... }). Parse lines, concatenate text blocks into the final reply, log tool_use calls (and their key arg: file path for Edit/Read/Write, command for Bash), log the result summary (turns, duration).

Authorization format

Allow-list DMs by phone number after resolving @lid@c.us. Recent WhatsApp uses opaque Linked-ID @lid for DMs by default; chat.id.user is the lid integer, not the phone number. Resolve via const contact = await chat.getContact()contact.number — that's the real phone number. Match against AUTHORIZED_NUMBERS.

Similarly, when sending, if the chat's server is lid, resolve to <number>@c.us before calling client.sendMessage — sending to the @lid id can silently no-op.


10. Media handling

Text-only messages: pass the body straight through.

For messages with hasMedia: true, re-hydrate via client.getMessageById and:

  • Images / stickers: msg.downloadMedia() → save bytes to state/media/<id>.<ext> → replace body with [image at <absolute-path>, caption: ...] (or [sticker at ...]). The agent reads them using the built-in Read tool (which supports images).
  • Voice notes / audio: save to state/media/<id>.ogg, replace body with [audio at <path>]. Document in AGENTS.md how the agent can transcribe via Bash (e.g. ffmpeg + whisper-cli). Don't transcribe eagerly — let the agent decide when it's worth it.
  • Video / document: marker only ([video], [document: filename]). Don't download by default.

Store media under state/media/ (gitignored). The --add-dir <project-root> flag on claude lets the agent read them.


11. Soul files (the "brain")

The system prompt is a concatenation of markdown files in soul/:

  • AGENTS.md — top-level operational brief. Covers: how protoclaw works architecturally, how to decide whether to reply (groups vs DMs), voice rules, output format (SKIP_REPLY), media markers, memory-edit instructions, privacy rules (esp. in groups), forbidden actions.
  • SOUL.md — voice and values. Tone, register, forbidden verbal tics, when to swear, how to handle being wrong.
  • IDENTITY.md — name, species, vibe, emoji if any.
  • USER.md — facts about the owner (relevant for personalization).
  • MEMORY.md — persistent facts learned across runs. The agent edits this via its own Edit / Write tool calls.

Keep them all short. They're prepended to every prompt — token cost adds up.

Privacy rules in AGENTS.md

Spell out explicitly: in groups, treat all readers as strangers to the owner's private life. Never volunteer phone numbers, addresses, health details, travel plans, or infrastructure facts. When in doubt, stay silent.


12. Cron

* * * * * /path/to/project/agent/run.sh >> /path/to/project/logs/beto.log 2>&1

Every minute is fine — idle runs log nothing and claude is only invoked when a chat actually has new messages. run.sh:

#!/usr/bin/env bash
set -euo pipefail
cd /path/to/project
export PATH="$HOME/.local/bin:/path/to/node/bin:$PATH"
exec node /path/to/project/agent/index.js

Default watermark for first-seen chats: now - 60s. The lockfile prevents overlap if a run exceeds one minute.


13. Logging

Log design principle: idle runs are silent, active runs are compact.

On activity, emit:

── run
<chatName>: <N> new
  tool Edit /abs/path
  claude <turns>t <seconds>s
  reply: "<first 160 chars>"
  sent (<len>c, delivered)
── done (active=1 errors=0)

Skip "whatsapp ready" / "N authorized chats" / watermark / latest-msg spam in normal mode. Gate them behind a DEBUG=1 env var.

Don't log a cost number even if claude reports one — it's a reference metric and will scare people on subscription auth.


14. Error handling

  • WhatsApp auth failure → log + exit; cron retries next minute.
  • claude non-zero exit or timeout → log stderr snippet, leave watermark un-advanced, retry next run.
  • claude returns rate-limit / error-looking text (matches /LLM request rejected/i etc.) → never forward to WhatsApp; retry next run.
  • Message send failure → catch, log, leave watermark un-advanced.
  • One chat's failure never aborts the others.

15. First milestone (definition of "working")

  1. node agent/auth.js → scan QR with the agent's WA number → see "auth successful" → ctrl-C cleanly.
  2. Authorized number sends a text DM to the agent.
  3. node agent/index.js runs, fetches the DM, calls claude, gets a reply, sends it, exits cleanly — reply arrives on the sender's phone.
  4. state/last_run.json shows a watermark for that chat.

Ship that, then add groups, media, cron.


16. Gotchas summary (things that will bite you)

Symptom Cause Fix
QR keeps appearing after successful auth ctrl-C killed auth script before LocalAuth flushed Handle SIGINT, call client.destroy(), wait, then exit
fetchMessages errors with waitForChatLoading WA Web internal changed Bypass, read Store.Chat.msgs directly (§8)
"Input must be provided..." when calling claude trailing prompt arg eaten by variadic --add-dir Pass prompt via stdin
Reply "sent" but never arrives client.destroy() ran before WA socket flushed await waitForAck(msg) with short timeout
Authorized DMs not matching WA now uses @lid IDs Resolve via chat.getContact().number
Message sent to @lid silently no-ops WA routing quirk Send to <number>@c.us instead
Claude returns a support-URL error string subscription cap hit Guard with regex; don't forward
Double log lines logger wrote to file AND stdout while cron redirected stdout to same file Log to stdout only, let cron handle the file

17. License & ethics note

This is a personal tool. Treat WhatsApp's automation policies as a hard constraint if you care about the number not getting banned:

  • Use a dedicated number, not a shared one.
  • Don't send unsolicited messages.
  • Keep reply rates human (no burst-broadcasts).
  • Authorized-contacts allow-list is not optional — without it, a bot-style account sending to random contacts will get flagged fast.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment