Installs into .claude/skills of the current project.
Are you the author of Ops Inbox?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/lifecycle-innovations-limited-ops-inbox)
---
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 (2026-09-17), a DM read on a 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.
- **Workers run on a cheaper tier than the main session.** Pass `model` on every fan-out agent: `haiku` for retrieval, `sonnet` for reading, classifying and ordinary drafts, the top model only for legal/financial/deal threads. Never pin a model id. Change models from the next stage onward; never stop a running worker for it. Details: `references/fan-out.md`.
## 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: a counterparty delivered finished assets
on a project 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 (the operator, 2026-09-17).** A message the operator
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
the operator has not opened; a DM read on a 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`)
hard-blocks `whatsapp_unread`, `conversations_unreads`, and any `is:unread` /
`unread_count` / `unread=1` filter in a terminal command.
**ON A FULLY ARCHIVED WHATSAPP STORE THE 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 every
configured account. 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 opened on a 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, the operator 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 read on a 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