1---
2name: whatsapp-group-agent
3description: Connect an AI agent to an existing WhatsApp group so it silently monitors messages, auto-creates tickets, files shared documents into a searchable library, and answers when mentioned. Use when the user wants a WhatsApp group bot, "make our WhatsApp group smart", WhatsApp-to-Linear/tickets automation, a group document-intake bot, or asks how to hook an agent/OpenClaw/WAHA into WhatsApp. Encodes a working architecture and every gotcha from a real production build.
4---
5# public variant, safe to publish — NOT auto-loaded (Claude Code reads only ../SKILL.md); keep in lockstep with ../SKILL.md: every gotcha edit lands in both
6
7Playbook for putting an AI agent inside an existing WhatsApp group. Built and
8verified on a real production founder group (reference implementation: OpenClaw
9running in a container on a VPS, driving a Linear workspace). Every rule below
10exists because its absence broke something. Replace every `<PLACEHOLDER>` with
11your own values.
12
13## Architecture decision (do not re-litigate without new facts)
14
15- **The official WhatsApp Cloud API cannot join an existing group.** Its
16 Groups API only CREATES business-owned invite-link groups (8-participant
17 cap). Verified against Meta docs 2026-06. Any solution that joins a real
18 group rides an unofficial linked-device client (Baileys) underneath.
19- **Winning stack:** OpenClaw (or WAHA as fallback) on a VPS + a NEW dedicated
20 real SIM/eSIM (never VoIP, WhatsApp blocks those; never your own personal
21 numbers) paired as a linked device + added to the group as a normal member.
22- **Ban risk is real and accepted:** the bot number is expendable. Keep a
23 spare SIM. All state (tickets, library, message log) lives outside WhatsApp,
24 so recovery = new SIM + re-pair (~15 min).
25- Cost: ~$10–30/mo (SIM $1–5 + model usage). Use a mid-tier model for the
26 agent (sonnet-class) with a cheap fallback.
27
28## Setup sequence
29
301. **Update OpenClaw first.** On a managed/prebuilt image the `latest` image
31 tag is often stale; the real install may be npm inside a persistent mount
32 (`npm install -g openclaw@latest` in the data home, e.g.
33 `/data/.npm-global`). WhatsApp is an external plugin now:
34 `openclaw plugins install clawhub:@openclaw/whatsapp`.
352. **Pair:** register WhatsApp on the bot number (a second account inside the
36 WhatsApp Business app coexists fine with a personal account on one phone),
37 then `openclaw channels login --channel whatsapp` and scan the QR.
38 If the terminal QR is unscannable through a chat UI, parse the ANSI
39 half-blocks and re-render as an image (approach: ▀▄█ chars → module matrix
40 → BMP → open in an image viewer).
413. **Group config** (`channels.whatsapp` in openclaw.json):
42 - `groupPolicy: "open"` + `groups: { "<GROUP_JID>": { requireMention: false } }`.
43 The `groups` map IS the per-group admission list ("DM + 1 configured
44 group" in the startup log confirms it). Do NOT use
45 `groupPolicy: "allowlist"`: in this plugin that gates by SENDER
46 (`groupAllowFrom`), not by group.
47 - Get the group JID WITHOUT opening admission (verified 2026-07-10, better
48 than the old temporarily-open dance): have someone send a message in the
49 group, then grep the CONTAINER FILE LOG — dropped inbounds are logged
50 there with their JID even though the docker console log shows nothing:
51 `grep -o "[0-9]\{15,\}@g\.us" /tmp/openclaw-<uid>/openclaw-*.log`
52 (inside the container). `openclaw directory groups list` only shows
53 already-configured groups, so it can't discover a new one.
54 - `reactionLevel: "minimal"` to enable agent-initiated reactions.
554. **Dedicated agent:** `agents.list` entry with its own workspace +
56 `bindings: [{ type: "route", agentId: ..., match: { channel: "whatsapp" } }]`.
57 `bindings[].match.peer` requires BOTH `kind` and `id`, so match on channel
58 only until the JID is known.
595. **Ticket/library tools:** small Python CLIs the agent calls via exec beat
60 MCP for reliability, and register Linear's remote MCP too
61 (`openclaw mcp set linear '{"url":"https://mcp.linear.app/mcp","transport":"streamable-http","headers":{"Authorization":"Bearer <LINEAR_API_KEY>"}}'`).
62 Reference CLIs from the build: `linear.py` (create/search/comment/
63 collection find-or-create/archive-done), `groupmem.py` (SQLite+FTS5 message
64 log), `library.py` (document intake + FTS index), `granola.py` (meeting
65 cache + project-scoped Q&A — see "Granola meeting access" below).
666. **Message-log hook (the safety net).** Register via
67 `hooks.internal.handlers: [{ event: "message", module: "hooks/group-logger/handler.js" }]`.
68 The module path resolves RELATIVE TO EACH AGENT WORKSPACE (put the file in
69 `<workspace>/hooks/...`). The HOOK.md `metadata.openclaw.events` route did
70 NOT bind in 2026.6.11. In the handler, insert on
71 `event.action === "preprocessed"` (fires once per admitted message;
72 "received" does not fire through this route) using `context.bodyForAgent`
73 (clean text), `context.senderName`, `context.groupId`.
747. **Daily sweep cron:** the agent can drop a run (known race: a message
75 arriving while the group session is initializing fails with "reply session
76 initialization conflicted"). Host cron runs
77 `openclaw agent --agent <id> --message "<reconcile prompt>"` daily to diff
78 the message log against tickets/library and file anything missed.
798. **Health cron:** restart the container ONLY when the gateway is
80 unreachable; "not linked" is a pairing state a restart cannot fix.
819. **Granola sync cron:** if meeting Q&A is wired (see below), host cron runs
82 `granola.py sync` every 30 minutes — separate from the daily sweep and
83 health cron. Agents never call `sync` inline; that eats request budget and
84 stalls a reply.
85
86## Granola meeting access (added 2026-07-20)
87
88Lets a group agent answer "what did we discuss in the meeting with X" from a
89local cache — the public API has no search, so agents read a synced SQLite
90cache, never the live API directly (except a one-off `get` on a known id).
91
92- **CLI:** `<data-home>/granola.py`, stdlib-only (urllib + sqlite3 — a
93 minimal container may not have `requests`). Subcommands:
94 `sync [--full] [--days N]`, `list --project P [--limit N] [--days N]`,
95 `search --query Q [--project P] [--limit N]`, `get --id ID [--transcript]`,
96 `unclassified [--limit N]`, `reclassify --id ID --project P`, `stats`.
97- **The public API surface is exactly three endpoints and nothing else:**
98 base `https://public-api.granola.ai`, `Authorization: Bearer
99 $GRANOLA_API_KEY`. `GET /v1/notes` (list), `GET /v1/notes/{id}[?include=
100 transcript]` (detail), `GET /v1/folders`. No server-side search, no
101 attendee filter, no free-text query — unknown query params are silently
102 ignored (200 OK, unfiltered) while bad values for KNOWN params 400. So
103 relevance filtering must happen client-side; that's why the cache exists.
104- **List items are stubs:** only id/title/owner/created_at/updated_at, no
105 summary/attendees/folder. You cannot classify off the list endpoint alone —
106 every note needs the per-note detail call.
107- **Folders are read-only via the API** — there is no write endpoint for
108 folders or folder membership, so you cannot auto-categorize inside Granola
109 itself. The project mapping has to live in your own store:
110 `granola-rules.json` next to the script, per project
111 `{domains, names, keywords}`, matched in priority order domains → names →
112 keywords.
113- **Keyword precision carries the classification.** Most notes are "solo
114 notes" with no attendees/calendar event populated, so attendee-domain
115 signals often don't fire. Loose keywords ("legal", "client", "consulting")
116 cause false positives from notetaker boilerplate — keep keywords
117 high-precision and let ambiguous meetings fall to a low-confidence `other`
118 bucket surfaced by `unclassified` for the daily sweep to review, rather
119 than guessing.
120- **No webhooks; polling is the only trigger.** `updated_after` drives
121 incremental sync, `page_size` maxes at 30 with cursor pagination, rate
122 limit is 25 req/5s burst / 5 req/s sustained. Never fetch transcripts in
123 bulk — only for a note a query has already judged relevant.
124- **Per-group scoping is mandatory, same discipline as `groupmem.py
125 --group <jid>` and the per-team Linear key prefix:** each group agent is
126 hard-scoped to its own project (`--project <own-project>` on every `list`/
127 `search` call in that AGENTS.md) so it can never read another project's
128 meetings.
129- **Secret handling:** `GRANOLA_API_KEY` goes in `.env`, same pattern as the
130 Linear keys. An `env_file` change needs `compose up -d --force-recreate`,
131 not just a restart — a plain restart keeps the container running with the
132 stale environment.
133
134## Multiple groups / projects (e.g. a second group → a different Linear team)
135
136Sessions are per-group (`agent:<id>:whatsapp:group:<jid>`), so one bot number
137serves many groups without context bleed. To add a group:
138
1391. Add its JID to the admission map:
140 `channels.whatsapp.groups["<NEW_GROUP_JID>"] = { requireMention: false }`
141 (get the JID the same way as in setup step 3).
1422. Pick the isolation level:
143 - **Same agent, different context (light):** add
144 `systemPrompt: "This group is project X with <people>; tickets go to Linear team <KEY> (pass --team <KEY> to linear.py)."`
145 to that group's entry in the groups map.
146 - **Dedicated agent per project (clean, preferred for a real second team):**
147 new `agents.list` entry with its own `workspace` (own AGENTS.md: project
148 context, taxonomy, collection-title language, team key) and optionally its
149 own `model`. Route with
150 `bindings: [{ type: "route", agentId: "<new>", match: { channel: "whatsapp", peer: { kind: "group", id: "<NEW_GROUP_JID>" } } }]`
151 placed BEFORE any channel-wide binding (exact peer match wins; remember
152 peer requires both `kind` and `id`). Tighten the original catch-all
153 binding to its own group JID at the same time.
1543. Linear team: `linear.py` takes `--team <KEY>` per call (or set
155 `LINEAR_TEAM_KEY` per agent via the workspace AGENTS.md instructions); one
156 workspace API key covers all its teams. Collection tickets stay per-team
157 automatically since they are created with that team flag.
158 **Different Linear WORKSPACE (verified 2026-07-10):** an API key only sees
159 its own workspace, so a second project in another workspace needs its own
160 key. Put it in `.env` as `LINEAR_API_KEY_<PROJECT>` (env_file change →
161 container RECREATE, `compose up -d --force-recreate`, not just restart) and
162 have that agent's AGENTS.md prefix EVERY linear.py call with
163 `LINEAR_API_KEY="$LINEAR_API_KEY_<PROJECT>" LINEAR_TEAM_KEY=<KEY>` — state
164 in the prompt that a missing prefix files tickets into the wrong company.
1654. Shared automatically: the message log and library (both partitioned by
166 group JID), health cron, daily sweep (make the sweep prompt name both
167 teams or run one sweep per agent). Scope every shared query by group:
168 `groupmem.py search/recent --group <jid>` in each AGENTS.md and in each
169 sweep prompt, otherwise agents and sweeps read the other project's chatter
170 (search lacked `--group` until 2026-07-10 — check before reusing an older
171 copy). If the second project shouldn't share the document library, forbid
172 `library.py` in its prompt (allow `extract-pdf`, it's read-only) and file
173 intake to Linear collection tickets only.
1745. Caveat to tell the user: it is still ONE WhatsApp number on Baileys; a ban
175 takes all groups down until re-pair. Use a second number for groups with
176 external collaborators.
177
178## Agent prompt rules (AGENTS.md in the agent workspace)
179
180**Policy v2 (2026-07-20):** replaced the flat "classify then react to every
181message" rule below. Reaction-per-message was pure noise (most
182`<media:audio>` fell through to a stray 👍) and the old taxonomy couldn't
183distinguish "addressed to the bot" from "humans talking to each other" from
184"actually ticket-worthy". Current rules:
185
186- **Prime directive — default to silence.** When in doubt, output `NO_REPLY`
187 and nothing else. A missed ticket is caught by the daily sweep; an unwanted
188 reaction or reply cannot be taken back. Never narrate ("noted", "creating a
189 ticket") before/between/after tool calls — in monitoring mode the only text
190 output is the final `NO_REPLY`.
191- **Am I addressed? — run BEFORE any classification.** True only if one of:
192 **A.** @mention of the bot itself — the prompt MUST spell out the bot's own
193 identifiers verbatim, because WhatsApp delivers a mention as raw literal
194 text with NO mention metadata (the OpenClaw plugin never forwards
195 `mentionedJids`): a mention arrives as `@<bot-LID>` (the bot's LID, a
196 WhatsApp-internal id — NOT the phone number, NOT the display name) or
197 `@<bot-number>` (phone). Either token anywhere in the message = addressed;
198 `@` + any other number is a human mention and does not count. A rule that
199 just says "@mention of your own number/handle" is unsatisfiable — the model
200 NO_REPLY'd a real direct mention until the literal tokens were spelled out
201 (incident + fix 2026-07-20). GENERAL LESSON: every group bot's prompt
202 states its own LID and phone number; discover the LID by having someone
203 tap-mention the bot and reading the raw body in the gateway file log or
204 message log.
205 **B.** the trigger word `bot` (or your language's equivalent, any casing)
206 used as a DIRECT ADDRESS anywhere in the message — first word ("bot,
207 what's open"), last word ("what's open, bot?"), or mid-sentence vocative
208 ("tell me, bot, what's open"). Widened from first-word-only 2026-07-20.
209 Third-person REFERENCE is explicitly not addressing: "the bot"/"the bot
210 didn't respond" stay ambient; genuinely unsure whether it's address or
211 reference → NOT addressed (silence-default wins).
212 **C.** WhatsApp quoted-reply to a message the bot sent.
213 **D.** conversational continuation (added 2026-07-20): the bot sent the
214 immediately previous group message AND the new message is a direct reaction
215 to it — a correction/objection ("I think that's incomplete"), a follow-up
216 question, or "help". One hop only; a new unrelated topic doesn't count.
217 None of A/B/C/D → NOT addressed → ambient classification below. A plain
218 imperative ("add hebrew support") is two humans assigning each other work,
219 never a bot request; audio/image/document with no A/B/C/D trigger is never
220 addressed.
221- **Ambient classification (not addressed), top-to-bottom, first match wins:**
222 audio/untranscribed media → **SILENT** (never guess unheard content) →
223 client/sensitive-case content (real client name/ID, case facts) →
224 **SILENT** (never filed, never ticketed) → intake (attachment or link worth
225 keeping) → file it, react **📁** → task/bug/feature/commitment →
226 dedup-search first, then react **✅** if a ticket was created, or **SILENT**
227 if an existing open ticket already matched → everything else (decisions,
228 questions, scheduling, FYI, banter, ideas with no owner) → **SILENT**, no
229 reaction of any kind.
230- **Reactions are receipts for completed actions only, and there are exactly
231 two:** ✅ created, 📁 filed. 👍/❓/📌 are retired, and so is the 🔁 duplicate
232 marker: finding an existing ticket is not work, so it earns silence like
233 everything else the bot did not act on. Write the dedup rule as "no ticket,
234 NO reaction, just NO_REPLY" — a weak model reads "duplicate found" as an
235 outcome worth announcing unless told otherwise. One reaction max: if intake
236 and task both fire on the same message, do both actions but show only ✅.
237 A text
238 reply is its own receipt — never stack a reaction on top of a reply. The
239 react call must OMIT message_id: an explicit id skips the plugin's
240 participant inference and WhatsApp silently drops group reactions missing
241 the participant key.
242- **When addressed, route by ask:** bare mention (a mention with no ask —
243 e.g. just `@<bot-LID>` — is still addressed: never NO_REPLY it; reply one
244 short line in the sender's language inviting the ask) / ticket ops (dedup,
245 then create/update,
246 confirm in one line with identifier+URL) / reminders (OpenClaw's own cron —
247 never build new — `announce → whatsapp:<target>`, group JID for "remind
248 us/the group" vs DM for "remind me"; reminders never touch Linear) /
249 knowledge or advice (search first via `groupmem.py`/`linear.py`/
250 `library.py`, answer short and in-language; for pure opinion give a short
251 honest take and state uncertainty plainly — never default to "I don't have
252 enough context") / meetings (Granola cache, always project-scoped — see
253 "Granola meeting access" above; summary first, transcript only when the
254 summary doesn't answer it) / capabilities ("what can you do?" — required
255 self-description, see below) / correction or banter (one short reply;
256 best-effort undo if it corrected a wrong action; no ticket, no reaction).
257- **Client PII in tickets:** when a task wraps a real client's data (name/ID,
258 medical/case detail), the ticket describes the technical problem only and
259 references the WhatsApp thread/date; never paste the client narrative into
260 Linear.
261- **Timezone (added 2026-07-20):** if the container clock's timezone differs
262 from the group's (e.g. a European VPS serving a group elsewhere), state the
263 offset in the prompt and have the bot convert any stated time to the
264 group's timezone and label it as such — otherwise reminders and "at 3pm"
265 answers land an hour off.
266- **Carried forward, unchanged:** dedup-search before create; rolling
267 "[collection]" tickets per document topic (find-or-create by exact title,
268 then comment per item) instead of a ticket per document, localized to the
269 group's own language; media arrives as `<media:image>`/`<media:document>`
270 placeholders with the file in `<data-home>/.openclaw/media/inbound/`, images
271 auto-described by the configured image model, PDFs need a pypdf extract
272 helper.
273
274## Capabilities self-description (added 2026-07-20)
275
276Required section in every group agent's AGENTS.md, answered when addressed
277and asked "what can you do?" — a silent bot can't be discovered by its users,
278and its emoji receipts are undecodable without an explanation on demand.
279Answer in the asker's language, adapted naturally, nothing invented:
280
2811. Silent monitoring — every task mentioned in the group becomes a ticket
282 automatically; documents/articles/screenshots are filed to the library and
283 collection tickets.
2842. On-request ("call me bot anywhere in the message, @mention me, or reply
285 to me" — deployed wording 2026-07-20; a direct follow-up right after the
286 bot speaks counts too):
287 open/search/close tickets, list what's open, set reminders, answer
288 questions from group history + the document library + Granola meetings,
289 give a short opinion.
2903. **Reaction glossary, verbatim, not paraphrased:** ✅ ticket created,
291 📁 filed — that is the whole set — plus an explicit line that silence is
292 deliberate: "seen, nothing to do," including when a ticket already exists.
293
294Without item 3 users can't decode the emoji receipts and end up asking "did
295that work?" in the chat, which defeats the point of the silent-monitoring
296design — this is now required for every group bot, not optional flavor.
297
298## Fallback models break discipline
299
300- Model idle timeouts (default 120s) silently push work to the LAST fallback
301 model. Cheap models follow the silence/reaction rules loosely: they announce
302 actions in chat, skip reactions, and misread plain imperatives ("add more
303 languages") as being addressed directly.
304- Fixes: set `agents.defaults.timeoutSeconds: 300`; order fallbacks mid-tier
305 before cheap (sonnet-4-6 → sonnet-4-5 → haiku last); define "addressed"
306 explicitly in the prompt (the A/B/C/D test above, literal identifier tokens
307 included; an imperative is a task); write every hard rule so the weakest
308 model in the chain still obeys it.
309
310## Container/environment gotchas (managed OpenClaw image on a VPS)
311
312- Run ALL openclaw CLI as the service uid: `docker exec -u 1000 ...`. Running
313 as root creates root-owned files that trip ownership security checks.
314- `/tmp` must be mode 1777; some prebuilt images ship it 0700 → gateway
315 refuses to start with "Unsafe fallback OpenClaw temp dir".
316- Config is strict-validated and unknown keys abort the gateway: run
317 `openclaw config validate` after every edit.
318- pip needs `--user --break-system-packages` (PEP 668), installs persist in
319 the /data home.
320- Bot LID discovery: the bot's own LID (needed verbatim in the prompt — see
321 "Am I addressed?" rule A) appears in no config file. Have someone
322 tap-mention the bot in the group, then read the raw message body — it shows
323 as literal `@<15-digit LID>` text — in the container file log
324 (`/tmp/openclaw-<uid>/openclaw-*.log`) or the message log. The plugin
325 forwards no mention metadata, so the raw body is the only place the LID
326 surfaces.
327- AGENTS.md/skills snapshot per session: reset the group session
328 (`rm -rf agents/<id>/sessions/*` + container restart) after prompt changes,
329 ideally at a quiet moment (see the initialization race above).
330