whatsapp-web
whatsapp-web
WhatsApp Web automation via Playwright + Chrome CDP. Scripts output JSON to stdout.
Setup
Requires Python 3.10+, Google Chrome, and Playwright.
First-time login — scan QR code once:
python3 scripts/login.py
This opens Chrome, navigates to web.whatsapp.com, reports the current login state, and exits immediately so the calling agent stays responsive. If the user still needs to scan the QR code, tell them to scan it from their phone and re-run the task once signed in. Chrome profile persists in /tmp/whatsapp-web/chrome_profile/, so no re-scan is needed after the first login.
Available Scripts
Check if number(s) are on WhatsApp
# Single number
python3 scripts/check_number.py --phone 081234567890
# Multiple numbers (comma-separated)
python3 scripts/check_number.py --phones 08111,08222,08333
Output: {"081234567890": true}
Add a new contact
python3 scripts/add_contact.py --phone 081234567890 --first-name Ezra
python3 scripts/add_contact.py --phone 081234567890 --first-name Ezra \
--last-name Wijaya --sync
Output: {"status": "saved", "first_name": "Ezra", "last_name": "Wijaya", "phone": "081234567890", "sync_to_phone": true}
Agent must ask the user for First Name, Last Name (optional), and whether to sync the contact to the phone before invoking this script. Pass --sync only if the user confirms syncing.
Create a group
python3 scripts/create_group.py --name "LT Team" --members "Ezra,Adit,Rani"
# Members can be repeated — useful when the user provides them in batches
python3 scripts/create_group.py --name "LT Team" \
--members "Ezra,081234567890" --members "Adit"
Output:
{
"status": "created",
"name": "LT Team",
"requested_members": ["Ezra", "Adit", "Rani"],
"added": ["Ezra", "Adit"],
"failed": ["Rani"]
}
failed lists members whose name/number didn't match a suggestion and were skipped. The group is still created as long as at least one member is added.
Agent must ask the user for both the group name and the members. Members can be many — accept comma-separated input and repeat the prompt if the user has more to add. Combine everything into one or multiple --members flags.
Exit (leave) a group
python3 scripts/exit_group.py --name "LT Team" --confirm
Output: {"status": "exited", "name": "LT Team", "exited": true, "already": false}
If the menu has no Exit option (already left), returns {"status": "noop", "exited": false, "already": true}. The group stays visible in your chat list as read-only — use delete_chat.py afterwards to hide it.
--confirm required. Leaving is reversible only if an admin re-invites you.
Delete a chat
python3 scripts/delete_chat.py --to "Ezra" --confirm
python3 scripts/delete_chat.py --to 081234567890 --confirm
Output: {"status": "deleted", "name_or_number": "Ezra", "deleted": true}
Removes the chat from YOUR sidebar and clears your copy of the history. The other party still sees the conversation. Not reversible.
For active groups WA won't offer "Delete chat" — use exit_group.py first, or delete_group.py for the full teardown.
--confirm required.
Delete a group (kick all → exit → delete)
python3 scripts/delete_group.py --name "LT Marketing Team" --confirm
Output:
{
"status": "deleted",
"name": "LT Marketing Team",
"kicked": ["Adit", "Rani"],
"skipped": [],
"exited": true,
"deleted": true
}
status values:
deleted— kicked all kickable members, exited the group, removed it from the chat list.exited— exit succeeded but delete didn't finalize (you can still remove it from the sidebar manually).partial— something stopped before exit.
skipped lists members whose Remove action didn't surface — usually means the caller isn't a group admin, so those members remain in the group.
DESTRUCTIVE. The script refuses to run without --confirm. Agent must ask the user for explicit confirmation before passing --confirm.
Pin / unpin a chat
python3 scripts/pin_chat.py --to "Ezra"
python3 scripts/pin_chat.py --to "Ezra" --unpin
python3 scripts/pin_chat.py --to 081234567890
Output examples:
{"status": "pinned", "action": "pin", "name_or_number": "Ezra", "already": false}{"status": "noop", "action": "pin", "name_or_number": "Ezra", "already": true}(already pinned){"status": "unpinned", "action": "unpin", ...}
WhatsApp Web allows at most 3 pinned chats. If pinning a 4th, WA shows a modal the script auto-dismisses — the chat stays unpinned and status stays unpinned (tell the user to unpin something first).
Send a message
python3 scripts/send_message.py --to "Ezra" --message "Hello!"
python3 scripts/send_message.py --to 081234567890 --message "Hi there"
Output: {"status": "sent", "to": "Ezra"}
Read recent messages from a chat
python3 scripts/read_messages.py --from "Ezra"
python3 scripts/read_messages.py --from 081234567890 --count 20
Output:
{
"from": "Ezra",
"count": 10,
"messages": [
{"direction": "in", "sender": "Ezra", "time": "08.42", "date": "17/04/2026", "text": "..."},
{"direction": "out", "sender": "Me", "time": "08.43", "date": "17/04/2026", "text": "..."}
]
}
direction is "in" (received) or "out" (sent by the logged-in user).
Last reply from a contact
# Last incoming message (what the contact said) — maps to "X bales apa"
python3 scripts/last_reply.py --from "Ezra"
# Last message regardless of sender — maps to "apa chat terakhir X"
python3 scripts/last_reply.py --from "Ezra" --any-direction
Output:
{
"from": "Ezra",
"mode": "incoming",
"message": {
"direction": "in",
"sender": "Ezra",
"time": "08.42",
"date": "17/04/2026",
"text": "oke siap"
}
}
message is null if no matching message is visible (e.g. asking for an incoming message in a chat the user has only sent to).
List chats in the sidebar
python3 scripts/list_chats.py # top 50 chats
python3 scripts/list_chats.py --limit 20 # top 20
python3 scripts/list_chats.py --names-only # drop previews
Output: {"total_in_sidebar": 188, "returned": 50, "chats": [{"name": "...", "preview": "...", "pinned": false}, ...]}
total_in_sidebar is the full chat count WhatsApp reports (all archived + active), returned is how many entries the script actually collected.
List pinned chats
python3 scripts/list_pinned.py
Output: {"count": 2, "chats": [{"name": "...", "preview": "...", "pinned": true}, ...]}
WhatsApp Web allows at most 3 pinned chats.
List unread chats / count unread messages
python3 scripts/list_unread.py # scan top 50 rows
python3 scripts/list_unread.py --limit 100 # scan deeper
python3 scripts/list_unread.py --count-only # just the totals
Output:
{
"chat_count": 3,
"message_count": 46,
"chats": [
{"name": "LT Marketing Team", "unread_count": 33, "unread": true, "pinned": false, ...}
]
}
chat_count = number of chats with unread messages; message_count = sum of per-chat unread counts. Only chats whose rows are scanned are counted — raise --limit if you have many chats and want to look deeper.
Open WhatsApp Web / check login state
# Default: open WA Web, report state, exit immediately (non-blocking)
python3 scripts/login.py
# Block until the user signs in (only when explicitly requested)
python3 scripts/login.py --wait
python3 scripts/login.py --wait --timeout 120
Default mode exits right after opening the window — agents MUST NOT use --wait unless the user explicitly asks to wait for login, otherwise the agent will appear to hang while the user scans the QR code.
Output examples:
{"state": "logged_in"}{"state": "qr_code", "action": "Scan the QR code with your phone", "message": "..."}{"state": "loading", "message": "..."}{"state": "timeout", "error": "..."}(only with--wait){"state": "error", "error": "..."}— Chrome / CDP / navigation failed
login.py never crashes with a traceback: Chrome-launch, CDP-connect, and navigation failures are reported as {"state": "error", ...} with exit code 1, same as a --wait timeout.
Script conventions
- All scripts output JSON to stdout, diagnostics to stderr
- Exit codes:
0= success,1= login required / login error / wait timeout,2= contact not found,3= destructive script missing--confirm - Destructive scripts (
delete_group.py,exit_group.py,delete_chat.py) refuse to run without--confirm(exit code 3). Agent MUST ask the user to confirm first before invoking them - Run
python3 scripts/<name>.py --helpfor usage - Scripts use PEP 723 inline dependencies — run with
uv runor install Playwright manually
Important notes
- Chrome persists across runs — never killed by the skill
- Anti-ban delay (default 3s) between operations
- Phone formatting defaults to Indonesian numbers (+62)
- All interactions are keyboard/text-based (no CSS selectors) for resilience against WA Web DOM changes
- Number verification checks multiple phone format variants for accuracy