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

# 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`).

## Parent session: AUTO-START DRAFTING

This skill MUST run in the parent session. Never set `context: fork` — that
parks the whole run (including drafts) in a background agent, so the parent
idles until the owner types "continue drafting". Measured 2026-09-21.

**Always report to the main agent by default.** Every Agent / Workflow /
forked worker this skill launches returns KEEP rows, thread arcs, and draft
text to the parent. It never `AskUserQuestion`, never numbered-options the
owner, never waits for "continue". If this skill itself was spawned as a
subagent, the same rule: the last message is a report for the parent, not a
card for the owner.

**30-second parent heartbeat (default).** At the start of every run, launch:

```bash
"$CLAUDE_PLUGIN_ROOT/bin/ops-inbox-parent-heartbeat.sh"
```

via Bash `run_in_background: true`. Default interval is 30 seconds
(`OPS_INBOX_HEARTBEAT_SEC`). The job sleeps, prints one `INBOX HEARTBEAT`
line, and exits. **The job's exit IS the ping to this parent session.**
When it fires: print that line to the owner, then relaunch the same
command. Never wait for the owner to ask for a status.

Every background worker also `SendMessage`s the parent at least every 30
seconds with one line (`channel=… keep=… drafting=…`). That is the
default, not an opt-in.

On `/ops:ops-inbox` / `/ops-inbox`, start drafting in THIS session without a
second prompt:

1. Kick the cheap scan (`ops-inbox-scan`) and fan-out readers immediately.
2. The moment the first KEEP / NEEDS_REPLY exists (scan JSON, keep.md,
   thread_tail, or a reader returning), stage that draft. Do not wait for
   the rest of the inbox.
3. Queue further drafts as readers land. One draft → one approval → one send.
4. Never wait for "continue drafting", "go", or "next?". The slash command
   is the start signal.

Scanners stay background and read-only. Sends never leave the parent.

## ⚠️ 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 WhatsApp accounts (`ops-wa-accounts` — never hardcode a port). Scan every agent-enabled number.
2. Freshness: `~/bin/wa-inbox-fresh.sh` (blocking, bounded). Then `bin/ops-inbox-scan` (defaults to every enabled account, `--debt-days 90`). Never an unread listing: unread is a display state and is empty for every thread already opened on a phone. Forgotten unanswered asks (archived/read, still their ball, inside 90 days) come back tagged `forgotten` — on WhatsApp AND on email, where a Gmail filter or category can hide a live thread from `in:inbox` entirely.
2b. **Every other channel in the same pass, in parallel:** iMessage/SMS (`$HERMES_HOME/bin/imessage-inbox-scan --days 30` — copies `-wal`/`-shm` first or recent messages are invisible; alphanumeric shortcodes and OTP bodies are `fyi`, never `needs_reply`). Slack: `channels_me {channel_types:"im,mpim"}` then `conversations_history` per human DM, never `conversations_unreads` — unread is never a filter (Sam, 2026-09-17), a DM he read on his phone and never answered stays invisible to an unread listing. Telegram user dialogs if configured. Email is already in the scan. A miss on one channel is not absence on another.
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.
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`). One read-only worker per channel, then one per KEEP thread-chunk. Stage the first draft as soon as one KEEP exists — do not wait for the full scan, and do not wait for "continue drafting". Stage the next draft the moment the previous card is answered — no "next?" pause.
6. Stage drafts one at a time (Rule 6) in this parent session. Archive after a verified send.

## Every suggested send carries reasoning

A draft without this block is incomplete. Never show only the outbound text.

Print, in the same bubble as the exact draft, three short lines in plain words:

- **Why this reply:** what in the live thread this answers (quote the ask, not a vibe).
- **Expected outcome:** what happens after they read it (they stop chasing, they can act, the thread closes).
- **Why that is good:** the concrete gain.

If you cannot fill all three from the thread, do not stage. Go read more.

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.

**AND NEVER SUBSTITUTE A LABEL FOR A CONVERSATION — measured 2026-09-16, email.** The same
defect has a second shape: `in:inbox` is a LABEL, not a state of the conversation. A Gmail
filter, a category (Promotions/Updates/Social/Forums), a mute, or one stray archive strips
INBOX from a live thread, and an inbox-only query then reports the mailbox clean while a
counterparty waits. Real miss: Salim Jawad at Believe delivered the finished UGC fan videos
on the `MRT Gen AI Videos` thread; Gmail had filed that thread out of the inbox, so three
consecutive scans said email was handled and nobody answered him. `ops-inbox-scan` now runs
a second email pass (`--debt-days`, default 90) over archived, non-promo, human mail and
reopens every thread whose NEWEST message is inbound and never answered; those rows arrive
in `email.needs_reply` tagged `forgotten: true` with a `forgotten_count` and a note. The
rule to carry into any channel you add: **debt is DIRECTION, not label or unread state.**
The honest test is always "whose message is last in this thread, and did we answer after
it" — never "is it still in the inbox".

**Unread is never a filter, on any channel (Sam, 2026-09-17).** A message Sam
opened and navigated away from without replying still owes a reply — read state is
a display flag, not a proxy for "handled". `conversations_unreads` only sees what
Sam has not opened; a DM he read on his phone and never answered is invisible to
it. Never use it, and never use `whatsapp_unread`, to decide what needs triage.
Always confirm direction with `conversations_history` (Slack) or `whatsapp_find` /
`whatsapp_thread` (WhatsApp) — who spoke last, not what is marked unread. A guard
(`unread-filter-guard.py`, wired into `pre-tool-dispatcher.py` on hub/Mac/HYPEST)
hard-blocks `whatsapp_unread`, `conversations_unreads`, and any `is:unread` /
`unread_count` / `unread=1` filter in a terminal command.

**ON SAM'S BOX THE WHATSAPP ZERO IS ALWAYS A LIE — measured 2026-09-06.** Every chat in
both stores is archived and `handled` is maintained on 3 of 1808 rows, so the scan prints
`whatsapp: every chat is archived and handled is not maintained ... inbox zero, not flag
corruption`, disarms its own recency net, and reports `needs_reply: 0` for personal_nl AND
personal_us. That run reported inbox zero while six people were genuinely waiting on a
reply. Whenever a note contains "recency net disarmed" or "taken at face value", the zero
carries no information: derive needs_reply yourself — a thread needs a reply when its
NEWEST message has `is_from_me = 0`, regardless of the archive flag. Do this with the
WhatsApp plugin tools (`whatsapp_find`, `whatsapp_thread`), never `whatsapp_unread`, and
never a hand-written sqlite read.

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

**ARCHIVED MEANS "DEALT WITH AS OF NOW", NOT FOREVER.** WhatsApp never moves
the archive flag when a new message lands, so a swept thread that gets a
reply an hour later would sit invisible while the scan reports inbox zero (observed
2026-09-06: five people replied within four hours of a sweep). `--apply` writes
a per-chat watermark (`ops-sweep-watermarks.json`, beside `messages.db`) and the
scan reopens any archived chat whose newest inbound is later than that mark,
noting how many. An archived chat that stayed quiet stays archived, and history
stays shut: the reopen is bounded by `--days`.

**Read state is not the test, on WhatsApp either.** Use `whatsapp_find` /
`whatsapp_thread` and classify by direction (newest message `is_from_me = 0` ==
needs_reply), never `whatsapp_unread` — a thread Sam opened on his phone and
never answered is empty in any unread listing but still owes a reply.

**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. Email rows carrying `forgotten: true` were archived or filtered out of the inbox but still owe a reply (see `forgotten_count` and the matching note) — treat them exactly like inbox rows.

**What the script does NOT do — and what you do next, in the MAIN session (no subagents):**

1. **Slack** — never `conversations_unreads` (unread is not a filter, Sam 2026-09-17;
   a guard hard-blocks the call). Instead: `channels_me {channel_types:"im,mpim"}` once
   to list human DMs, then `conversations_history {channel_id, limit:"5d"}` per DM to
   check who spoke last. A DM Sam read on his phone and never answered must surface —
   an unread listing would miss it entirely.
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
