---
name: Ops Inbox Zero
slug: ops-inbox-zero
category: Automation
description: Ops Inbox Zero handles WhatsApp inbox triage, reads threads, and drafts or sends replies after approval. Use it when asked to check inbox, clear messages, archive chats, or respond through the OPS workflow.
github: "https://github.com/Lifecycle-Innovations-Limited/claude-ops/tree/main/claude-ops/skills/ops-inbox"
language: Shell
stars: 186
forks: 21
install: "npx degit https://github.com/Lifecycle-Innovations-Limited/claude-ops/tree/main/claude-ops/skills/ops-inbox ~/.claude/skills/ops-inbox"
installs_to: ~/.claude/skills/ops-inbox
source_path: claude-ops/skills/ops-inbox/SKILL.md
collection_size: 25
category_size: 2032
collection_url: "https://dirskills.com/collections/Lifecycle-Innovations-Limited/claude-ops"
added: 2026-09-06T05:20:39.183Z
last_synced: 2026-09-06T05:20:39.183Z
canonical_url: "https://dirskills.com/skills/ops-inbox-zero"
---

# Ops Inbox Zero

Ops Inbox Zero handles WhatsApp inbox triage, reads threads, and drafts or sends replies after approval. Use it when asked to check inbox, clear messages, archive chats, or respond through the OPS workflow.

**Install:**

```bash
npx degit https://github.com/Lifecycle-Innovations-Limited/claude-ops/tree/main/claude-ops/skills/ops-inbox ~/.claude/skills/ops-inbox
```

## README

# 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`.
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" --pretty            # both channels
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-scan" --whatsapp-only     # WA only
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-scan" --days 14           # wider window
# target a specific WhatsApp account (both flags, or neither):
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-scan" \
  --wa-store ~/.local/share/whatsapp-mcp/whatsapp-bridge-<label>/store/messages.db \
  --bridge-port <port>
```

With no flags the scan resolves the single agent-enabled account itself and **exits 3 rather than
guess** when that is ambiguous. Its JSON carries a `whatsapp_account` block (`phone`, `bridge_port`,
`store`, `resolved_by`) — that is the account every downstream archive and reply must use, and
`ops-inbox-archive-set` reads it so the two can never drift onto different numbers.

**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
