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.
- 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 theclaudeCLI, posts the reply to the right chat. - Agent can output
SKIP_REPLYto stay silent; silence is always valid. - Agent has full
Read/Edit/Write/Bashtools 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).
- Any Linux host with:
- Node.js 20+ (22+ recommended).
- A working
claudeCLI (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.
<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
{
"dependencies": {
"dotenv": "^16",
"qrcode-terminal": "^0.12",
"whatsapp-web.js": "^1.34"
}
}# 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.
- Use
whatsapp-web.jsLocalAuthwithdataPath: state/session. - First run:
node agent/auth.jsprints a QR; scan with the agent's dedicated number (WhatsApp → Settings → Linked Devices → Link a device). - Important:
auth.jsmust handle SIGINT gracefully — callclient.destroy()before exiting so LocalAuth flushes the session keys to disk. If you kill the process abruptly right afterready, the Chrome profile may be half-written and subsequent runs will re-request a QR. - After auth,
index.jsuses 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.
- Acquire lockfile at
/tmp/<project>.lock(stale-PID recovery). If another run is in progress, exit silently. - Initialize WA client, wait for
readyevent (or 120s timeout → fail the run). - Load state from
state/last_run.json:{ watermarks: { [chatId]: unixTs }, last_run_ts }. - Load system prompt once per run: concatenate
soul/AGENTS.md,SOUL.md,IDENTITY.md,USER.md,MEMORY.md,contacts.mdwith# === NAME.md ===separators. - Fetch chats → filter to authorized.
- 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.
- 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.
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.
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-diris 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, concatenatetextblocks into the final reply, logtool_usecalls (and their key arg: file path for Edit/Read/Write, command for Bash), log theresultsummary (turns, duration).
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.
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 tostate/media/<id>.<ext>→ replace body with[image at <absolute-path>, caption: ...](or[sticker at ...]). The agent reads them using the built-inReadtool (which supports images). - Voice notes / audio: save to
state/media/<id>.ogg, replace body with[audio at <path>]. Document inAGENTS.mdhow the agent can transcribe viaBash(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.
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/Writetool calls.
Keep them all short. They're prepended to every prompt — token cost adds up.
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.
* * * * * /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.jsDefault watermark for first-seen chats: now - 60s. The lockfile prevents overlap if a run exceeds one minute.
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.
- WhatsApp auth failure → log + exit; cron retries next minute.
claudenon-zero exit or timeout → log stderr snippet, leave watermark un-advanced, retry next run.claudereturns rate-limit / error-looking text (matches/LLM request rejected/ietc.) → never forward to WhatsApp; retry next run.- Message send failure → catch, log, leave watermark un-advanced.
- One chat's failure never aborts the others.
node agent/auth.js→ scan QR with the agent's WA number → see "auth successful" → ctrl-C cleanly.- Authorized number sends a text DM to the agent.
node agent/index.jsruns, fetches the DM, callsclaude, gets a reply, sends it, exits cleanly — reply arrives on the sender's phone.state/last_run.jsonshows a watermark for that chat.
Ship that, then add groups, media, cron.
| 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 |
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.