| name | ops-inbox |
| description | Full inbox management across all channels — WhatsApp (whatsmeow bridge via mcp__whatsapp__*), iMessage (chat.db reader + AppleScript send via mcp__plugin_imessage_imessage__*), Email (Gmail MCP), Slack (MCP), Telegram (user-auth MCP), Discord (webhook + REST read), Notion (MCP — comments, mentions, assigned tasks). Scans FULL inbox (not just unread), identifies messages needing replies, archives handled conversations. |
| argument-hint | [channel: whatsapp|imessage|email|slack|telegram|discord|notion|all] |
| allowed-tools | ["Bash","Read","Grep","Glob","Skill","Agent","AskUserQuestion","TeamCreate","SendMessage","TaskCreate","TaskUpdate","TaskList","CronCreate","CronList","mcp__gog__gmail_search","mcp__gog__gmail_read_thread","mcp__gog__gmail_send","mcp__gog__gmail_labels","mcp__claude_ai_Slack__slack_search_public_and_private","mcp__claude_ai_Slack__slack_read_channel","mcp__claude_ai_Slack__slack_list_channels","mcp__claude_ai_Slack__channels_list","mcp__claude_ai_Notion__notion-search","mcp__claude_ai_Notion__notion-fetch","mcp__claude_ai_Notion__notion-get-comments","mcp__claude_ai_Notion__notion-create-comment","mcp__claude_ai_Notion__notion-update-page","mcp__claude_ai_Notion__notion-create-pages","mcp__whatsapp__list_chats","mcp__whatsapp__list_messages","mcp__whatsapp__search_contacts","mcp__whatsapp__send_message","mcp__whatsapp__get_chat","mcp__whatsapp__get_message_context","mcp__whatsapp__archive_chat","mcp__whatsapp__resync_app_state","mcp__plugin_imessage_imessage__chat_messages","mcp__plugin_imessage_imessage__reply"] |
| effort | high |
| maxTurns | 60 |
OPS ► INBOX ZERO
⚠️ WHATSAPP TRANSPORT — MCP ONLY, NEVER wacli
For all WhatsApp operations in this skill (list chats, read messages, search contacts, send replies, archive chats), use the mcp__whatsapp__* tool family backed by the whatsmeow (Go) whatsapp-bridge — upstream lharries/whatsapp-mcp. (Earlier docs misnamed this as "Baileys" — Baileys is the Node.js WhatsApp library; this bridge uses go.mau.fi/whatsmeow.)
NEVER call the legacy wacli CLI (wacli chats list, wacli messages list, wacli send, wacli doctor, wacli history backfill, etc). The wacli store and keepalive daemon are deprecated for this skill.
If you find yourself reaching for any wacli ... shell command, stop and use the MCP tool with the same intent:
| Intent | ✅ Use this | ❌ Do NOT use |
|---|
| List recent chats | mcp__whatsapp__list_chats {sort_by: "last_active", limit: 25} | wacli chats list |
| Read full thread | mcp__whatsapp__list_messages {chat_jid, limit: 20} | wacli messages list |
| Full-text search | mcp__whatsapp__list_messages {query: "<text>", limit: 20} | wacli messages search |
| Resolve a contact | mcp__whatsapp__search_contacts {query: "<name>"} | wacli contacts |
| Send a reply (after approval) | mcp__whatsapp__send_message {recipient: "<JID>", message: "<text>"} | wacli send |
| Health check | lsof -i :8080 | grep LISTEN + (macOS) launchctl print "gui/$(id -u)/com.${USER}.whatsapp-bridge" / (Linux) systemctl --user is-active whatsapp-bridge.service | wacli doctor / ~/.wacli/.health |
| Trigger history backfill | curl -fsS -X POST http://127.0.0.1:8080/api/backfill (claude-ops patch — runs per-chat against the 50 most-recent chats; bridge also auto-backfills 5s after every Connected event) | — |
Rationale: the bridge exposes a typed MCP surface, returns consistent JSON shapes (is_from_me, content, timestamp, sender), supports FTS5 search natively, and avoids store-lock contention with the wacli keepalive daemon. Mixing the two surfaces caused inconsistent state in past sessions.
Sole exception: the ~/.wacli/.health file is still readable for legacy daemon-health surfacing in other skills, but no wacli command should be invoked from this skill.
Runtime Context
Standing default posture — read this first, every run. Before the offline scan-engine, before any per-channel MCP call, the default mode of this skill is: spin up one agent per available/configured channel (WhatsApp, Email, Slack, Telegram, iMessage, Notion, Discord — whichever are configured; see "Channel availability + fallback" below) via the Workflow tool (Agent Teams as the fallback), running in parallel. This is the standing default for the whole skill — you do not wait for triage to discover volume before fanning out; fanning out IS the first move. Each per-channel agent: deep-reads every conversation in its channel, clears the FULL-THREAD AWARENESS GATE, and — for any NEEDS_REPLY candidate — pulls full cross-channel context on that person/topic/thread (gmail search, whatsapp search, the ops-memories contact profile, etc. — per "FULL CONTEXT — NEVER ASSUME" / "FULL-CONTEXT RECALL + CROSS-CHANNEL DEDUP" below) before drafting a reply where one is genuinely needed. It reports back structured recommendations (classification + draft + reasoning, grounded in that cross-channel research) to the orchestrating session. "READ-ONLY" here means one thing only — never send, archive, mark-read, or mutate anything — it does NOT mean "stay inside your one assigned channel"; agents are granted the cross-channel read/search tools they need for context-gathering. Every send still goes through the main session's Rule-6 one-draft → one-approval → one-send gate, unchanged (see "Standing behavior: RUN WIDE" and "Workflow fan-out" below for the mechanics and hard constraints). The offline bin/ops-inbox-scan step below is a fast pre-filter that FEEDS this fan-out — not a replacement for it, so the per-channel agents aren't blindly scanning everything from scratch. The only exception: skip the fan-out in the genuinely trivial case (script covered everything, ~1-3 candidates left) — that stays the exception, not the default framing.
Before executing, load available context:
-
Auto-sync WhatsApp in the background (DEFAULT — every invocation) — the FIRST thing this skill does, before any scan or menu, is guarantee the store is fresh, then fire a recent-conversation history backfill and a contacts-link in the background, non-blocking.
0a. Freshness gate (run FIRST, blocking, bounded). Before classifying anything, run ~/bin/wa-inbox-fresh.sh (shipped by scripts/install-whatsapp-bridge-linux.sh). It probes the bridge with a real curl connection probe (curl -s -m4 http://127.0.0.1:8080/), forces a backfill, triggers voice-note transcription, and waits (bounded ~32s) for the newest message to settle, then prints a FRESHNESS report (newest message = … (N min old)). It only restarts the bridge if the curl probe genuinely fails twice — do NOT gate liveness on ss | grep :8080, because ss renders port 8080 as the service name webcache, so the grep never matches and you'd needlessly bounce a healthy bridge. Exit 2 means the bridge is down and unrecoverable → the store is STALE, do not trust last-sender classification.
Mac WhatsApp.app fallback (bridge-miss recovery)
The whatsmeow bridge can silently miss inbound messages when its history/app-state sync lags — most often on @lid chats (e.g. 2026-06-11 it missed a reply from a contact that the Mac WhatsApp.app had). The Mac app keeps an unencrypted local Core Data store at ~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ChatStorage.sqlite, readable over Tailscale SSH, so it is a reliable ground-truth backstop.
-
When it runs AUTOMATICALLY: wa-inbox-fresh.sh now invokes the Mac cross-check itself whenever the bridge store looks stale — on exit 2 (store unreadable) or when the newest message is >2h old, it prints a MAC GROUND TRUTH block (latest 10 messages from the Mac app store) inline in the freshness report. No orchestration needed.
-
When to use manually: a contact's known reply is missing from the bridge (common on @lid chats) — cross-check before classifying that thread as "no reply".
-
Command: bin/wa-mac-latest.sh --contact <name|number> [N] (also --recent [N], --since "YYYY-MM-DD HH:MM", add --json for machine-readable output). It reads ~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ChatStorage.sqlite over SSH. Schema: ZWAMESSAGE (ZTEXT, ZISFROMME, ZMESSAGEDATE = seconds since 2001-01-01) joined to ZWACHATSESSION (ZPARTNERNAME, ZCONTACTJID).
-
Transport chain (bin/wa-mac-transport.sh, shared by all wa-mac-* scripts): ① Tailscale/direct SSH (WA_MAC_SSH=user@host) → ② Cloudflare-tunnel SSH (WA_MAC_CF_HOST=ssh-mac.example.com, via cloudflared access ssh ProxyCommand) when Tailscale is down. One-time wiring: scripts/setup-wa-mac-cf-tunnel.sh (installs cloudflared locally + the Mac LaunchDaemon from a remotely-managed tunnel token, then verifies end-to-end). Both env vars live in the shell profile, never in the repo.
-
READ-ONLY ground truth for reads. The reader never writes and never sends. Sends still go through the whatsmeow bridge (mcp__whatsapp__send_message) under the Rule-6 outbound-approval gate — the Mac store is only consulted to confirm what actually arrived. The ONLY write-capable Mac surface is wa-mac-archive.sh (archive-only, see Tier 4 of the archive ladder).
-
Why no Linux-native alternative: there is no official WhatsApp Linux desktop app; the third-party Flatpak clients (whatsapp-for-linux, ZapZap) are Electron WhatsApp-Web wrappers that need a GUI, consume a linked-device slot, and store data in encrypted IndexedDB (not a queryable SQLite) — so the Mac ChatStorage.sqlite is the preferred backstop.
The FULL-THREAD AWARENESS GATE (in "Processing each channel") depends on this step having run first. That gate's "read both directions incl. [voice]" only works once wa-inbox-fresh.sh (freshness + backfill) and the voice-note transcription pass (step 0c) have completed and the store has settled — otherwise outbound rows and [voice] bodies are still missing and the gate reads an incomplete thread.
0b. Background backfill + contacts-link (idempotent, safe every time). The backfill pulls recent messages for the 50 most-active chats; the link populates messages.db.contacts from the whatsmeow session store so both <pn>@s.whatsapp.net and <lid>@lid chat JIDs resolve to names (without it the contacts table is empty and LID-format chats show raw phone numbers):
BR="${WHATSAPP_BRIDGE_DIR:-$HOME/.local/share/whatsapp-mcp/whatsapp-bridge}"
if curl -s -o /dev/null -m 4 http://127.0.0.1:8080/ 2>/dev/null; then
curl -fsS -m 10 -X POST http://127.0.0.1:8080/api/backfill >/dev/null 2>&1 &
[ -f "$BR/link_contacts.py" ] && python3 "$BR/link_contacts.py" >/dev/null 2>&1 &
fi
Kick this off, then continue with the steps below while it runs — give the link ~2s before name-resolving chats. link_contacts.py resolves names via whatsmeow_contacts + whatsmeow_lid_map (name preference: full_name → first_name → push_name → business_name). It ships via scripts/install-whatsapp-bridge-linux.sh into the bridge dir; recreate it from whatsmeow_contacts/whatsmeow_lid_map if absent.
0c. Voice notes are first-class. Incoming voice notes (media_type='audio', empty content) are auto-transcribed into content as [voice] <text> by the whatsapp-transcribe.timer (systemd-user, every 10 min, OpenAI whisper-1) — and wa-inbox-fresh.sh triggers a transcribe pass on every scan. So a voice note shows up in NEEDS_REPLY / thread scans exactly like a text message; treat a [voice] … body as the sender's words. Transcription is idempotent (only ever fills empty audio rows, never clobbers real text) and capped per run, so it never re-bills or stacks.
0c-bis. ALL media is now first-class, not just voice. Beyond voice→[voice] (transcribe above), incoming video / image / document media (empty content) is auto-enriched into content as [video] … / [image] … / [document] … by transcriber/enrich_media.py (vision for stills/video frames + Whisper for any audio track) on the whatsapp-enrich.timer (systemd-user, every 10 min) — and wa-inbox-fresh.sh queues an enrich pass on every scan. So an image, clip, or PDF shows up in NEEDS_REPLY / thread scans with a real, readable body, exactly like text. Enrichment is idempotent (only fills empty media rows) and capped per run. The bridge also self-heals media that 403/404/410s (stale directPath, common for larger media) by asking the sender's phone to re-upload via SendMediaRetryReceipt (apply-patches.py Fix M), so large media never silently drops.
0d. The scan engine self-refreshes + self-reconciles on EVERY run — this is automatic, you do not orchestrate it. bin/ops-inbox-scan (the primary classifier, step "Scan engine" below) now does the refresh/pull itself, BLOCKING and bounded, before it classifies — so the data is converged by the time you read its JSON, regardless of whether the background ops-inbox-autosync hook has finished. On each invocation the scan:
- Refreshes (frontfill/backfill): if the bridge is reachable on
:8080, it fires POST /api/backfill + link_contacts.py, then waits (bounded ~18s) for the newest stored message timestamp to stop advancing so the classify pass reads a settled store. This is the blocking guarantee the background hook alone does NOT give. Skip with OIS_NO_REFRESH=1 (set automatically on repeat calls in one session to avoid re-waiting).
- Reconciles outbound sends (owner directive 2026-06-05 "include all things I sent to all people"): it reads the bridge's outbound-send journal (
journalctl --user -u whatsapp-bridge.service, or the bridge log file on non-systemd hosts) into a {recipient_jid → latest_send_epoch} map, and demotes any NEEDS_REPLY thread whose last inbound is older than a send to any of that person's JIDs (reconciled flag set, moved to WAITING). This catches replies that went out via /api/send or a phone send that has not yet landed in messages.db — the single most common false-NEEDS_REPLY. Only epoch-stamped send lines drive demotion (a send that genuinely predates the inbound never demotes).
Net effect: running /ops:ops-inbox autonomously pulls the latest state AND folds in everything the user already sent, with zero extra orchestration on your part — just read the scan JSON. A reconciled field on a WAITING item means "already answered, reply not yet in the store"; never re-draft it. You still clear the FULL-THREAD AWARENESS GATE on whatever genuine NEEDS_REPLY candidates remain.
-
Self-heal plugin version pin — if any ${CLAUDE_PLUGIN_DATA_DIR} file or ~/.claude/plugins/installed_plugins.json references a cache/ops-marketplace/ops/X.Y.Z/ path that no longer exists on disk, downstream hooks (stop-all.sh, ops-post-session-cleanup) emit Plugin directory does not exist. Resolve before scanning:
INSTALLED="$HOME/.claude/plugins/installed_plugins.json"
CACHE_DIR="$HOME/.claude/plugins/cache/ops-marketplace/ops"
PINNED=$(python3 -c "import json; d=json.load(open('$INSTALLED')); print(d.get('plugins',{}).get('ops@ops-marketplace',[{}])[0].get('version',''))")
LATEST=$(ls "$CACHE_DIR" 2>/dev/null | sort -V | tail -1)
if [ -n "$PINNED" ] && [ -n "$LATEST" ] && [ "$PINNED" != "$LATEST" ] && [ ! -d "$CACHE_DIR/$PINNED" ]; then
python3 -c "
import json
p='$INSTALLED'; d=json.load(open(p))
for e in d.get('plugins',{}).get('ops@ops-marketplace',[]):
if e.get('version')=='$PINNED':
e['version']='$LATEST'
e['installPath']='$CACHE_DIR/$LATEST'
json.dump(d, open(p,'w'), indent=2)
"
bash "$HOME/.claude/scripts/hooks/ops-plugin-version-heal.sh"
fi
The existing ops-plugin-version-heal.sh only rewrites downstream targets from installed_plugins.json (the source of truth). When the source itself is stale, the heal hook is a no-op — patch it first, then re-run the hook.
-
Preferences: Read ${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.json
default_channels — which channels to scan by default
secrets_manager / doppler — how to resolve channel credentials if not in env
-
Daemon health: Read ${CLAUDE_PLUGIN_DATA_DIR}/daemon-health.json
- Check
whatsapp-bridge status — verify com.${USER}.whatsapp-bridge is running (lsof -i :8080 or launchctl print "gui/$(id -u)/com.${USER}.whatsapp-bridge")
- Also verify the ops mcp-proxy is up on
:8090 (lsof -i :8090 | grep LISTEN) — Claude's MCP client connects through the proxy SSE endpoint, not directly to the bridge. If :8080 is up but :8090 is down, mcp__whatsapp__* tools will never load.
- If either layer is down, surface the issue before WhatsApp operations
- Do not declare WhatsApp MCP unavailable purely because tools haven't loaded yet — when both ports are LISTEN, retry
ToolSearch select:mcp__whatsapp__list_chats,... up to 3× at 5s intervals to let the SSE handshake complete
-
Ops memories: Check ${CLAUDE_PLUGIN_DATA_DIR}/memories/ before drafting any reply:
contact_*.md — load profile for the contact you're about to reply to
preferences.md — apply user's communication style and language preferences
topics_active.md — check for active threads or deadlines related to this contact
donts.md — never violate these restrictions in drafts
-
Launch the live inbox watcher (background job, every session) — right after the steps above, start bin/ops-inbox-live-watch.sh via Bash with run_in_background: true. It polls the Gmail inbox (via gog) every ~4 minutes and exits — with a NEW INBOX MAIL: <from> | <subject> | <date> | id=<id> summary line — the instant a genuinely new inbound message lands; it also exits (with a watcher expired line) after ~6h if nothing new arrives. The job's exit IS the new-mail ping — treat it exactly like a direct orchestrator notification: when it fires, re-scan the affected channel and relaunch the watcher (same command) so live coverage never lapses. Pure bash + python3 + gog, no systemd/launchd dependency, cross-platform (Linux + macOS).
CLI/API Reference
whatsapp-bridge (WhatsApp — mcpwhatsapp*)
Bridge health — check bridge is running before any WhatsApp operation. Same lsof probe across platforms; supervisor command differs:
lsof -i :8080 | grep LISTEN
launchctl print "gui/$(id -u)/com.${USER}.whatsapp-bridge" 2>&1 | head -3
systemctl --user is-active whatsapp-bridge.service
journalctl --user -u whatsapp-bridge.service -n 10 --no-pager
One-line cross-platform restart — use the in-repo wrapper when you don't want to branch on uname yourself:
bash "$CLAUDE_PLUGIN_ROOT/scripts/lib/whatsapp-bridge-up.sh"
It restarts via launchctl on Darwin and systemctl --user on Linux, then waits up to 5s for :8080 to come up.
If you need the raw recipes:
macOS (handles the "service not loaded" case that breaks bare kickstart):
LABEL="com.${USER}.whatsapp-bridge"
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
TARGET="gui/$(id -u)/${LABEL}"
if ! launchctl kickstart -k "$TARGET" 2>/dev/null; then
[ -f "$PLIST" ] && launchctl load -w "$PLIST"
sleep 2
launchctl kickstart -k "$TARGET" 2>/dev/null || true
fi
sleep 5
lsof -i :8080 | grep -q LISTEN && echo "bridge up" || echo "bridge FAILED — check ~/.local/share/whatsapp-mcp/whatsapp-bridge/logs/bridge.err.log"
Linux (systemd-user — the install script's standard path):
systemctl --user daemon-reload
systemctl --user restart whatsapp-bridge.service
sleep 5
lsof -i :8080 | grep -q LISTEN && echo "bridge up" || journalctl --user -u whatsapp-bridge.service -n 30 --no-pager
Why the macOS recipe matters: bare launchctl kickstart -k gui/$UID/<label> exits with Could not find service if the LaunchAgent isn't loaded (common after reboot, plist edits, or when the daemon hasn't auto-registered). Always quote the target string and fall back to launchctl load -w before retrying.
First-time Linux install — if the bridge isn't installed yet on a Linux host:
bash "$CLAUDE_PLUGIN_ROOT/scripts/install-whatsapp-bridge-linux.sh" --wa-phone <E.164>
This clones lharries/whatsapp-mcp into ~/.local/share/whatsapp-mcp, applies the in-repo claude-ops patches (Fix A/B pair-phone hardening, auto-backfill on Connected, POST /api/backfill REST endpoint, crash-safe requestHistorySync, Python LID↔phone↔contact resolver), drops the systemd-user units (whatsapp-bridge.service, whatsapp-backfill.{service,timer}, whatsapp-transcribe.{service,timer}), installs the voice-note transcriber (transcriber/transcribe_voice_notes.py), the media enricher (transcriber/enrich_media.py, with whatsapp-enrich.{service,timer}) and the pre-scan freshness gate (~/bin/wa-inbox-fresh.sh), enables linger, and emits the pairing code via journalctl --user -u whatsapp-bridge -f. Idempotent: re-running is safe and updates patches in place. Pass --no-transcribe-timer to skip voice-note transcription, --no-enrich-timer to skip video/image/document enrichment. The transcribe and enrich services read OPENAI_API_KEY from ~/.config/systemd/env/mcp-secrets.env. The media-retry self-heal (Fix M) is part of the bridge patch set, no extra flag.
MCP tools (use these instead of any wacli CLI command):
| Tool | Usage | Output |
|---|
mcp__whatsapp__list_chats | {sort_by: "last_active"} | Array of chats with jid, name, last_message_time |
mcp__whatsapp__list_messages | {chat_jid, limit, query} | Array of messages with id, sender, content, timestamp, is_from_me |
mcp__whatsapp__search_contacts | {query} | Contacts matching name or phone |
mcp__whatsapp__send_message | {recipient, message} | Send result |
mcp__whatsapp__get_chat | {chat_jid} | Chat metadata |
mcp__whatsapp__get_message_context | {chat_jid, message_id} | Message context window |
mcp__whatsapp__archive_chat | {chat_jid, archive: true} | Archive (or unarchive with archive: false) a chat — sends app-state mutation via whatsmeow |
mcp__whatsapp__resync_app_state | {name: "regular_low", full_sync: true, skip_bad: true} | Force full app-state resync — run when archive fails with LTHash mismatch (server/local desync) |
Bulk archive non-actionable WA chats — for newsletters, dead group chats, one-word reactions, etc.:
DB="${WHATSAPP_BRIDGE_DB:-$HOME/.local/share/whatsapp-mcp/whatsapp-bridge/store/messages.db}"
for jid in "<NEWSLETTER_JID>@newsletter" "<GROUP_JID>@g.us" "<CONTACT_PHONE>@s.whatsapp.net"; do
curl -s -X POST http://localhost:8080/api/archive \
-H 'Content-Type: application/json' \
-d "{\"chat_jid\":\"$jid\",\"archive\":true}"
done
Archive state is locally queryable (Fix H — bridge persists archived flag in chats table):
sqlite3 "$DB" "SELECT jid, name, last_message_time FROM chats WHERE archived=0 ORDER BY last_message_time DESC;"
sqlite3 "$DB" "SELECT jid, archived FROM chats WHERE jid='<JID>';"
Full-text search — use mcp__whatsapp__list_messages with a query param (backed by FTS5 after running scripts/whatsapp-bridge-migrate.sh):
DB="${WHATSAPP_BRIDGE_DB:-$HOME/.local/share/whatsapp-mcp/whatsapp-bridge/store/messages.db}"
sqlite3 "$DB" "SELECT chat_jid, sender, content, timestamp FROM messages WHERE rowid IN (SELECT rowid FROM messages_fts WHERE messages_fts MATCH '<query>') ORDER BY timestamp DESC LIMIT 20;"
Contact lookup — use mcp__whatsapp__search_contacts or query contacts table directly:
sqlite3 "$DB" "SELECT jid, name, phone FROM contacts WHERE name LIKE '%<name>%' COLLATE NOCASE LIMIT 10;"
History backfill — the whatsmeow bridge automatically syncs history on connection. No manual backfill command exists; if messages are missing, restart the bridge using the robust recipe above (load-then-kickstart).
gog CLI (Gmail/Calendar)
| Command | Usage | Output |
|---|
gog gmail search "in:inbox" --max 50 -j --results-only --no-input | Full inbox scan | JSON array of threads |
gog gmail thread get <threadId> -j | Get full thread with all messages | Full message JSON |
gog gmail get <messageId> -j | Get single message | Message JSON |
gog gmail raw <messageId> | Dump lossless raw Gmail API JSON — includes authoritative labelIds | Raw message JSON |
gog gmail archive <messageId> [<messageId>...] --force | Archive — removes the INBOX label (dedicated archive action; --force/-y skips confirm; add --no-input for CI) | Archive result |
gog gmail archive --query "<gmail-query>" --max N --force | Archive by query | Archive result |
gog gmail messages modify <messageId> --add <LABEL> --remove <LABEL> | Edit labels only (NOT archive — use the archive subcommand above for that) | Labels result |
gog gmail send --to "<email>" --subject "<subj>" --body "<body>" | Send email | Send result |
gog gmail send --reply-to-message-id <msgId> --reply-all --body "text" | Reply all | Send result |
gog gmail send --to "<email>" --subject "<subj>" --body "<body>" --track | Send with open-tracking pixel (requires tracking setup — see Open Tracking section) | Send result + tracking-id |
gog gmail track status | Show tracking configuration status | configured: true/false |
gog gmail track opens [<tracking-id>] --since <duration> --to <email> -j | Query email opens for a tracking-id (or all recent opens) | JSON array of open events |
gog gmail mark-read <messageId> ... --no-input | Mark as read | Result |
gog gmail labels list -j | List all labels | Labels JSON |
Known trap — archive verification: do NOT verify an archive with gog gmail search "in:inbox". That search result is cached/stale and keeps returning already-archived messages, making archive look like it failed when it succeeded. Verify the live label state instead:
gog gmail raw <messageId> | python3 -c "import json,sys; d=json.load(sys.stdin); print('INBOX' in d.get('labelIds',[]))"
Standing behavior: RUN WIDE — parallel subagents / agent-teams / workflow by DEFAULT
Every /ops:ops-inbox run should be fast for the owner, whose time is spent only reviewing and approving — never waiting on serial reads. By default:
- Fan out the moment there is more than a trivial amount of work. After the offline
ops-inbox-scan first pass, push the per-thread deep-read / dedup / context-gathering / draft-writing into parallel background workers — Workflow fan-out (preferred) or an Agent-Teams read-only scanner per channel/thread-chunk. Do NOT deep-read dozens of threads serially in the main session.
- Do research, context-gathering, and draft-writing in the background while the owner works. Kick off the readers/drafters; let them build full-thread arcs, cross-channel dedup, contact profiles, and staged draft text concurrently. Surface results as they land so the owner approves in a steady stream instead of after one big serial pass.
- Parallelism NEVER changes the safety model. Workers are strictly READ-ONLY — they classify and return draft text only. Every outbound send stays in the main session, one draft → one
AskUserQuestion → one approval → one send (Rule 6 + PER-DRAFT APPROVAL).
- Respect the box concurrency ceiling (heartbeat
MAX_BUSY) — queue extra work rather than exceeding it.
Scan engine — offline script triages first, Workflow fan-out is the DEFAULT for deep per-thread work
Run bin/ops-inbox-scan FIRST. It is the primary scan engine. It classifies the two
heaviest channels — WhatsApp (direct read of the whatsmeow sqlite store) and Email (one
gog gmail search) — deterministically, in-process, in well under a second, emitting compact
JSON. No subagents, no MCP, near-zero tokens.
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-scan" --pretty
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-scan" --whatsapp-only
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-scan" --days 14
ONE-SHOT TRIAGE (Sam 2026-07-21): bin/ops-inbox-zero attempts the inbox-zero
pipeline (scan → Paperclip SSOT → Slack direct API → dual-JID deep-read → proposed WA/email
archive actions → KEEP report) in one shell call. Gmail, Slack, or Paperclip can be skipped
when authentication or local services are unavailable; the report surfaces each status.
The agent still does the manual nuance
refinement (unsure rows + Rule-6 inline drafts + Telegram AskUserQuestion), but the script
handles the deterministic 80% in ~5s so the agent only spends tokens on judgment calls.
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-zero"
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-zero" --archive
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-zero" --no-slack
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-zero" --days 14
Always run the report-only command first. Present the exact proposed WhatsApp and Gmail
archive items/counts with AskUserQuestion; only after explicit approval rerun the same
arguments with --archive. Never infer approval from a previous run. unsure items are
report-only and are never archived or unarchived by the script.
Outputs:
/tmp/ops-inbox-scan-clean.json — raw scan output
/tmp/ops-inbox-keep.json — classified KEEP/ARCHIVE/UNSURE rows (incl. Slack DMs)
/tmp/slack-triage.txt — Slack direct-API triage (AUTH + human DMs)
~/.local/share/ops-inbox/keep-<ts>.md — final report (agent reads this for Telegram)
Why this exists: the multi-channel scan used to fan out one Workflow subagent per
channel. A single real run burned ~330k subagent tokens / 5 agents / ~130s to do work
that, for WhatsApp, is a sqlite read, and for Email, is a CLI call. The script does the same
classification (and better — it merges each person's lid↔phone chats into one
conversation and resolves real names from contacts) for free. Reserve agent fan-out for
genuine reasoning, not for reading a database.
ops-inbox-scan output (always valid JSON, even on partial failure):
{
"generated_at": "…", "window_days": 7,
"whatsapp": {
"needs_reply": [ { "who", "jid", "alt_jids", "last_message_at", "age_min",
"last_from_me", "preview":[{from_me,text}] } ],
"waiting": [ … ],
"groups": [ … ],
"fyi": [ … ]
},
"email": { "reachable": true, "needs_reply":[…], "waiting":[…], "fyi":[…] },
"counts": { … }, "notes": [ … ]
}
What the script does NOT do — and what you do next, in the MAIN session (no subagents):
- Slack — one
mcp__slack__conversations_unreads {include_messages:true} call. One
round-trip; a subagent is pure overhead. Skip entirely if prefs show 0 workspaces.
- Telegram — one
mcp__plugin_ops_telegram__list_dialogs call (skip the
@SamCloudDevBot / Pocket ops bot dialog — that's automation). Skip if unconfigured.
- FULL-THREAD AWARENESS GATE on the few NEEDS_REPLY candidates — the script's WhatsApp
buckets are merged-thread, last-direction-correct first passes; its
groups entries
are explicitly un-classified. Its email needs_reply is an envelope first pass. Before
you draft ANY reply, clear the gate per "Processing each channel": for the handful of
candidates, read the full thread both directions (incl. [voice]), write the 2-sentence
arc, reconcile the user's own phone-sent messages, and demote anything already answered.
You are now doing deep reads on ~3 threads, not scanning hundreds — that is the whole
point of the split: cheap script-side triage, expensive reasoning only where it pays.
When to use the Workflow fan-out below — DEFAULT for real per-thread volume: the offline
ops-inbox-scan is the cheap first-pass triage and always runs first, but the per-thread
deep-read/draft work (clearing the FULL-THREAD AWARENESS GATE + cross-channel dedup on every
candidate) is reasoning work, and reasoning work fans out in parallel by default. The
moment there is real volume — more than a handful of NEEDS_REPLY candidates across channels,
a Slack/Telegram/iMessage backlog of human threads, or any channel the script can't reach —
default to the Workflow fan-out: launch one read-only drafter/scanner agent per
channel (or per thread-chunk within a channel), run them concurrently, and synthesize. This
is faster and more token-efficient than serially deep-reading dozens of threads in the main
session, and it collapses wall-clock to the slowest single channel.
The only time you skip the fan-out is the genuinely trivial case: the script already
covered everything and only ~1–3 threads need a deep read (e.g. WA + email with a couple of
candidates and a glance at Slack/Telegram). Then the script + a couple of inline MCP calls is
enough and a fan-out would be pure overhead. For anything with real per-thread volume, the
fan-out is the default — not a fallback.
Either way, the fan-out NEVER sends, archives, or mutates. Scanner/drafter agents are
strictly read-only and return classifications + draft text; every outbound send stays in
the main session under the Rule-6 one-draft → one-approval → one-send gate. Defaulting to
parallel fan-out changes only HOW threads are read and drafted, never the outbound-approval
safety.
Workflow fan-out (DEFAULT for real per-thread volume — per the note above)
When there is real per-thread volume to deep-read and draft, use the Workflow tool as
the default path: fan out one read-only scanner/drafter agent per channel (or per
thread-chunk within a high-volume channel), then synthesize. Channels and chunks are
processed concurrently and wall-clock collapses to the slowest single unit. Do not fan out
for channels the offline script already fully triaged down to ~1–3 trivial candidates —
that re-burns the tokens the cheap-triage split exists to save. The rule of thumb: offline
script triages always; the Workflow fan-out does the deep per-thread reasoning whenever
volume is more than a glance.
Hard constraints (these override convenience — they are how this stays Rule-6-safe):
- Read-only scanners — Rule 6. Every scanner agent's prompt MUST state, verbatim in
spirit: "You are READ-ONLY. Do NOT send, archive, mark-read, or mutate anything. Only
read / search and classify. Return structured results." "READ-ONLY" means exactly
one thing here — never send, archive, mark-read, or mutate. It does NOT mean "stay inside
your one assigned channel." Each agent still owns its assigned channel's classification
pass, but for any NEEDS_REPLY candidate it must pull full cross-channel context on that
person/topic/thread before drafting — per "FULL CONTEXT — NEVER ASSUME" and "FULL-CONTEXT
RECALL + CROSS-CHANNEL DEDUP" below (gmail search across the person's email, whatsapp
search/list_messages for mentions elsewhere, the ops-memories
contact_*.md profile,
etc.) — not just read its own channel's thread in isolation. It then returns a
recommendation AND a pre-written draft (when one is warranted), grounded in that full
research, not a raw single-channel classification. All sending stays in the main
session, one draft → one approval → one send. The workflow NEVER sends, archives, or
mutates — it only reads (across whatever channels the candidate needs) and classifies.
- Detect availability FIRST. Only fan out a scanner for a channel that already passed
the per-channel checks in "Channel availability + fallback". Never spawn a scanner for an
unconfigured / unreachable channel — it burns a turn and produces a misleading
"unreachable" row. Build the workflow's channel list from the channels you confirmed up.
- No
AskUserQuestion inside the workflow. Presentation, reply drafting, approval,
archive, and the Cron offer all happen back in the main session after the workflow
returns. Workflow agents cannot gate sends, so they must never try.
- Each scanner loads its own channel's MCP tools via
ToolSearch select:... before use,
and honours the documented reconnect handshake (WhatsApp 3× at 5s, iMessage 5s→15s) before
reporting a channel unreachable. Never fabricate conversations. Also grant each agent the
read-only cross-channel search tools it needs for context-gathering — at minimum gmail
search/thread-read, whatsapp search/list_messages, and the ops-memories contact registry —
so it can look up the same person/topic across other channels before drafting, not just
its own assigned channel's tools.
Canonical scan workflow. Pass the available channels in via args (the orchestrator
builds the list from the detected-available channels), so the script body stays stable:
⚠️ When invoking Workflow, you MUST pass the channel task list via the tool call's
top-level args parameter (as shown below) — not just referenced inside the script body.
Omitting it silently produces zero agents and an empty result, not an error. A real run
failed this way: args was written into the script text but never passed as the tool's
args parameter, so the workflow spawned nothing and returned nothing to synthesize.
Workflow({
args: [
{
key: 'email',
select: 'select:mcp__gog__gmail_search,mcp__gog__gmail_read_thread,mcp__gog__gmail_labels',
steps:
'gmail_search "in:inbox newer_than:7d"; labels+from on the search envelope are first-pass only — before any NEEDS_REPLY, gog gmail thread get per candidate and clear the FULL-THREAD AWARENESS GATE (full thread both directions, 2-sentence arc, reconcile SENT).',
},
{
key: 'slack',
select:
'select:mcp__slack__conversations_unreads,mcp__slack__channels_list,mcp__slack__conversations_history,mcp__slack__conversations_replies',
steps: 'conversations_unreads to find unread DMs/channels; read latest via history/replies.',
},
{
key: 'whatsapp',
select:
'select:mcp__whatsapp__list_chats,mcp__whatsapp__list_messages,mcp__whatsapp__search_contacts,mcp__whatsapp__get_chat',
steps:
'list_chats {sort_by:"last_active"}; last_is_from_me is ONLY a first pass. FIRST merge each person lid<->phone chats into one conversation via whatsmeow_lid_map (store/whatsapp.db) so a contact is not double-counted as NEEDS_REPLY on @lid and WAITING on the phone JID. Then, before any NEEDS_REPLY, clear the FULL-THREAD AWARENESS GATE: list_messages {chat_jid, limit: 25} for EACH mapped JID (or the DB union recipe), merge by timestamp, read BOTH directions including is_from_me=1 rows and [voice] transcripts, write the 2-sentence arc summary, and reconcile the user own sends that may be missing from the store. Never classify from the last message alone.',
},
{
key: 'imessage',
select: 'select:mcp__plugin_imessage_imessage__chat_messages',
steps:
'chat_messages {limit:30} (omit chat_guid); classify each thread by who sent the LAST message. Capture the chat_id GUID from each header.',
},
{
key: 'telegram',
select:
'select:mcp__plugin_ops_telegram__list_dialogs,mcp__plugin_ops_telegram__get_messages,mcp__plugin_ops_telegram__search_messages',
steps: 'list_dialogs (last 7d); get_messages for dialogs with pending activity.',
},
],
script: `
export const meta = {
name: 'ops-inbox-scan',
description: 'Read-only parallel scan + classify of all available comms channels',
phases: [{ title: 'Scan' }, { title: 'Synthesize' }],
}
const SCAN_SCHEMA = {
type: 'object', additionalProperties: false,
required: ['channel', 'reachable', 'conversations'],
properties: {
channel: { type: 'string' },
reachable: { type: 'boolean', description: 'true ONLY if tools were actually called and returned data' },
note: { type: 'string', description: 'tools called, or the exact error if unreachable' },
conversations: { type: 'array', items: {
type: 'object', additionalProperties: false,
required: ['who', 'summary', 'status'],
properties: {
who: { type: 'string' },
summary: { type: 'string', description: 'one line: what is pending' },
status: { type: 'string', enum: ['NEEDS_REPLY', 'WAITING', 'HANDLED', 'FYI'] },
chatId: { type: 'string', description: 'JID / chat GUID / threadId needed to reply — capture it now' },
lastMessageAt: { type: 'string' },
},
}},
},
}
phase('Scan')
// args can arrive as a JSON string (harness serialization) — parse defensively
// so the fan-out never dies with "args.map is not a function".
const CHANNELS = (typeof args === 'string' ? JSON.parse(args) : args) || []
const scans = (await parallel(CHANNELS.map(c => () =>
agent(
\`READ-ONLY inbox scanner for the "\${c.key}" channel. You MUST NOT send, archive, \` +
\`mark-read, or mutate anything — read / search ONLY.\\n\` +
\`STEP 1: run ToolSearch with query exactly "\${c.select}" to load the tool schemas.\\n\` +
\`STEP 2: \${c.steps}\\n\` +
\`Classify each conversation NEEDS_REPLY / WAITING / HANDLED / FYI exactly as STEP 2 \` +
\`directs (including merged-thread / full-thread rules where specified). Capture chatId \` +
\`for each (needed later to reply). Cover ~last 7 days plus \` +
\`anything clearly still open. Retry the documented reconnect handshake before reporting \` +
\`reachable=false. Never fabricate conversations.\`,
{ label: \`scan:\${c.key}\`, phase: 'Scan', schema: SCAN_SCHEMA }
)
))).filter(Boolean)
phase('Synthesize')
return await agent(
\`You are READ-ONLY. Do NOT send, archive, mark-read, or mutate anything — only merge \` +
\`and order the data below.\\n\` +
\`Per-channel read-only scan results as JSON:\\n\${JSON.stringify(scans, null, 2)}\\n\\n\` +
\`Return ONLY structured JSON with buckets: needsReply[], waiting[], fyi[], unreachable[]. \` +
\`Each item: {channel, who, summary, chatId, lastMessageAt}. Order needsReply most-urgent \` +
\`first. Do NOT draft replies — that happens in the main session under the per-message gate.\`,
{ label: 'synthesize', phase: 'Synthesize',
schema: { type: 'object', additionalProperties: true } }
)
`,
});
After the workflow returns the synthesized buckets, proceed to presentation + reply in
the main session using the per-channel sections below. Stage every reply one-at-a-time
under Rule 6 (one draft → AskUserQuestion / approval word → send → next). The workflow
gave you what needs a reply and the chatId to reach it; it never sent anything.
Fallback — Agent Teams support
When the Workflow tool is unavailable (older harness) but
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 is set, fall back to Agent Teams for the
"all channels" path — same read-only fan-out, just without the Workflow harness. Set up one
read-only scanner teammate per available channel:
TeamCreate("inbox-channels")
Agent(team_name="inbox-channels", name="whatsapp-scanner", ...) # READ-ONLY
Agent(team_name="inbox-channels", name="email-scanner", ...) # READ-ONLY
Agent(team_name="inbox-channels", name="slack-scanner", ...) # READ-ONLY
Agent(team_name="inbox-channels", name="telegram-scanner", ...) # READ-ONLY
Each teammate scans its channel and reports classified results back; you can steer
("focus email first") and process replies as they land. Agent Teams' advantage over the
Workflow path is mid-flight steering and shared context (one scanner can flag a message
referencing another channel). If neither Workflow nor Agent Teams is available, scan
channels sequentially in the main session.
Every fallback keeps the same read-only + Rule 6 constraints — each scanner teammate's
prompt MUST say "You are READ-ONLY. Do NOT send any outbound messages. Return drafts to the
orchestrator who stages them one-by-one." Sending stays in the main session, always.
Pre-gathered data
${CLAUDE_PLUGIN_ROOT}/../../bin/ops-unread 2>/dev/null || echo '{}'
Environment variables
All channel credentials come from env vars or CLI auth — no hardcoded secrets.
| Variable | Default | Purpose |
|---|
GMAIL_ACCOUNT | auto-detect | Gmail account for gog CLI |
SLACK_MCP_ENABLED | false | Set true when Slack MCP server is configured |
TELEGRAM_ENABLED | false | Set true when Telegram user-auth MCP is configured |
NOTION_MCP_ENABLED | false | Set true when Notion MCP integration is configured |
WHATSAPP_BRIDGE_DB | ~/.local/share/whatsapp-mcp/whatsapp-bridge/store/messages.db | Bridge messages DB path |
Core principle: INBOX ZERO (the goal of this skill — NON-NEGOTIABLE)
The success metric of /ops:ops-inbox is an EMPTY inbox on EVERY channel — not "surfaced the NEEDS_REPLY". Every conversation that no longer needs the user's eyes, action, or reaction MUST be archived in the same run. This is mandatory, not a nicety:
Paperclip SSOT + WA archive API (Sam 2026-07-19 — hard):
- When Paperclip is on the box, before KEEP-classifying or drafting money/legal/hire/product: scan open issues + pending
issue_thread_interactions for that counterparty/thread id (e.g. a payment-round thread → its tracking issue, a card/supplier thread → its issue, a comp/hire thread → its issue). Resolve the concrete counterparty→issue mappings from your local prefs/board, not from this public skill. Prefer the board gate + staged draft over a new Telegram AskUserQuestion when the same decision is already wired. Never re-ask a locked Q or contradict answered options.
- WhatsApp archive is mandatory every pass — not “demote only.” After classify, call the bridge archive endpoint on every non-actionable chat and every chat you just replied to (phone
@s.whatsapp.net and every @lid / alt_jids):
curl -s -X POST http://127.0.0.1:8080/api/archive \
-H 'Content-Type: application/json' \
-d '{"chat_jid":"<jid>","archive":true}'
MCP equivalent: mcp__whatsapp__archive_chat {chat_jid, archive: true}. Report archived count. Leave only true NEEDS_REPLY / creative-hold / crisis-surface chats unarchived. Demote-without-archive is a defect (phone inbox still noisy).
🚫 HARD GUARDRAIL — NEVER ARCHIVE A TODO/ACTION-LABELED EMAIL UNTIL VERIFIED DONE (overrides every archive rule below).
An email carrying ANY of the user's todo/action labels is an open task the user is tracking — it MUST stay in the inbox until the task is verified complete, regardless of how it looks to the classifier (even if the subject reads like spam, a newsletter, or an automated notification, and even if it lands in WAITING/FYI). Treat the label as the user's explicit intent and obey it over your own classification.
- Protected labels (case-insensitive, match by name):
To Respond, Respond, Reply, Reply Later, Action, Needs Action, Follow up, Awaiting Reply, To-do / Todo, Tasklet, Timed Actions, and any label whose name contains to-do/todo/task/action/respond/follow up/reply later/needs action/awaiting reply — except completion labels like Actioned or any name containing actioned (those signal verified done, not open todos). (Skip Gmail system labels INBOX/SENT/DRAFT/UNREAD/IMPORTANT/STARRED/CATEGORY_* — those are not todo markers.)
- Check before archiving ANY email. Read the message's
labelIds (gog gmail raw <messageId> is authoritative) and if it carries a protected label, KEEP it — do not archive, do not count it in the archive set.
- "Verified done" = the protected label has been removed, OR the thread was moved to a completion label (e.g.
Actioned), OR the user explicitly says it's handled. Only then may it be archived. Archiving a still-todo-labeled email is a defect, not cleanup — surface it as a kept actionable item instead.
- This applies on every channel that exposes labels/flags (email today; extend to any future labeled channel). For label-less channels (WhatsApp/iMessage), the equivalent is "never archive a thread with a live unresolved action item" per rule 1.
-
Archive everything that isn't a live action item. FYI/noise/newsletters/bot channels, concluded threads, courtesy closes, reaction-only tails — and WAITING (you-sent-last) too. Archiving is reversible and WhatsApp/email auto-resurface a chat the instant a new message lands, so archiving WAITING loses nothing. The only things left visible after a run are genuine open NEEDS_REPLY items — including finance, legal, and personal threads, which are handled exactly like any other thread: draft the appropriate reply or take the required action, investigate as needed, and archive once fully handled. Nothing with a live unresolved action item gets archived regardless of category — and per the HARD GUARDRAIL above, nothing carrying a todo/action label is archived until that task is verified done, even if it would otherwise classify as FYI/noise/WAITING. All outbound sends on any thread — including finance, legal, and personal — still require per-message approval via the outbound-comms gate (Rule 6).
-
After you reply to anyone, IMMEDIATELY archive that chat — reply→archive is one atomic step. Never leave a just-answered thread sitting in the list.
-
include_context: true is the HARD DEFAULT on every list_messages read. Never pass false — you must always see the surrounding thread to understand what a message is about before classifying or drafting.
-
Verify the bridge is FULLY up before trusting any classification — run wa-inbox-fresh.sh, confirm the systemd unit is active and :8080 LISTEN, and do a real read. A stale store mis-classifies last-sender.
-
Don't ask per-archive once this rule is in play — the user wants efficiency. Archive the DONE set, then report the archived COUNT in one line and keep only the KEEP set visible.
-
WhatsApp archive heal: the bridge /api/archive can hang / 409 with LTHash mismatch when app-state desyncs. Escalation ladder (try each in order, stop when archive succeeds):
Tier 1 — transient desync (most common): run mcp__whatsapp__resync_app_state {name:"regular_low", full_sync:true, skip_bad:true}, wait ~5s, retry archive. skip_bad:true skips server-side patches that permanently fail LTHash verification; without it the loop re-fails on the same patch.
Tier 2 — still failing after resync: POST /api/reconcile_archived to rebuild chats.archived from the phone's authoritative state without re-pairing. Returns {"archived_count":N,"non_archived_count":M}. Then retry archives.
Tier 3 — massively poisoned chain (verified 2026-06-10, 31/31 batch archives at ≥2 s pacing):
Tier 4 — server-side rate-limit (429 rate-overlimit) or tiers 1–3 exhausted: WhatsApp's servers are throttling app-state fetches for the account, so NO bridge-side mutation can land (this is server-side; resync retries just re-429). Bypass the bridge entirely and archive via the REAL Mac WhatsApp.app:
bin/wa-mac-archive.sh --batch <file-with-one-jid-or-name-per-line>
The Mac app is a first-class client — its archive mutations sync server-side to the phone and propagate back to the bridge once its app-state heals. The script is archive-only (scope-guarded; it can never send or delete), resolves chats from the Mac ChatStorage.sqlite, drives the app via AppleScript UI automation in the Aqua session, verifies ZARCHIVED=1 per chat, and paces (default 4s). Transport = wa-mac-transport.sh (Tailscale → Cloudflare tunnel). Map @lid JIDs to phone JIDs or names first (the scan JSON carries both). Requires the Mac online on either transport + Accessibility permission for the SSH-launched osascript; on failure it reports per-chat FAIL — fall back to waiting out the 429 (15–60 min) and retry Tier 1.
Surface the appropriate tier to the user when archive blocks; don't abandon inbox-zero.
Core principle: FULL INBOX SCAN
Do NOT just check unread. Scan the FULL recent inbox for each channel and classify every conversation:
Core principle: FULL CONTEXT — NEVER ASSUME
CRITICAL SAFETY RULE — NEVER SEND WITHOUT UNDERSTANDING:
Before drafting or sending ANY reply on ANY channel, you MUST have read the FULL conversation history (20+ messages) and PROVEN you understand it by summarizing:
- What the conversation is about
- What each party said (distinguish user messages from contact messages)
- What the contact is actually asking/saying in their last message
- What a sensible reply would address
Failure mode this prevents: An agent reads only the last message "je kan het toch uit Klaviyo halen?" and replies "Welke data heb je nodig?" — completely wrong because the contact was telling the user to pull data themselves (they have 2FA), not asking for data. Without the full thread, the reply was nonsensical and confused the contact.
Hard rule: if you cannot summarize the conversation arc in 2 sentences, you have not read enough messages. Go back and read more.
The user does NOT remember every thread. For EVERY message you present, you MUST build full context BEFORE showing it. Never show just a subject line and ask "what do you want to do?" — the user needs to understand what it's about first.
For every NEEDS REPLY item, gather this context automatically:
- Full thread body — read the ENTIRE thread (
gog gmail thread get / mcp__whatsapp__list_messages {limit: 20}), not just the last message. Summarize the full conversation arc.
- Contact profile — search across channels to build a card:
gog gmail search "from:<contact_email>" --max 10 — recent email history
mcp__whatsapp__search_contacts {query: "<name>"} — WhatsApp presence
mcp__whatsapp__list_messages {query: "<name>", limit: 5} — recent WhatsApp mentions
- If Linear configured: search for issues assigned to or mentioning this contact
- Present: who they are, role/company, last N interactions, relationship context
- Topic context — identify the subject matter and search for related threads:
gog gmail search "subject:<keywords>" --max 5 — related email threads
mcp__whatsapp__list_messages {query: "<topic keywords>", limit: 5} — related WA messages
- Summarize: what this topic is about, any deadlines, any pending decisions
- ops-memories (if available) — check
~/.claude/plugins/data/ops-ops-marketplace/memories/ for any stored context about this contact or topic
When presenting a NEEDS REPLY item:
━━━ [Contact Name] — [Subject] ━━━
Who: [role, company, relationship — from contact search]
History: [last 3 interactions across channels]
Thread: [2-3 sentence summary of full conversation arc]
Last msg: [full body of their last message]
Context: [related threads/decisions/deadlines found]
Draft reply: "[contextually aware draft based on all above]"
Stage this per the PER-DRAFT APPROVAL principle below: print the block above (it already carries the full draft text and reasoning) as plain chat text, THEN one AskUserQuestion for this item alone, single-select [Send] [Edit] [Skip], with a short (~10-line) preview — the preview clips, so it is a quick-glance companion to the inline block above, never the only place the draft is shown. Never combine with any other item's approval.
When drafting replies:
-
Use the full thread history to maintain conversation continuity
-
Reference specific points from their message
-
Match the contact's communication style (formal/casual, language)
-
If ops-memories has preferences for this contact, apply them
-
Never generate a generic reply — every draft must show you read the full thread
-
NEEDS REPLY — other party sent last message, awaiting your response
-
WAITING — you sent last message, waiting for them (no action needed)
-
HANDLED — conversation concluded, can be archived
-
FYI — newsletters, notifications, automated messages (bulk archive)
Core principle: TONE & LANGUAGE MATCH (every draft, every channel)
A reply that is correct but in the wrong voice is a defect. Before drafting on any channel:
- Language follows the thread, not your default. Reply in the language the user actually uses with that specific contact — Dutch with Dutch contacts, English with English contacts, etc. Detect it from the thread history (the user's own
is_from_me/SENT messages in this thread), never from your locale. For a shared-audience group/thread, match the thread's common language, not one participant's.
- Match the user's register with this contact. Read the user's own prior messages in the thread for: formality (jij/u, first-name vs formal), greeting/sign-off style, emoji density (default sparse — often zero; never add emoji the user doesn't use), sentence length, and any pet phrases. Mirror it. A casual one-liner contact gets a casual one-liner; a formal business contact gets formal prose.
- Honour stored contact tone. Check
~/.claude/plugins/data/ops-ops-marketplace/memories/contact_*.md and the contact registry (below) for a recorded tone profile and apply it verbatim (e.g. partner threads: warm, short, no business framing).
- Never restate known context back at the contact and never sound like an AI assistant — write as the user would.
Core principle: FULL-CONTEXT RECALL + CROSS-CHANNEL / ALREADY-SENT DEDUP
The single most damaging error is replying to something the user already handled — in this thread, in another thread, in a group, on another channel, or by phone/voice that never landed in the store. This dedup gate is MANDATORY and BLOCKING: no thread may be classified NEEDS_REPLY or drafted against until ALL dedup checks (steps 1–7 below) have run and come up empty. A thread is NOT NEEDS_REPLY just because its own last message is inbound — the same request may already be answered somewhere else, and re-surfacing an already-handled item is treated as a scan bug, not a near-miss. The check is on the underlying REQUEST/TOPIC, not just the literal thread: if the same ask was satisfied anywhere — another thread, another channel, a group, or a sent email — it is HANDLED.
Before confirming ANY NEEDS_REPLY or drafting, run steps 1–7:
- Same-thread recheck — scan the full thread both directions (incl.
[voice]/[image]/[document] enrichments). If the user replied after the last inbound, it is WAITING/HANDLED.
- Cross-thread, same person (MANDATORY) — the user may have answered the same person on their other JID (lid↔phone), in a group both are in, or a secondary number. Search the contact across threads (
mcp__whatsapp__list_messages {query, limit:25}, is_from_me:true after the inbound timestamp). A satisfying reply in ANY of the person's threads → HANDLED.
- Cross-channel (MANDATORY) — the user may have answered the email by WhatsApp, or the WhatsApp by email/iMessage/Slack/Telegram. For EVERY NEEDS_REPLY candidate, you MUST search the OTHER channels for a recent outbound to the same person OR about the same topic before confirming:
gog gmail search "in:sent newer_than:21d (to:<addr1> OR to:<addr2>)" (then **verify each hit's labelIds contains SENT via gog gmail raw — search alone is polluted), mcp__whatsapp__list_messages {query:"<name|topic>"} / phone+@lid deep-read, iMessage chat_messages, Slack/Telegram/Productlane history. A verified hit on the same request → reclassify HANDLED. Skipping this search is not permitted — an unsearched candidate is not yet a confirmed NEEDS_REPLY.
- Same request across DIFFERENT people / group (MANDATORY) — the same ask sometimes arrives from several people, or in a group AND a DM. If the user already answered the request once (in the group, to one person, or in a broadcast), the duplicate copies are HANDLED — never draft the same answer twice. Match on topic/intent, not just sender identity.
- Already-sent (email) (MANDATORY) — a reply sent from another client may not be the thread's last message in the envelope first-pass. Open the thread (
gog gmail thread get) AND search SENT for a message after the last inbound before classifying NEEDS_REPLY.
- Search:
gog gmail search "in:sent newer_than:21d (to:<addr1> OR to:<addr2>)" plus subject/topic variants for the same ask (not just same person).
- VERIFY every hit with
gog gmail raw <id> / get: labelIds must contain SENT. gog gmail search 'in:sent to:X' is polluted — it often returns thread peers / inbound envelopes that lack SENT (session-hardened 2026-07-18). Search alone is never proof of outbound.
DRAFT ≠ delivered. Lindy/Gmail drafts must not be treated as already-replied.
- Same person ≠ same ask. A payment on one deal does not close a different payment-round thread; a calendar invite to someone does not close an unrelated bug thread with them; an old thread from a prior year does not close a new opportunity. Match on the specific ask, not just the person.
- Cross-channel: also check WA (phone +
@lid), Slack, Productlane/support@ when the ask is product/support. Demote email NEEDS_REPLY when the same ask was already answered on WA (or vice versa).
- Report: every KEEP / NEEDS_REPLY row must state
already_replied? = no | yes (where) | partial (what remains). Never stage a draft that duplicates a verified SENT/WA reply.
- Use the contact registry (below) to resolve who a sender/number/JID actually is and pull their cross-channel identity + context in one offline lookup, so the steps 2–5 searches are precise across every JID/address/handle the person owns.
- Trust the user's word over the store — if the user says they replied, it is HANDLED even if the store doesn't show it.
Only after steps 1–7 ALL come up empty is a thread a true NEEDS_REPLY candidate. Draft it, then run the fact-verified redraft gate before staging:
- Fact-verified redraft gate (MANDATORY, before staging ANY draft — owner directive 2026-07-04). Clearing steps 1–7 makes a thread a genuine NEEDS_REPLY; it does NOT mean the draft you are about to write is safe to stage. Before staging any draft, the drafter MUST additionally:
- (a) Deep-read the full target thread, both directions — not the summary from step 1–7, an actual re-read of every message for the specific facts the draft will state or rely on.
- (b) Read RELATED threads — same contact across other channels, and any other thread about the same topic/deal (a different contact, a group, an earlier email chain) that could bear on what the draft should say.
- (c) Verify every load-bearing factual claim in the draft with a cheap check before it goes in the draft — a web search for a price/value, a prior email/thread for "who currently holds X", a calendar check for availability, etc. Never state a load-bearing fact in a draft from memory or assumption alone.
- Failure mode this prevents: a draft routed a master clearance to the wrong label until the RELATED threads proved Spinnin'/WMG actually held it (checking only the target thread would have missed this); a watch's stated value (€22.600) changed the negotiation advice the draft gave, and stating the wrong figure from memory would have misled the recipient. Both are "the target thread alone looked fine" failures — that is exactly why (b) and (c) are mandatory, not optional enrichment.
Only after steps 1–7 come up empty may you draft; only after step 8 is satisfied may you stage that draft. This is the FULL-THREAD AWARENESS GATE, extended cross-channel, cross-request, and fact-verified. Surfacing a NEEDS_REPLY without having run the cross-thread, cross-channel, cross-request, and already-sent checks (steps 1–7) is a scan bug — do not present it; do not stage a draft until step 8 is clean. None of this changes the outbound path: even a genuine, fact-verified NEEDS_REPLY is still drafted and sent only in the main session under the Rule-6 gate, per-draft, per the PER-DRAFT APPROVAL principle below.
Core principle: PER-DRAFT APPROVAL — ONE AskUserQuestion PER DRAFT, NEVER BUNDLED (owner directive 2026-07-04)
Every staged outbound draft gets its OWN AskUserQuestion call — never bundle multiple drafts into one question, and never present a batched list of drafts with an "approve all" / "ok all" style option. This applies on every channel (email, WhatsApp, iMessage, Slack, Telegram, Discord, Notion) and supersedes any earlier guidance in this skill that showed multiple candidates followed by a single combined approval.
The preview field clips (owner-observed 2026-07-24, live run): it visually cuts off at ~10 short lines, so a full draft + reasoning block does not reliably fit and gets truncated in the box the user actually sees. So the tool call is never the only place the draft appears:
- Print the full draft inline as plain chat text FIRST, before the
AskUserQuestion tool call. This means the exact message the recipient will see (to/recipient, subject for email, full body) plus the short "Reasoning / facts verified" block (which thread(s) were read, which related threads were checked, which load-bearing facts were verified and how — e.g. "verified via web search: current EUR/USD"; "verified via Gmail search in:sent: Sam confirmed Spinnin'/WMG holds this master on 2026-05-12"). This inline text is the source of truth the user actually reads — never rely on the tool's preview pane alone. If the draft itself is long, split it across multiple chat messages rather than truncating it (same pattern as the existing "split long drafts into separate AskUsers/bubbles" convention) — do not silently cut it.
- Then call
AskUserQuestion. Keep its preview field short — roughly 10 lines max — since it clips: the full draft if it's genuinely short, otherwise a compact excerpt/summary of the draft plus at most 2 reasoning bullets. The preview is a quick-glance companion to the inline text above it, not the only place the draft is shown.
The call, exactly:
- Single-select, options limited to
[Send] [Edit] [Skip] (3 options — well under the Rule-1 cap of 4). Do not add extra options like "Read full thread" or "Archive" to this specific question — the full-thread read is already mandatory before the draft is staged (FULL-THREAD AWARENESS GATE + fact-verified redraft gate above), and archive is a separate, subsequent step once the draft is sent or skipped.
- One draft → full draft printed inline in chat → one
AskUserQuestion (short preview) → one decision (Send/Edit/Skip) → (if Send) one send → archive → then and only then move to the next draft's own inline text + AskUserQuestion.
Forbidden patterns (same spirit as Rule 6's forbidden-output list):
- "Here are 4 drafts — approve all?"
- A single question whose options are a list of different recipients/threads (e.g.
[Send to Alice] [Send to Bob] [Skip both])
- Presenting the full NEEDS_REPLY list with drafts inline and asking one omnibus "which should I send?" question
- Reusing one
AskUserQuestion result to gate more than one send
This does not change Rule 6's underlying send gate (stage → show full draft → explicit approval → send → next) — it makes explicit exactly how that gate is implemented: full draft + reasoning printed inline in chat first, then one AskUserQuestion with a short preview, options [Send]/[Edit]/[Skip].
Core principle: SNOOZE & FOLLOW-UP INTELLIGENCE (never let the user lose a thread)
Cleanup must never silently drop something the user still owes or is owed. Archiving WAITING is safe only because new inbound auto-resurfaces it — but a thread where the ball is in the user's court, or where the other side has gone quiet on something the user needs, must be snoozed with a reminder, not just archived-and-forgotten.
When cleaning up, classify each non-NEEDS_REPLY item one more level:
- USER-OWES (todo) — the user promised an action ("ik pak het vanavond op", "I'll resend the env file", "stuur ik je"), or a meeting-note / email assigned the user a task. → KEEP visible and schedule a follow-up reminder. Never archive an unfulfilled user commitment.
- AWAITING-OTHER, time-sensitive — the user is waiting on a reply tied to a deadline/deal. → archive (auto-resurfaces) but set a nudge reminder if no response by a sensible horizon (default 3 days) so the user can chase.
- CONCLUDED — courtesy close, social tail, fully-answered. → archive, no reminder.
Scheduling reminders (a safe, non-outbound chore — do autonomously): use CronCreate (one-shot, recurring:false) for each USER-OWES / nudge item. The reminder prompt should name the contact, the owed action, and the source thread, e.g. "Reminder: you told you'd handle 's email tonight — follow up (WhatsApp )." Pick a sensible fire time (same evening for "tonight", +3d for nudges). Reminders fire to the user; they are NOT outbound third-party comms, so no approval gate applies.
Meeting-note / assigned-todo capture: when an email or message contains action items assigned to the user (meeting recaps, "can you…", "actie Sam:"), extract them and schedule reminders so they are never lost — even if the thread itself is then archived. Surface the extracted todos in the run summary.
Core principle: SAFE AUTONOMOUS CHORES (do these without asking)
The user wants chores handled autonomously. A chore is a low-risk action with no decision, no commitment, no approval, and no uncertainty. Do these in the cleanup pass without prompting:
- Archive things clearly already replied to / concluded (per the dedup principle).
- Schedule reminders / snoozes for USER-OWES and nudge items (
CronCreate, non-outbound).
- Reconnect / re-auth integrations that have a known, automatable flow (e.g. a "reconnect your X" nudge where the repair is a scripted browser/OAuth step you've done before) — verify success, report it.
- Forward simple receipts / invoices to a pre-configured destination (e.g. the accountant) only when that routing is already established in preferences/memories — this is a fixed-destination chore, not a new outbound decision.
- Mark-read / label hygiene (move concluded items, apply
Actioned).
Hard limits on "autonomous" — these are NOT chores and still gate:
- Any new outbound message to a third party (a reply, an ack, a "got it 👍", a forward to a new recipient) is covered outbound comms → Rule 6: stage ONE draft, get explicit per-message approval, then send. The
block-outbound-comms.py hook enforces this with a single-use token regardless of flags — do not attempt to bypass it. "Ack all" still means "stage each ack for one-tap approval", because an ack is an outbound message.
- Anything involving a decision, a commitment, money, legal/medical content, or any uncertainty → surface to the user, do not act.
When in doubt whether something is a chore, it is not — surface it.
Contact registry (offline cross-channel identity + context)
bin/ops-contact-registry builds and reads a single offline JSON registry that maps every known person to their cross-channel identities and a short context blurb — so scans resolve names instantly and the dedup/tone principles have ground truth without live lookups.
- Build / refresh:
bin/ops-contact-registry build — merges WhatsApp contacts.db (+ whatsmeow_lid_map for lid↔phone), Gmail frequent senders, and Slack users (when configured) into ${CLAUDE_PLUGIN_DATA_DIR}/contact-registry.json. Idempotent; safe to run on every inbox pass (cheap, offline). Enriches with ops-memories contact_*.md context + recorded tone.
- Lookup:
bin/ops-contact-registry lookup "<name|email|phone|jid>" → the merged record {name, emails[], phones[], wa_jid, wa_lid, slack_id, last_channel, context, tone}. Use it to resolve a sender/number/JID and to drive the cross-channel dedup search precisely.
- During a scan: prefer the registry for name resolution before falling back to
contacts.db → chat name → giga. Use the merged identities to search the SAME person across channels for already-sent dedup.
The registry is read-only reference data — never a send surface.
Channel availability + fallback
For each channel, detect availability at runtime:
- Email: Try
gog CLI first. If gog unavailable, try mcp__gog__gmail_* MCP tools. If neither, report unavailable.
- WhatsApp: Two layers must be checked — DO NOT misdiagnose by only probing one.
- Layer A — whatsmeow bridge (
:8080): lsof -i :8080 | grep LISTEN. If absent, bridge is down — run the robust restart recipe above (launchctl load -w fallback before kickstart), wait 5s, re-check.
- Layer B — MCP transport: Claude's client connects to
mcp__whatsapp__* via the ops mcp-proxy at 127.0.0.1:8090/servers/whatsapp/sse, NOT directly to :8080. Verify: lsof -i :8090 | grep LISTEN and curl -sS -m 3 http://127.0.0.1:8090/servers/whatsapp/sse | head -1 (should emit event: endpoint). If :8090 isn't listening, the ops mcp-proxy daemon is down — restart via bash ~/.claude/scripts/hooks/ops-plugin-version-heal.sh then check ${CLAUDE_PLUGIN_DATA_DIR}/daemon-services.json for the proxy service entry.
- MCP tool-load handshake: when both layers are up but
mcp__whatsapp__* tools aren't listed yet, the SSE handshake is still in flight. Retry ToolSearch select:mcp__whatsapp__list_chats,mcp__whatsapp__list_messages,mcp__whatsapp__search_contacts,mcp__whatsapp__send_message,mcp__whatsapp__archive_chat,mcp__whatsapp__get_chat,mcp__whatsapp__resync_app_state up to 3 times with 5s spacing before declaring unavailable. Never report "WhatsApp MCP not available" while :8080 AND :8090 are both LISTEN — that is a transient handshake, not a configuration failure.