ops-inbox · diff

git:20260906.1105144 to git:20260906.c9beb13

9 added, 0 removed. Audit A to A.

---
name: ops-inbox
description: "OPS on-demand: This skill should be used when the user asks to \"check inbox\", \"inbox zero\"…"
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
# Slack — multi-workspace inbox scan uses these MCP tools when a workspace's
# token is bound to the Slack MCP in ~/.claude.json. Workspaces whose
# token_env is NOT bound to the MCP are scanned via direct curl from Bash
# (no MCP entry needed for those).
- 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
# Telegram: user-auth MCP tools added when configured
# Notion: MCP tools (claude.ai integration or self-hosted)
- mcp__claude_ai_Notion__notion-search
- mcp__claude_ai_Notion__notion-fetch
- mcp__claude_ai_Notion__notion-query-data-sources
- 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
# WhatsApp — these are the SINGLE-ACCOUNT server names (see CLAUDE.md Rule 8).
# allowed-tools entries are exact strings, so if you run one bridge per account
# (`whatsapp-personal`, `whatsapp-work`, ...) these will NOT grant access to yours.
# Add your own per-account entries alongside these, e.g. mcp__whatsapp-work__list_chats.
- 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
# iMessage — official `imessage` plugin. chat_messages reads ~/Library/Messages/chat.db
# (allowlist-scoped); reply sends via AppleScript to Messages.app. No bridge, no daemon.
- mcp__plugin_imessage_imessage__chat_messages
- mcp__plugin_imessage_imessage__reply
effort: high
maxTurns: 60
context: fork
---
# OPS ► INBOX ZERO
Load `ops-rules` before acting. Public repo (no personal data). Outbound: one draft → one approval → one send. If `AskUserQuestion` / `Workflow` are missing, follow Rule 10 in `ops-rules` (Hermes: numbered options / two-turn Telegram card; `delegate_task`).
## ⚠️ 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`.)
> **Server name.** `mcp__whatsapp__*` is the single-account default. Installs with more than one account
> register one server per account (`whatsapp-nl`, `whatsapp-us`, `whatsapp-personal`, `whatsapp-work`, ...)
> and have no plain `mcp__whatsapp__*` at all. `whatsapp-cos` is not an account — do not send on it.
> Resolve the real name from the available tools before the first call, and when several accounts exist,
> scan each one separately and keep the results labelled by account. See CLAUDE.md / ops-rules Rule 8.
**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 | `ops-wa-accounts --list`, then `curl -s -o /dev/null -w "%{http_code}" "$WA_API/api/v1/status"` (401 is healthy when the route is Bearer-gated). Never `lsof :8080`, never `launchctl kickstart` unless policy says the bridge is local. | `wacli doctor` / `~/.wacli/.health` |
| Trigger history backfill | `curl -fsS -X POST "$WA_API/api/backfill"` (resolve `$WA_API` first — see "WHICH NUMBER" below; 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.
## ⚠️ WHICH NUMBER — resolve the account before the first read or send
**This box may run more than one WhatsApp account.** The bridge process may be local, or it may
run on another host with this machine as a **client**. Clients dial a local reverse-proxy URL from
policy (`api` / `bridge_port` in `$PREFS_PATH` `.channels.whatsapp`, written by `/ops:setup`
step 3b; `$OPS_DATA_DIR/registry.json` `.whatsapp` is the same shape). They never dial a remote
IP, never guess `:8080`, and never start a local LaunchAgent "to recover" when policy says the
bridge is elsewhere. A 401 on `$WA_API/api/v1/status` is healthy when that route is Bearer-gated.
**Resolve first, every run, before any read, archive, or send:**
```bash
"$CLAUDE_PLUGIN_ROOT/bin/ops-wa-accounts" --list # every account: port, number, agent, api
# Two agent-enabled accounts: scan EACH, labelled. Do not call --port (exits 3 on purpose).
# For each agent_enabled account in the JSON: WA_API=$account.api
```
`ops-wa-accounts` reads local `whatsapp-bridge*` stores if present, then **overlays policy**
from `$PREFS_PATH` (wins), then registry, then legacy `~/.config/whatsapp/agent-policy.json`.
Policy `api` / `bridge_port` always wins over a leftover local store's launcher port. A client
box with no `messages.db` is still resolved. It exits 3 rather than pick one account when more
than one is agent-enabled — that is a loop, not a stop.
**The rules:**
1. **Never hardcode a port, never use whichever port answers, never probe a remote IP.** Use
each account's `api` from the resolver JSON.
2. **A number can be agent-disabled.** An account with `agent_enabled: false` is off-limits: do not
scan it, do not archive it, do not send from it, do not pair or re-pair it.
3. **Two agent-enabled accounts is not ambiguous.** Scan each, keep results labelled by account,
reply on the number the thread lives on. Stop and ask only when none are enabled, or when
unpolicy'd bridges appear with no `api`.
4. **Reply on the number the thread lives on.** Cross-account replies are never an acceptable fallback.
5. **Rule 8 applies to the MCP surface too.** With several accounts the servers are named per account
(`mcp__whatsapp-nl__*`, `mcp__whatsapp-us__*`, or `whatsapp-personal` / `whatsapp-work`) and a bare
`mcp__whatsapp__*` / `mcp__whatsapp-cos__*` is not an account. Match the registered name; do not
assume. Hermes `wa status` / `wa thread` / `wa find` is a valid read path on a client box when MCP is down.
**Why this section exists:** a full inbox run once followed this skill's hardcoded
`127.0.0.1:8080` and sent every reply from the wrong number. Guessing a port, or treating a
missing local `messages.db` as "WhatsApp down", repeats that class of failure.
## Runtime Context
Default: fan out one read-only scanner per configured channel after the offline scan. Skip fan-out only in the trivial case (~1–3 candidates). Full freshness / Mac fallback / version-heal / watcher: `references/runtime.md`.
Every run, in order:
0. **Rank by VIP first.** Load the `vip` skill and resolve the VIP set once
(`/ops:vip list`). Tier-1 senders are read and answered before anything else,
tier-2 next, everything else after. A VIP with an unanswered inbound is
surfaced individually with thread context, never as a line in a digest.
Before drafting to any person, load `relations` for their brief and open
commitments.
1. Resolve the WhatsApp account (`ops-wa-accounts` — never hardcode a port).
2. Freshness: `~/bin/wa-inbox-fresh.sh` (blocking, bounded). Then `bin/ops-inbox-scan --all-accounts`. Never an unread listing: unread is a display state and is empty for every thread already opened on a phone.
3. **All-context sweep** (Rule 9 + `references/details.md` "ALL CONTEXT SOURCES"): query every configured calendar, mailbox, and messaging channel before any schedule claim or NEEDS_REPLY draft. Google Calendar alone is not enough — Notion show/calendar databases count when Notion is configured. A miss on one store is not absence.
4. `bin/ops-inbox-archive-set` report-only. Present KEEP vs ARCHIVE; `--apply` only after explicit OK.
5. Deep-read KEEP / NEEDS_REPLY. Fan out if volume (`references/fan-out.md`).
6. Stage drafts one at a time (Rule 6). Archive after a verified send.
Channel processing, FULL-THREAD AWARENESS GATE, and per-channel recipes: `references/details.md`.
## 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.
```bash
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-scan" --all-accounts --pretty # EVERY enabled number
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-scan" --whatsapp-only # WA only
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-scan" --days 14 # wider window
# target one specific WhatsApp account instead:
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-scan" \
--wa-store ~/.local/share/whatsapp-mcp/whatsapp-bridge-<label>/store/messages.db \
--bridge-port <port>
```
**`--all-accounts` is the default choice when more than one number is agent-enabled.**
It scans each one, labels every row with its account and bridge port, and still emits the
flat `whatsapp` buckets older consumers read. Without it the scan exits 3 rather than pick
a number, and a caller that "handles" that by scanning one account silently reports half
an inbox.
**NEVER substitute an unread listing for a scan.** `unread` is a DISPLAY state: an owner
who reads on a phone leaves every real thread at zero unread, so an unread-only sweep
reports inbox zero over a mailbox full of unanswered asks. The working set is what has not
been DEALT WITH (`handled=0`, falling back to `archived=0`), never what has not been seen.
**Empty buckets are only trustworthy when the scan says it succeeded.** Check the flags
before believing a zero:
| field | meaning | what you do |
|---|---|---|
| `email.empty: true` | search ran, zero hits | real inbox zero |
| `email.reachable: false` | the call failed | fix it, never report zero |
| `whatsapp.blocked: true` | no store, local or pulled | fix ssh/policy, never report zero |
On a client box (bridge on another host) the scan pulls the remote store itself over the
policy `ssh` + `remote_store` entries, using `VACUUM INTO` for a consistent snapshot. It
only reports `blocked` when that genuinely fails.
+ **CROSS-ACCOUNT: a reply on one number counts for the other.** One person often
+ exists in both stores under different jids. A reply sent from account A lands
+ only in A's database, so B's copy still ends on their inbound line and looks
+ unanswered. The scan therefore reads every OTHER account's store as read-only
+ evidence and demotes such a thread to `waiting` with
+ `reconciled: "answered from another WhatsApp account"`. That discovery is
+ automatic; `--peer-store DB` names one explicitly and `--no-peer-stores` turns
+ it off. Never draft a reply for a thread carrying a `reconciled` field.
+
**ARCHIVE/KEEP SPLIT — `bin/ops-inbox-archive-set`.** The scan says what the
inbox looks like; this turns that into the two lists inbox-zero actually needs,
deterministically, instead of re-deciding a few hundred rows by eye every run:
```bash
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-archive-set" # report only
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-scan" | \
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-archive-set" - # reuse a scan
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-archive-set" --scan /tmp/scan.json --json
# extra WhatsApp account (repeat the pair per account):
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-archive-set" \
--wa-store ~/.local/share/whatsapp-mcp/whatsapp-bridge-<label>/store/messages.db \
--bridge-port <port>
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-archive-set" --apply # AFTER approval
```
- **ARCHIVE** — WAITING (you sent last), courtesy tails with no open ask
("thanks!", "will do!!"), threads past `--stale-days` (7), dead groups past
`--dead-group-days` (30).
- **REVIEW (FYI)** — newsletters, broadcasts, automated mail. **Never swept.**
Read it, brief the user, then ask; `--archive-fyi` is the opt-in that answer
unlocks. See the FYI core principle below.
- Undescribed media (`[image]`, `[voice]` with no enrichment yet) is **unknown**,
never a tail — it always lands in KEEP.
- **KEEP** — every genuine unanswered ask, plus every email carrying a
todo/action label. Ambiguity always resolves to KEEP; keeping is safe,
archiving is the risky direction.
- **Report-only by default.** It archives nothing without `--apply`, and never
sends. Present the counts plus the KEEP list with `AskUserQuestion`, and only
re-run with `--apply` after an explicit OK. `--apply --dry-run` walks the path
without calling out.
- It enforces the todo/action-label HARD GUARDRAIL in code, so a labelled mail
cannot be swept even when it looks exactly like a newsletter.
**ONE-SHOT TRIAGE:** `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.
```bash
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-zero" # safe report-only default
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-zero" --archive # only after explicit approval
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-zero" --no-slack # skip Slack triage
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-zero" --days 14 # wider window
```
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` JSON always includes `whatsapp` / `email` buckets (`needs_reply`, `waiting`, `groups`, `fyi`) plus `whatsapp_account` and `counts`. Partial failure still emits valid JSON.
**What the script does NOT do — and what you do next, in the MAIN session (no subagents):**
1. **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.
2. **Telegram** — one `mcp__plugin_ops_telegram__list_dialogs` call (skip the
Pocket ops bot dialog — that's automation). Skip if unconfigured.
3. **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.
**Fan-out for real per-thread volume.** After the cheap scan, deep-read and draft in parallel (`Workflow` default; Agent Teams / Hermes `delegate_task` fallback). Skip only the trivial case (~1–3 candidates). Mechanics, hard constraints, and the canonical Workflow JS: `references/fan-out.md`. Fan-out never sends, archives, or mutates — Rule 6 stays in the main session.
## Agent Teams support
When `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` is set and `Workflow` is missing, fan out one read-only scanner per available channel:
```
TeamCreate("inbox-channels")
Agent(team_name="inbox-channels", name="whatsapp-scanner", ...)
```
If the flag is NOT set, use `Workflow` or sequential main-session reads. Full script and hard constraints: `references/fan-out.md`.
### Fallback — Hermes / Grok (`delegate_task`)
When this skill runs on Hermes or Grok (no `Workflow` tool, no `AskUserQuestion`):
- **Fan-out:** `delegate_task` for one read-only scanner per available channel. If that
tool is missing, scan sequentially in the main session. Same read-only contract.
- **Approval:** numbered options in chat. On Telegram, two turns — full draft as its own
bubble, then the `[Send]` `[Edit]` `[Skip]` card. Never bundle drafts. Never put the
only copy of the draft in a clipped preview.
- Plugin-wide table: Rule 10 in `CLAUDE.md` and `hermes-plugin/RUNTIME.md`.
## Additional resources
Read the matching file before acting. Do not skip.
- `references/cli.md` — WhatsApp MCP + gog CLI
- `references/details.md` — inbox-zero rules, gates, per-channel processing, **ALL CONTEXT SOURCES**
- `references/runtime.md` — freshness, Mac fallback, version-heal, watcher, all-context sweep
- `references/fan-out.md` — Workflow JS, Agent Teams, hard constraints
- `CHANNELS.md` — channel setup
- `vip` skill — tier order for step 0; who is answered first
- `relations` skill — person brief, open commitments, draft context