WhatsApp is not a discovery channel — it is a conversion channel. The goal is not to send fast: it is one qualified hand-off per opt-in lead, no unsolicited bulk, no duplicate send. Reference BSP: Whatchimp (Meta Business Partner); any BSP works.
Every access is opt-in — each one stays off until you fill its placeholder in ## Configure. The default is no network.
AccessWhyDefaultBSP API token ( <WA_API_KEY>)Send/read messagesNone — no network call without itCRM sheet ID ( <CRM_SHEET_ID>)One row per leadNone — CRM writes skippedAlert channel / webhook ( <ALERT_CHAT_PRIMARY>)Leaves your machine. Posts lead name + phone to Telegram/Slack/DiscordNone — recap stays localLocal workspace ( <WORKSPACE_DIR>/memory/)State + duplicate guardLocal files only
Personal data persisted, in full: display name, case type, free-text situation, conversation timestamps, template-send log, alert log, per-run recaps. Phone numbers are stored raw ONLY in wa-crm-state.md (the system-of-record mirror); every duplicate register and the blacklist key on sha256(phone)[0:12]. That is lead PII on disk — ## Data minimization and retention is doctrine, not an appendix. Scope the API token to the one business number; never reuse a full-account token.
TriggerAction"answer the whatsapp leads"wa-inbound run — §Flow, free-form only inside open window"send the first message to new leads"wa-outbound run — approved template only"relance the cold leads"wa-followup run — cadence table"quality score dropped" / "number got paused"Stop outbound. ## Troubleshooting, last two rows. Human review"am I talking to a bot?" (asked by a lead)Answer honestly, immediately — ## Identity
PlaceholderExampleYour value<BRAND_NAME>"Acme Studio"—<WA_BUSINESS_NUMBER>"+1 555 0000"—<WA_PHONE_NUMBER_ID>numeric ID from your BSP—<WA_API_KEY>scoped to one number—<WA_TEMPLATE_FIRST_CONTACT> / _FOLLOWUP_SOFT / _FOLLOWUP_HARD / _CLOSINGMeta-approved template IDs—<CRM_SHEET_ID>Sheet/Airtable ID— (omit → no CRM write)<ALERT_CHAT_PRIMARY>alert chat ID— (omit → local recap only)<WORKSPACE_DIR>"~/.openclaw/workspace/whatsapp-acme"—yaml
# <WORKSPACE_DIR>/config.yaml
whatsapp:
phone_number_id: <WA_PHONE_NUMBER_ID>
base_url: https://app.whatchimp.com/api/v1 # swap host for another BSP
templates:
first_contact: <WA_TEMPLATE_FIRST_CONTACT>
followup_soft: <WA_TEMPLATE_FOLLOWUP_SOFT>
followup_hard: <WA_TEMPLATE_FOLLOWUP_HARD>
closing: <WA_TEMPLATE_CLOSING>
crm: { sheet_id: <CRM_SHEET_ID>, tab: "Leads" }
alerts: { primary_chat: <ALERT_CHAT_PRIMARY>, channel: telegram }
retention: { lead_days: 90, recap_days: 30, template_log_days: 30 }
inbound_pipeline:
shared_queue_file: <WORKSPACE_DIR>/../shared/leads-whatsapp.json
schedule: # dm_check "*/2 9-22 * * *" · add_contacts "*/5 9-22 * * *" · follow_up "0 10 * * *"
timezone: Europe/Paris
After any message a user sends you, you have 24 h to reply in free-form. After 24 h of user silence, only Meta-approved templates, each billed as a new conversation. Free-form to a cold conversation → API rejection or a marketing-conversation bill + a quality hit; exit code 2, always skipped. Check window state before every outbound send:
bash
curl -s "https://app.whatchimp.com/api/v1/whatsapp/get/conversation" \
-d "apiToken=<WA_API_KEY>" -d "phone_number_id=<WA_PHONE_NUMBER_ID>" \
-d "phone_number=<DIGITS>" -d "limit=1"
# Output: {"status":1,"data":[{"sender":"user","created_at":"2026-07-16T09:12:04Z"}]}
# sender="user" and < 24h old → free text OK. Otherwise → template.
Session check first on every cron:
bash
curl -s "https://app.whatchimp.com/api/v1/whatsapp/subscriber/list" \
-d "apiToken=<WA_API_KEY>" -d "phone_number_id=<WA_PHONE_NUMBER_ID>" \
-d "limit=1" -d "offset=1"
# Output: {"status":1,"data":[...]} 401/403 → stop+alert · 429 → stop, wait next run
Timeout > 10 s → BSP down: one retry, then stop and alert. Never retry in a loop. A 200 OK carrying status=0 is a failure, not a send.
Never send a "test" message to a real lead — use a dedicated test number. Never auto-rotate an API token from inside a cron; rotation is a human action.
Meta assigns each number a tier: T1 1k uniques/24h · T2 10k · T3 100k · T4 unlimited. New numbers start at T1. Tiers rise on volume + quality, and fall — or the number gets paused — when quality drops.
ConditionPhaseOutboundTier 1 or quality yellow/redAInbound replies + approved-template follow-ups. ≤ 50 first-contact/dayTier 2+ and quality green, no drop in 14 dBFull doctrine, first week capped at 50% of Phase B max
Read memory/wa-state.md at start of every run. Phase B is a manual flip, never automatic. Any quality drop → revert to A immediately.
Action (hard limits)Phase APhase BInbound replies / cron run2050First-contact templates / 24 h50500Follow-up templates / 24 h20200Unique recipients / 24 h800 (under T1's 1000 cap)tier cap − 10%Conversations / cron run3080Same user — outbound spacingmin 24 h between any two outbound
Follow-up cadence: J+1 soft · J+3 soft · J+7 hard · J+15 hard · J+20 closing → status=lost. Stop after the closing template. Continuing past J+20 is ban-risk territory.
Quality-score collapse is dominated by duplicate template sends. Three registers, read before every outbound action:
RegisterRow formatRulewa-alerts-sent.md``{"phone_hash":"<sha256:12>","qualified_date":"YYYY-MM-DD"}Phone present → never alert twicewa-template-log.md``{"phone_hash":"<sha256:12>","template_id":"<ID>","sent_at":"<ISO>","result":"ok|error"}Same (phone, template) in last 24 h → SKIPwa-crm-state.mdone row per phone, never twoOnly wa-outbound appends; every other cron UPDATEs
If in doubt, SKIP. It is always better to skip than to send a duplicate. This one rule is the difference between a number that stays Tier 2 for years and one paused in 30 days.
Alert only when a lead replies AND qualifies — never after a first template send. Alerting on first contact generates dozens of false alerts and trains the human team to ignore the channel.
The registers above hold lead PII — a regulated store, not logs.
RuleConcretelyMinimizeStore phone + name + case type + status. Never store ID numbers, health details, case documents, or payment data in memory files — keep those in the system of record.MaskKey the duplicate registers on sha256(phone)[0:12], not the raw number. Lookups work identically; a leaked log exposes nothing.Restrictchmod 700 <WORKSPACE_DIR>/memory. One number = one workspace. Never share wa-template-log.md across numbers or brands.RetainLeads 90 d after last contact, recaps 30 d, template log 30 d — then delete. Prune at the start of the daily run, not "someday": find "<WORKSPACE_DIR>/memory" -name 'wa-recaps.md' -mtime +30 -delete (silent — pruned, not archived).Alert payloadName + case type + phone only. Never paste conversation transcripts into the alert channel — it leaves your machine and is usually a wider audience than the case file.EraseOn an opt-out or deletion request: purge every register, add the hash to wa-blacklist.md, confirm to the lead. Blacklist stores hashes only.Sensitive nichesLegal, medical, financial: confirm with your DPO/counsel before any cron writes lead data. This skill does not make that call for you.
If a lead asks whether they are talking to a bot, a human, or an AI: say so plainly, in the same message, and offer the human.
text
You're talking to <BRAND_NAME>'s automated assistant. I can take your details
now and a specialist will call you back — or I can pass you to them right away.
No ambiguity, no pivot, no "I'm part of the team". Honest disclosure is not a cost here: a lead who discovers the automation later is a complaint, a block, and a quality-score hit. Blocks and reports are exactly what Meta measures. Disclosure is account safety. Escalate to a human — stop the automated replies for that thread and alert — when the lead asks for a human, asks twice about identity, or raises anything the templates do not cover.
Skeletons: A greeting + "how can we help" · B empathy + one contextual question · C hand-off: Thank you for these details. A specialist from <BRAND_NAME> will call you back. Could you confirm your name and the best time to reach you?
Classify every thread before drafting — case type is what the alert payload and the recap carry. Structure below, questions are yours:
Case typeSignalActionA — high urgencyPending deadline, acute problemAsk date, location, urgency → collect contact → qualifiedB — mid intentOngoing situation, no deadline5-7 contextual questions → collect contact → qualifiedC — out of scopeNot what the brand doesRedirect to the right channel. Never force qualification****D — existing clientPhone-match in wa-clients-known.mdAnswer with empathy, redirect to the standard client support line. Never paste the prospect CTA — a paying client who gets a sales pitch blocks, and a block is a quality hit
"Too many questions" failsafe — after 5 questions with no contact info, switch: "To answer precisely, a specialist needs to call you back. Could you confirm your name + best time?" This converts more question-only threads than any other tactic.
Qualification requires: thread open · not blacklisted · not already "qualified — awaiting callback" · no auto-reply in the last 60 s (anti-cascade). Any check fails → skip.
text
1. Session check. 2. subscriber/list unseen_count>0 — if 0 unread → STOP.
3. Per unread (≤20 A / ≤50 B): get/conversation limit=20 → if last sender="bot" SKIP
→ qualify → draft → normalize encoding → POST /whatsapp/send → verify status=1.
4. If qualified: check wa-alerts-sent.md → if absent, alert primary chat, append hash.
UPDATE the CRM row (wa-inbound never APPENDs). 5. Recap.
text
1. Session check. 2. Read shared queue, filter status="pending" — if 0 → STOP.
3. Per lead: template-log 24h check → subscriber/create → POST /whatsapp/send/template
template_id=<WA_TEMPLATE_FIRST_CONTACT> → verify status=1 → queue status="first_message_sent"
→ APPEND CRM row (only cron that appends) → append template log.
4. Never alert from this cron. 5. Recap.
text
1. Session check. 2. CRM: status in {first_message_sent, follow_up_sent}, idle at a J+N mark.
3. Per candidate: template-log check → send cadence template → update CRM + log. 4. Recap.
Running Playwright against web.whatsapp.com is outside WhatsApp's Business Terms and risks a permanent number ban. It is documented here only as a read-only contingency while BSP approval is pending, and it is not recommended for production. Requires an explicit human decision per incident — an agent must not choose this path on its own. Sessions rotate the QR link unpredictably (daily manual re-login); no template sends are possible; quality score still applies at the number level. If a login challenge or automation check appears: stop and hand the session to a human. Never tune timing to avoid a detection check. Migrate back to the BSP API as soon as approval lands.
CodeMeaningRecap0Sent, or nothing to do (zero unread)status: ok1Fatal (API 5xx, JSON parse fail)status: error, alert224h-window violation attempted → skippedstatus: skip, log3Auth fail (401/403)status: blocked, alert now4Duplicate-guard SKIPstatus: ok
Every run ends with this, verbatim, to the alert channel and appended to memory/wa-recaps.md:
text
[Job name] — [status: ok|partial|blocked|skipped]
Inbound replies: [N or "—"]
Templates sent: [N or "—"]
Qualified leads: [count + first names only]
Phase / Tier: [A|B / Tier 1|2|3|4]
Quality score: [green|yellow|red]
Blockers: [text OR "—"]
Next action: [1 line]
Filled instance:
text
wa-inbound 10:32 — status: ok
Inbound replies: 7
Templates sent: —
Qualified leads: 2 (Marc, Lina)
Phase / Tier: A / Tier 1
Quality score: green
Blockers: —
Next action: human callbacks due before 12:00
Better silence than spam. Better a blockage report than a fake success. Never fake a successful send.
SymptomRoot causeFix200 OK, message never deliveredphoneNumberID vs phone_number_id — BSP casing differs per endpoint. No error is raised, so you'll believe your code is rightCheck casing per endpoint. Verify status=1 in the body, not just HTTP 200Send rejected for unknown reasonLeading + in the number. 33612345678 works, +33612345678 often does notStrip + before every callAccents vanish in delivered messagesSome BSPs munge non-ASCII silentlyRoundtrip test once (send accented → fetch back). If it munges: strip accents, emojis, typographic quotes, em-dashes before sendMessage body shows an empty slotTemplate variable {{1}} sent with no parameter — silently renders blankPass every slot explicitlyTemplates sent from the wrong numberMultiple bots on one BSP account, active bot not verifiedVerify active bot before the first send of every cron"outside 24h window" errorFree-form on a closed conversationSwitch to template, retry onceInbound message seen after 24 hWebhook latency — the window closed while nothing errored, so no alarm firedReopen with a template: UTILITY category if one exists, MARKETING otherwise (different billing and Meta tolerance). Explain the delay in a variable slot. Log in wa-incidents.md, audit webhook latency"template not approved"Meta rejected or paused itStop using it. Pick the fallback template. Never re-approve by editing content post-approvalQuality score yellow → red overnightScore lags hours, not minutes — one bad run degrades it for 1-2 daysYellow: flip to Phase A, audit template content. Red: inbound only, alert, manual review of 7 daysNumber paused by MetaUsually: outbound to non-opted-in leads, duplicate templates, or content driftStop everything. Review last 200 actions. Appeal via BSP. Never spin up a second number to get around it — Meta cross-checks businesses
memory/: wa-state.md (phase/tier/quality) · wa-alerts-sent.md · wa-template-log.md · wa-crm-state.md · wa-blacklist.md (hashes) · wa-clients-known.md · wa-recaps.md · wa-learnings.md · wa-incidents.md. Shared: ../shared/leads-whatsapp.json (written by upstream social agents, read by wa-outbound).
bash
mkdir -p "<WORKSPACE_DIR>/memory" && chmod 700 "$_" && cd "$_" && for f in wa-recaps wa-state \
wa-alerts-sent wa-template-log wa-crm-state wa-blacklist wa-clients-known wa-learnings \
wa-incidents; do [ -f "$f.md" ] || : > "$f.md"; done
mkdir -p "<WORKSPACE_DIR>/../shared" && Q="<WORKSPACE_DIR>/../shared/leads-whatsapp.json"
[ -f "$Q" ] || echo '[]' > "$Q" # wa-outbound reads this at step 2 — absent = first run crash
# Output: (silent, idempotent) — existing files are never truncated
memory/ exists, chmod 700, retention values set in config.yaml.This skill ONLY: operates a WhatsApp Business number through a BSP API you own · sends free-form inside an open 24h window and Meta-approved templates outside it · contacts leads who opted in on another channel · qualifies and hands off to a human · keeps a masked, retention-bounded local state to avoid duplicates.
This skill NEVER: sends unsolicited bulk or non-opted-in outbound · sends free-form to a cold conversation · hides or denies the automation when a lead asks · adapts timing or behavior to slip past an automated check — when one appears, it stops and a human takes over · stores case documents or special-category data in memory files · retains lead data past the configured window · creates a second number to work around a Meta pause · rotates tokens or restarts infrastructure by itself.
Meta's WhatsApp Business Terms, the WhatsApp Business Messaging Policy, and your jurisdiction's privacy law are binding constraints on every run in this document. Where this doctrine and those rules disagree, those rules win.