Install
openclaw skills install heleni-whatsappComplete WhatsApp management for OpenClaw agents: per-conversation memory (groups + DMs), unanswered message tracking, loop prevention, and multi-PA coordination. Use when: tracking conversation context, recalling past decisions, finding unanswered messages, or preventing echo/duplicate message loops.
openclaw skills install heleni-whatsappCovers two responsibilities in one skill:
CONTEXT_FILE="/opt/ocana/openclaw/workspace/skills/heleni-whatsapp/.context"
[ -f "$CONTEXT_FILE" ] && source "$CONTEXT_FILE"
# Then use: $OWNER_PHONE, $JID_CORE_TEAM, $INBOX_FILE, etc.
Any model. All operations are file-based. No reasoning required. Use a medium+ model only when deciding what is worth logging.
memory/
whatsapp/
groups/
YOUR_GROUP_JID-sanitized/ ← sanitized JID
meta.json ← group name, JID, participants
context.md ← running conversation context
decisions.md ← key decisions
people.md ← who participates and their role
dms/
972XXXXXXXXX/ ← sanitized phone number
meta.json ← name, phone, relationship
context.md ← running DM context
notes.md ← tasks, preferences, important facts
inbox/
pending.json ← unanswered message tracking
memory/whatsapp/groups/<JID-sanitized>/context.mdmemory/whatsapp/dms/<PHONE-sanitized>/context.md@, ., + with -After every significant exchange — incoming OR outgoing — create or update the DM context file. This is not optional. Sessions restart constantly. Without a context file, you have zero memory of who this person is or what was discussed.
Triggers:
Template:
mkdir -p memory/whatsapp/dms/<PHONE>/
# Write to memory/whatsapp/dms/<PHONE>/context.md:
# Name, role, last interaction date, what was discussed, current status
No exceptions. Every DM = a context file.
init_whatsapp_memory() {
TYPE="$1" # "group" or "dm"
ID="$2" # JID or phone number
NAME="$3" # Human-readable name
SAFE_ID=$(echo "$ID" | tr '@.+' '---')
if [ "$TYPE" = "group" ]; then
DIR="$HOME/.openclaw/workspace/memory/whatsapp/groups/$SAFE_ID"
mkdir -p "$DIR"
cat > "$DIR/meta.json" << EOF
{"type": "group", "jid": "$ID", "name": "$NAME", "created": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"}
EOF
touch "$DIR/context.md" "$DIR/decisions.md" "$DIR/people.md"
else
DIR="$HOME/.openclaw/workspace/memory/whatsapp/dms/$SAFE_ID"
mkdir -p "$DIR"
cat > "$DIR/meta.json" << EOF
{"type": "dm", "phone": "$ID", "name": "$NAME", "created": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"}
EOF
touch "$DIR/context.md" "$DIR/notes.md"
fi
echo "Initialized WhatsApp memory: $NAME"
}
# Examples:
# init_whatsapp_memory "group" "YOUR_GROUP_JID@g.us" "PA Team"
# init_whatsapp_memory "dm" "+PHONE_NUMBER" "Contact Name"
wa_log() {
TYPE="$1" # "group" or "dm"
ID="$2" # JID or phone
CONTENT="$3" # what to log
FILE_NAME="${4:-context.md}" # context.md / decisions.md / notes.md
SAFE_ID=$(echo "$ID" | tr '@.+' '---')
BASE="$HOME/.openclaw/workspace/memory/whatsapp"
if [ "$TYPE" = "group" ]; then
FILE="$BASE/groups/$SAFE_ID/$FILE_NAME"
else
FILE="$BASE/dms/$SAFE_ID/$FILE_NAME"
fi
if [ ! -f "$FILE" ]; then
mkdir -p "$(dirname "$FILE")"
touch "$FILE"
fi
echo "[$(date -u +%Y-%m-%d\ %H:%M)] $CONTENT" >> "$FILE"
}
wa_context() {
TYPE="$1"
ID="$2"
LINES="${3:-20}"
SAFE_ID=$(echo "$ID" | tr '@.+' '---')
BASE="$HOME/.openclaw/workspace/memory/whatsapp"
if [ "$TYPE" = "group" ]; then
DIR="$BASE/groups/$SAFE_ID"
else
DIR="$BASE/dms/$SAFE_ID"
fi
if [ ! -d "$DIR" ]; then
echo "No memory for this conversation yet."
return
fi
NAME=$(python3 -c "
import json
with open('$DIR/meta.json') as f:
print(json.load(f).get('name', '?'))
" 2>/dev/null || echo "?")
echo "=== $NAME ==="
echo "--- Recent ---"
tail -"$LINES" "$DIR/context.md" 2>/dev/null || echo "(empty)"
echo "--- Notes/Decisions ---"
cat "$DIR/notes.md" "$DIR/decisions.md" 2>/dev/null | tail -10 || echo "(none)"
}
wa_search() {
QUERY="$1"
BASE="$HOME/.openclaw/workspace/memory/whatsapp"
echo "Searching WhatsApp memory for: '$QUERY'"
grep -r "$QUERY" "$BASE" --include="*.md" -l 2>/dev/null | while read file; do
DIR=$(dirname "$file")
NAME=$(python3 -c "
import json
with open('$DIR/meta.json') as f:
print(json.load(f).get('name', '?'))
" 2>/dev/null || echo "?")
echo "Found in: $NAME"
grep -n "$QUERY" "$file" | head -3
echo ""
done
}
| File | Use for |
|---|---|
| context.md | Ongoing conversation events, tasks assigned |
| decisions.md | Agreed outcomes, group decisions |
| people.md | Who's in the group, their role/style |
| notes.md | DM tasks, owner preferences, follow-ups |
Never log: casual greetings, duplicate info, credentials.
1. Extract JID or phone from inbound metadata
2. If group: run wa_context "group" "$JID" 10
If DM: run wa_context "dm" "$PHONE" 10
3. Use context to inform your response
4. After responding: log anything worth remembering
Inbox file: /opt/ocana/openclaw/workspace/inbox/pending.json
{
"version": 1,
"messages": [
{
"id": "MSG_ID",
"ts": "2026-04-02T10:00:00Z",
"chat_id": "+972XXXXXXXXX",
"chat_name": "Owner",
"chat_type": "direct",
"sender_name": "Owner",
"sender_phone": "+972XXXXXXXXX",
"body": "message text...",
"answered": false,
"answered_at": null
}
]
}
import json, datetime
INBOX = "/opt/ocana/openclaw/workspace/inbox/pending.json"
with open(INBOX) as f:
data = json.load(f)
data["messages"].append({
"id": "<message_id>",
"ts": datetime.datetime.utcnow().isoformat() + "Z",
"chat_id": "<chat_id>",
"chat_name": "<chat_name>",
"chat_type": "direct", # or "group"
"sender_name": "<sender_name>",
"sender_phone": "<sender_phone>",
"body": "<message body, max 300 chars>",
"answered": False,
"answered_at": None
})
with open(INBOX, "w") as f:
json.dump(data, f, indent=2)
import json, datetime
INBOX = "/opt/ocana/openclaw/workspace/inbox/pending.json"
with open(INBOX) as f:
data = json.load(f)
for msg in data["messages"]:
if msg["id"] == "<message_id>":
msg["answered"] = True
msg["answered_at"] = datetime.datetime.utcnow().isoformat() + "Z"
with open(INBOX, "w") as f:
json.dump(data, f, indent=2)
import json
from datetime import datetime, timedelta, timezone
INBOX = "/opt/ocana/openclaw/workspace/inbox/pending.json"
MAX_AGE_HOURS = 24
with open(INBOX) as f:
data = json.load(f)
cutoff = datetime.now(timezone.utc) - timedelta(hours=MAX_AGE_HOURS)
unanswered = [
m for m in data["messages"]
if not m["answered"]
and datetime.fromisoformat(m["ts"].replace("Z", "+00:00")) > cutoff
]
for m in sorted(unanswered, key=lambda x: x["ts"]):
ts = datetime.fromisoformat(m["ts"].replace("Z", "+00:00")).strftime("%d/%m %H:%M")
print(f"📩 {m['sender_name']} | {m['chat_name']} | {ts}")
print(f" > {m['body'][:100]}")
During heartbeat, check for unanswered messages from the last 2 hours:
import json
from datetime import datetime, timedelta, timezone
INBOX = "/opt/ocana/openclaw/workspace/inbox/pending.json"
with open(INBOX) as f:
data = json.load(f)
cutoff = datetime.now(timezone.utc) - timedelta(hours=2)
unanswered = [
m for m in data["messages"]
if not m["answered"]
and datetime.fromisoformat(m["ts"].replace("Z", "+00:00")) > cutoff
]
if unanswered:
for m in unanswered:
print(f"⚠️ No reply to {m['sender_name']}: {m['body'][:80]}")
import json
from datetime import datetime, timedelta, timezone
INBOX = "/opt/ocana/openclaw/workspace/inbox/pending.json"
with open(INBOX) as f:
data = json.load(f)
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
data["messages"] = [
m for m in data["messages"]
if not m["answered"]
or datetime.fromisoformat(m["ts"].replace("Z", "+00:00")) > cutoff
]
with open(INBOX, "w") as f:
json.dump(data, f, indent=2)
NEVER share information across chat contexts:
When the owner asks you to check in with someone:
Before responding to ANY message, check sender_id from inbound metadata.
Before sending to a group or DM:
wa_contexttail -10 instead of reading full context filesUse this section when the agent is not responding to WhatsApp messages.
Step 1 — Classify the problem:
openclaw gateway restart, wait 30s, check if count increments.Step 2 — Fix ingest issues (Messages = 0):
openclaw gateway status
openclaw gateway restart
# Wait 30 seconds, then test
openclaw gateway logs --last 50 # Look for: binding failed, session dropped, ingest error
If errors persist → escalate to platform admin (infrastructure issue).
Step 3 — Fix runtime issues (no reply despite incoming messages):
# Check for billing errors
grep -i "billing\|402\|credits" ~/.openclaw/logs/agent.log | tail -20
# Verify API key (expect HTTP 200)
curl -s -o /dev/null -w "%{http_code}" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
https://api.anthropic.com/v1/models
# 200 = OK | 401 = invalid key | 402 = billing error
Prevention:
When to escalate: Gateway restart doesn't fix Messages = 0, logs show socket/binding/session errors, or multiple agents affected at the same time.