OPS ► COMMS skill

OPS on-demand: This skill should be used when the user asks to "send a message"…

by Lifecycle-Innovations-Limited·MIT license·★ 543 Stars on the repo·GitHub ↗

Use now

Inside this skill

SKILL.md · the author's own words
# OPS ► COMMS

Load `ops-rules` before acting. Public repo (no personal data). Outbound: one draft → one approval → one send. If `AskUserQuestion` / `Work…

## Runtime Context

Before executing, load available context:

1. **Daemon health**: Read `${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/daemon-health.json`
   - Check bridge liveness before any WhatsApp operation: `lsof -i :8080 | grep LISTEN`
   - If bridge not running, prompt user to restart: `launchctl kickstart -k gui/$UID/com.${USER}.whatsapp-bridge`

2. **Ops memories**: Before drafting any message, check `${CLAUDE_PLUGIN_DATA_DIR}/memories/`:
   - `contact_*.md` — load profile for the recipient
   - `preferences.md` — match user's communication style, language, and tone
   - `donts.md` — restrictions that must not appear in any draft

Word-for-word excerpt, not a result. Read the full SKILL.md ↓

Files of OPS ► COMMS

Lifecycle-Innovations-Limited/main1 file shown
SKILL.md
Show the full text497 lines

OPS ► COMMS

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

Runtime Context

Before executing, load available context:

  1. Daemon health: Read ${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/daemon-health.json

    • Check bridge liveness before any WhatsApp operation: lsof -i :8080 | grep LISTEN
    • If bridge not running, prompt user to restart: launchctl kickstart -k gui/$UID/com.${USER}.whatsapp-bridge
  2. Ops memories: Before drafting any message, check ${CLAUDE_PLUGIN_DATA_DIR}/memories/:

    • contact_*.md — load profile for the recipient
    • preferences.md — match user's communication style, language, and tone
    • donts.md — restrictions that must not appear in any draft
  3. Preferences: Read ${CLAUDE_PLUGIN_DATA_DIR}/preferences.json for default_channels to determine which channel to prefer when multiple are available for a contact.

Routing table

Pattern Action
whatsapp Show WhatsApp recent chats — offer to read or send
email Show recent email threads via Gmail MCP
slack Show recent Slack activity
telegram Show Telegram recent chats
discord Show recent Discord channel activity (via bin/ops-discord)
notion Search Notion workspace — pages, comments, tasks
voice Voice / phone / video — routes to /ops:ops-voice (bin/ops-voice)
call * Native Phone.app call via bin/ops-voice phone
facetime * FaceTime audio/video via bin/ops-voice facetime
zoom Start an instant Zoom meeting via bin/ops-voice zoom start
send * to * Parse message and contact, determine best channel, send
read * Read the specified channel or contact's messages
home alarm Pipe a Homey alarm event as a WhatsApp/Telegram alert — delegates to /ops:ops-home alarm --notify (only if home_automation is configured in $PREFS_PATH)
(empty) Show channel picker menu

Natural-language parsing:

  • send "deploy done" to #general on discord → bin/ops-discord send general "deploy done".
  • call <contact> / dial <contact> / phone <contact> → resolve number, then bin/ops-voice phone <E.164>.
  • facetime <contact> (with optional audio) → bin/ops-voice facetime <handle> [--audio].
  • start a zoom / new zoom meeting → bin/ops-voice zoom start.
  • join zoom <ID> → bin/ops-voice zoom join <ID>.
  • text <contact> "<body>" / sms <contact> "<body>" → bin/ops-voice twilio-sms <to> $TWILIO_FROM_NUMBER "<body>" (guarded by per-message approval).

Send flow: send [message] to [contact]

  1. Parse contact name and message from $ARGUMENTS.
  2. Determine channel by contact lookup:
    • Check WhatsApp: mcp__whatsapp__search_contacts {query: "[contact]"} 2>/dev/null
    • Check Slack: mcp__claude_ai_Slack__slack_search_users with query: "[contact]"
    • Check email: known from context or ask
  3. If multiple channels found, use AskUserQuestion: [WhatsApp] / [Slack] / [Email]
  4. Always preview before sending. Use AskUserQuestion to confirm:
Ready to send via [channel]:
  To: [contact name] ([identifier])
  Message: "[full message text]"

  [Send now]  [Edit message]  [Cancel]

If user picks "Edit message", use AskUserQuestion with free-text to get the revised message, then re-preview.

  1. Send via the chosen channel. Confirm with: Sent to [contact] via [channel] ✓
WhatsApp send

CRITICAL — READ BEFORE SENDING: Before drafting ANY WhatsApp reply, you MUST:

  1. Read the full conversation: mcp__whatsapp__list_messages {chat_jid: "<JID>", limit: 20}
  2. Understand which messages have is_from_me: true (user sent) vs is_from_me: false (contact sent)
  3. Summarize what the conversation is about and what the contact is asking
  4. Only THEN draft a reply that addresses what the contact actually said

Never send a reply based on a single message. A message like "can you pull it from Klaviyo?" means nothing without knowing what "it" refers to from prior messages.

Pre-flight: Check bridge is running: lsof -i :8080 | grep LISTEN. If not running, restart: launchctl kickstart -k gui/$(id -u)/com.${USER}.whatsapp-bridge and wait 5s.

mcp__whatsapp__send_message {recipient: "[contact_jid]", message: "[message]"}
Slack send

Use mcp__claude_ai_Slack__slack_send_message with resolved channel/user ID.

Email send (draft)

Use mcp__claude_ai_Gmail__create_draft — always create draft first. Then use AskUserQuestion:

Draft created for [recipient]:
  Subject: [subject]
  Body: [preview]

  [Send now]  [Keep as draft]  [Edit]

Outbound judgment: verify before you assert

Before drafting to a person, load relations for their brief, open commitments and the full-context draft order, and vip for their tier. A tier-1 recipient is drafted with extra care and never handled from a digest line. Neither skill can send; Rule 6 still owns the send.

Rule 6 governs whether a message may go out. This section governs whether the message is right. Every item below is a failure mode seen on a real run, where the approval gate held and the content was still wrong.

Research every load-bearing claim

Never state a fact in someone else's inbox from memory. A price, a date, an availability, a "who currently holds X" all get a cheap check first: a search, the actual thread, the calendar, the invoice.

Claims about the recipient's own access get probed, not inferred. Telling a colleague their access is broken is a claim about a system you can inspect. A wrong diagnosis sends a competent person chasing a non-problem, and they will believe you because you appear to hold the admin view. Sort each access item into fixed, never broken, or theirs, and say which. Telling someone "that already works" stops them building a workaround for a permission they already have.

Verify the join, not just the endpoints

The hardest fabrications are not invented facts, they are invented relationships between two real things that share a name, an entity, or a keyword. Each half is true, the sentence joining them is not, and it reads as diligence rather than a guess.

"X should be coordinated with Y" is itself a factual claim and needs its own evidence. A company or person appearing in two matters is weak evidence they are the same workstream and often strong evidence of a naming coincidence. Be especially suspicious of an advisory clause nobody asked for: the recipient asked a yes or no question, and a "let's just align with..." rider is exactly where invented linkage hides. When challenged on such a clause, probe and delete it rather than rewording it into something defensible.

Do not absorb the other party's job

An inbound message that mentions a task is not automatically a task for the user. When the sender owns the workstream, a heads-up about their own work in progress is not an instruction transferring that work.

Before staging any reply containing a first-person commitment ("I'll pick that up", "I'll clean that up"), check whose function it is, who has the access, and whether the commitment is even wanted. A message that only needs acknowledging gets acknowledgement. Volunteering labour reads as helpful and creates real obligations nobody tracks. When the work does need doing and the counterparty owns it, name it and hand it back rather than quietly taking it on.

Do not route a decision the counterparty already made

A narrow A/B to the user is right for a real trade-off and wrong when the counterparty already supplied the answer inside the message being triaged. An identifier a person gives for their own account (login, email, phone, handle, bank account) is authoritative. A conflict with the admin view means the estate is misconfigured, not that they misremembered their own account. Use the probing to shape the message, not to defer. Escalate only when the action is irreversible and expensive; two systems disagreeing is neither.

The tell that this rule was broken is a closing question to the user whose answer is already quoted verbatim in the inbound message.

A staged draft goes stale

A queue of drafts is researched at one moment and sent over hours. Any draft referencing a pending external process (a bank instruction, a signature, a shipment, a filing) must have that process re-checked immediately before the send, not just at research time. A draft that correctly said "still waiting, no news" became wrong when the counterparty's reply landed mid-queue.

Two consequences are easy to miss: the draft's central claim can flip outright, and new inbound often creates a second outbound that also needs drafting. Search results encountered mid-queue get triaged, not skimmed past.

A parallel session may have already sent it. Multiple agents can work the same inbox and send under the same identity. Treat any outbound from the user's own identity that this session did not write as another agent's work: verify its claims at the source, report the item closed with that evidence, and move on. Do not re-send, and do not assume it was correct just because it exists.

A counterparty's restatement of your position is not authoritative

When someone replies "which of these did you mean?" or summarises your position back before proceeding, do not pick the more plausible reading. Open what the user actually sent and read their own words. A partner who guesses wrong and proceeds on that guess can commit the user, in writing, to a different deal than the one they proposed. Their politeness is not evidence; a soft hedge from a competent counterparty is exactly where an unnoticed inversion lives.

When the user contradicts your draft, check before defending it

Low-confidence pushback ("check, I think we already signed", "I might have credentials too") is a hard stop and a research instruction, not a hedge to be reassured about. Re-probe the primary source and come back with what it says, including "partly, the mechanism is X not Y". Never defend a draft against the user's own recollection without checking first.

Do not trust an audit's verdict over primary evidence

A subagent audit is a research artifact, not a finding. Before acting on a cancel-or-delete verdict, open one paid invoice and read the SKU, quantity, billing address, and payment history. "No API key in the environment" does not mean "unused": no-code SaaS runs entirely in a web UI. A "paused" or free-tier notice may belong to an entirely different account than the paid plan.

Reply to their message, not your parallel checklist

When the user says "just reply", answer only the inbound ask. Do not bolt on extra forms, address updates, or multi-item checklists they did not ask for in that draft. Extra tracks get a separate message after the send if still needed. When the user corrects a draft to be shorter or more spoken, rewrite against their wording, not a re-packaged version of your own.

A work handover must match the tracker exactly

When a message tells someone what to work on, do not paraphrase the tracker. Re-run the query and enumerate every open item in board order with identifier, priority, and state. Vagueness is how items get silently dropped and how the recipient invents their own ordering. If something is deliberately excluded, say so and why rather than omitting it silently.

Sequencing irreversible actions: preserve, verify, then destroy

When an action destroys an asset (a contact list, a mailbox, a dataset), order the work export, then verify the export is real, then destroy. "Verify" means row count and headers inspected against an expectation stated up front, so the check is falsifiable. An export returning a few hundred rows from a plan holding tens of thousands is a failed export that looks like a successful one. Split it across two approvals: the export can run autonomously, the destroy waits for the user.

Verify the send actually landed

Do not treat a send tool returning without an error as delivery. Confirm on the channel: the outbound row appears in the thread, or the message carries the SENT label. A DRAFT is not a delivery, and a broken sender alias can bounce seconds later. If verification fails, do not archive; an unverified send is an unanswered thread.

Post-reply disposition, in the same step as the send

Every thread you reply to gets a disposition immediately. There is no "leave it and see".

  • Ball in their court — archive once the send is verified.
  • User still owes something — snooze with a dated reminder naming the owed action and the thread, then archive. An unfulfilled commitment must never stay visible as a substitute for tracking it.
  • Unactionable or already answered — archive.

Only threads genuinely waiting on the user's own next action stay in the inbox.

Auto-resume the next staged outbound

After one item is approved, sent, verified, and dispositioned, stage the next queue item automatically without waiting for "go" or "next". Stop only for the per-draft approval itself, a real decision that is the user's to make, or a blocker only a human can clear.


Read flow: read [channel]

WhatsApp:

mcp__whatsapp__list_chats {sort_by: "last_active"}

Show last 10 chats with sender, preview, timestamp. Use mcp__whatsapp__list_messages {chat_jid, limit: 5} to preview each chat.

Email: Use mcp__claude_ai_Gmail__search_threads with query: "in:inbox" (NOT is:unread — scan full inbox including read messages), show thread list.

Slack (multi-workspace):

Read the derived channels.slack.workspaces[] object from the pre-gathered bin/ops-unread output (NOT the raw preferences.json → slack_workspaces[], which has no available field — it only persists workspace metadata). The bin/ops-unread step resolves each workspace's token_env and emits available: true|false per entry. Iterate that array:

  • For each available: true entry, use mcp__claude_ai_Slack__slack_search_public_and_private with query: "in:channel" (NOT is:unread) if the MCP token matches, or direct curl for non-bound workspaces. To resolve the token for direct curl, the entry's token_env field is the name of the env var; validate it matches ^[A-Za-z_][A-Za-z0-9_]*$ before indirect expansion (${!token_env}) to avoid bash aborting on invalid identifiers.
  • Label results with the workspace name: Slack/<workspace_a>, Slack/<workspace_b>, etc.
  • channels.slack.multi_workspace == false / legacy mode: fall back to mcp__claude_ai_Slack__slack_search_public_and_private if channels.slack.available == true, otherwise report "Slack not configured".

Telegram: Use mcp__claude_ops_telegram__get_updates (limit: 20) and mcp__claude_ops_telegram__list_chats. Fall back to: telegram-cli --exec "dialog_list" 2>/dev/null || echo "Telegram MCP not configured"

Discord: ${CLAUDE_PLUGIN_ROOT}/bin/ops-discord read "<CHANNEL_ID>" --limit 20 --json — requires DISCORD_BOT_TOKEN (or credential-store discord/bot-token). Fall back to bin/ops-discord channels --json if the user doesn't know the channel ID and DISCORD_GUILD_ID is set.

Notion: Use mcp__claude_ai_Notion__notion-search with the user's query (or query: "" sorted by last_edited_time for general browsing). For each result:

  • Fetch full page content with mcp__claude_ai_Notion__notion-fetch using the page URL/ID from search results
  • Get comments with mcp__claude_ai_Notion__notion-get-comments
  • Show page title, database name, last editor, and recent comments

Notion API fallback: If MCP tools fail and NOTION_API_KEY is set, use curl -s -H "Authorization: Bearer $NOTION_API_KEY" -H "Notion-Version: 2022-06-28" -X POST https://api.notion.com/v1/search -d '{"query":"<QUERY>","page_size":10}'

Notion comment/reply

Use mcp__claude_ai_Notion__notion-create-comment with the page ID to reply to a comment thread. For creating new pages in a database, use mcp__claude_ai_Notion__notion-create-pages.

Always preview before commenting:

Ready to comment on Notion page:
  Page: [page title]
  Comment: "[comment text]"

  [Post comment]  [Edit]  [Cancel]
Telegram send

Use mcp__claude_ops_telegram__send_message with chat_id (from list_chats) and text.

Discord send

Shell out to bin/ops-discord send. Three invocation shapes:

# By channel alias (resolves DISCORD_WEBHOOK_<UPPER> or DISCORD_WEBHOOK_URL)
${CLAUDE_PLUGIN_ROOT}/bin/ops-discord send "<channel-alias>" "<message>" --json

# By channel snowflake (17-20 digit ID, routed through bot token)
${CLAUDE_PLUGIN_ROOT}/bin/ops-discord send "<CHANNEL_ID>" "<message>" --json

# By full webhook URL (useful when the URL is stored per-project)
${CLAUDE_PLUGIN_ROOT}/bin/ops-discord send "https://discord.com/api/webhooks/<ID>/<TOKEN>" "<message>" --json

If the script exits 1 with {"error":"no discord credential configured — run /ops:setup discord"}, prompt the user via AskUserQuestion (≤4 options per Rule 1): [Run /ops:setup discord] / [Paste webhook URL now] / [Skip]. Do NOT silently skip — that violates Rule 3.

Note: DISCORD_WEBHOOK_URL is shared with the ops-fires notification sink (scripts/ops-notify.sh). When pre-existing, prefer it as the default for /ops:comms discord send rather than asking the user to set a separate value.

Voice / phone / video

All voice traffic flows through bin/ops-voice (full surface documented in the ops-voice skill). Native channels (Phone.app, FaceTime, Zoom start|join) need no credentials; programmatic channels (Twilio voice/SMS, Bland AI, Zoom schedule) follow the standard credential-resolution order.

# Native — no creds
${CLAUDE_PLUGIN_ROOT}/bin/ops-voice phone    "+1234567890" --json
${CLAUDE_PLUGIN_ROOT}/bin/ops-voice facetime [email protected] --audio --json
${CLAUDE_PLUGIN_ROOT}/bin/ops-voice zoom     start
${CLAUDE_PLUGIN_ROOT}/bin/ops-voice zoom     join 1234567890 --pwd <password>

# Programmatic — gated by Rule 6 (per-message approval)
${CLAUDE_PLUGIN_ROOT}/bin/ops-voice twilio-call "+1234567890" "$TWILIO_FROM_NUMBER" --twiml "<URL>" --json
${CLAUDE_PLUGIN_ROOT}/bin/ops-voice twilio-sms  "+1234567890" "$TWILIO_FROM_NUMBER" "<body>" --json
${CLAUDE_PLUGIN_ROOT}/bin/ops-voice bland-call  "+1234567890" "<task prompt>" --json

Send-flow integration: when $ARGUMENTS looks like call <contact>, facetime <contact>, text <contact> "<body>", or have an AI call <contact> and ...:

  1. Resolve the contact's number/handle (mcp__whatsapp__search_contacts or preferences.json → contacts).
  2. For native calls (phone, facetime, zoom): preview [Place call via <channel> to <contact>] [Cancel] then invoke.
  3. For Twilio voice/SMS and Bland AI: stage the full draft (recipient, channel, body or task-prompt) and gate behind one AskUserQuestion per message (Rule 6). Never batch.

How the approval is actually enforced — see scripts/outbound-guard/README.md. One store, /tmp/.claude-outbound-guard.json, shared by every CLI. The owner arms it from their own shell with ! ok (1 message), ! ok 3, or ! ok all (10, capped). A message is identified by recipient plus content, so the same message crossing the PreToolUse hook and the MCP proxy costs one unit rather than two. A counter does not replace Rule 6: you still stage each draft and wait for a yes.

Three traps that have each caused a real bypass:

  • Never let a helper script do the sending. The Bash guard matches on the text of the command, so a send hidden inside a script is invisible to it. Print the command from a helper if you like, then run the real one inline.
  • A SENT label is not delivery. A send-as alias with bad SMTP credentials is stamped SENT and bounced by mailer-daemon seconds later in the same thread. Check for the bounce before reporting a message as delivered.
  • Match tool names by pattern. Multi-account installs expose mcp__whatsapp-<label>__send_message; an exact-string allowlist silently stops covering them while still appearing to run (Rule 8).
  1. If no credential resolves for a programmatic channel, prompt via AskUserQuestion with [Run /ops:ops-voice setup] / [Paste credential now] / [Try native instead] / [Skip] (Rule 3 — never silently skip).

Empty arguments — channel picker

Display the header, then use batched AskUserQuestion calls (max 4 options each):

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 OPS ► COMMS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Before presenting options, read ${CLAUDE_PLUGIN_DATA_DIR}/preferences.json and check which channels are configured. Only show configured channels. If <=4 total options (configured channels + "Send a message"), present in a single call. If >4, batch:

AskUserQuestion call 1 — Read channels:

  [Read WhatsApp]
  [Read Email]
  [Read Slack]
  [More...]

AskUserQuestion call 2 (only if "More..."):

  [Read Telegram]
  [Make a call (voice)]
  [Send a message]

If all channels are configured, that's 6+ options — always batch. If only 3 channels are configured, "Read X" + "Read Y" + "Read Z" + "Send a message" = 4, fits in one call. The voice channel is configured iff preferences.json → channels.voice is present OR default_channels contains "voice".

Execute the selected action.


Ledger Integration

CLAIM_KEY by channel and message unit:

  • Slack thread: slack:thread:<channel>:<ts>
  • WhatsApp message: slack:thread:wa:<jid>:<ts> (reuse slack: namespace for threads)
  • Outbound draft (no inbound thread): comms:draft:<channel>:<YYYY-MM-DDTHH-MM>
Pre-flight skip-check
CLAIM_KEY="slack:thread:<channel>:<ts>"   # adjust per channel
ledger query --claim-key "$CLAIM_KEY" --since=-PT24H

Skip any message/thread where a done or in_progress entry exists. Surface awaiting_user entries as "draft already staged — resend or edit?"

Claim + resolve
# Claim before drafting
ledger write \
  --claim-key "$CLAIM_KEY" \
  --kind "draft" \
  --status "in_progress" \
  --title "Comms: <channel> — <brief description>" \
  --ttl-sec 3600

# After user approves + send fires
ledger write \
  --claim-key "$CLAIM_KEY" \
  --kind "send" \
  --status "done" \
  --title "Comms: <channel> — <brief description>" \
  --context "sent via <channel>"

Additional resources

CLI detail: references/cli.md.

1---
2name: ops-comms
3description: "OPS on-demand: This skill should be used when the user asks to \"send a message\"…"
4argument-hint: '[channel] | send [message] to [contact] | read [channel] | notion [search query]'
5allowed-tools:
6 - Bash
7 - Read
8 - Grep
9 - Glob
10 - Skill
11 - AskUserQuestion
12 - mcp__claude_ai_Gmail__search_threads
13 - mcp__claude_ai_Gmail__get_thread
14 - mcp__claude_ai_Gmail__create_draft
15 - mcp__claude_ai_Slack__slack_send_message
16 - mcp__claude_ai_Slack__slack_read_channel
17 - mcp__claude_ai_Slack__slack_search_users
18 - mcp__claude_ai_Slack__slack_search_public_and_private
19 - mcp__claude_ops_telegram__send_message
20 - mcp__claude_ops_telegram__get_updates
21 - mcp__claude_ops_telegram__list_chats
22 - mcp__claude_ai_Notion__notion-search
23 - mcp__claude_ai_Notion__notion-fetch
24 - mcp__claude_ai_Notion__notion-get-comments
25 - mcp__claude_ai_Notion__notion-create-comment
26 - mcp__claude_ai_Notion__notion-update-page
27 - mcp__claude_ai_Notion__notion-create-pages
28effort: medium
29maxTurns: 40
30context: fork
31---
32 
33# OPS ► COMMS
34 
35Load `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`).
36 
37## Runtime Context
38 
39Before executing, load available context:
40 
411. **Daemon health**: Read `${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/daemon-health.json`
42 - Check bridge liveness before any WhatsApp operation: `lsof -i :8080 | grep LISTEN`
43 - If bridge not running, prompt user to restart: `launchctl kickstart -k gui/$UID/com.${USER}.whatsapp-bridge`
44 
452. **Ops memories**: Before drafting any message, check `${CLAUDE_PLUGIN_DATA_DIR}/memories/`:
46 - `contact_*.md` — load profile for the recipient
47 - `preferences.md` — match user's communication style, language, and tone
48 - `donts.md` — restrictions that must not appear in any draft
49 
503. **Preferences**: Read `${CLAUDE_PLUGIN_DATA_DIR}/preferences.json` for `default_channels` to determine which channel to prefer when multiple are available for a contact.
51 
52## Routing table
53 
54| Pattern | Action |
55| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
56| `whatsapp` | Show WhatsApp recent chats — offer to read or send |
57| `email` | Show recent email threads via Gmail MCP |
58| `slack` | Show recent Slack activity |
59| `telegram` | Show Telegram recent chats |
60| `discord` | Show recent Discord channel activity (via bin/ops-discord) |
61| `notion` | Search Notion workspace — pages, comments, tasks |
62| `voice` | Voice / phone / video — routes to `/ops:ops-voice` (bin/ops-voice) |
63| `call * ` | Native Phone.app call via `bin/ops-voice phone` |
64| `facetime *` | FaceTime audio/video via `bin/ops-voice facetime` |
65| `zoom` | Start an instant Zoom meeting via `bin/ops-voice zoom start` |
66| `send * to *` | Parse message and contact, determine best channel, send |
67| `read *` | Read the specified channel or contact's messages |
68| `home alarm` | Pipe a Homey alarm event as a WhatsApp/Telegram alert — delegates to `/ops:ops-home alarm --notify` (only if `home_automation` is configured in `$PREFS_PATH`) |
69| (empty) | Show channel picker menu |
70 
71Natural-language parsing:
72 
73- `send "deploy done" to #general on discord` → `bin/ops-discord send general "deploy done"`.
74- `call <contact>` / `dial <contact>` / `phone <contact>` → resolve number, then `bin/ops-voice phone <E.164>`.
75- `facetime <contact>` (with optional `audio`) → `bin/ops-voice facetime <handle> [--audio]`.
76- `start a zoom` / `new zoom meeting` → `bin/ops-voice zoom start`.
77- `join zoom <ID>` → `bin/ops-voice zoom join <ID>`.
78- `text <contact> "<body>"` / `sms <contact> "<body>"` → `bin/ops-voice twilio-sms <to> $TWILIO_FROM_NUMBER "<body>"` (guarded by per-message approval).
79 
80---
81 
82## Send flow: `send [message] to [contact]`
83 
841. Parse contact name and message from `$ARGUMENTS`.
852. Determine channel by contact lookup:
86 - Check WhatsApp: `mcp__whatsapp__search_contacts {query: "[contact]"}` 2>/dev/null
87 - Check Slack: `mcp__claude_ai_Slack__slack_search_users` with `query: "[contact]"`
88 - Check email: known from context or ask
893. If multiple channels found, use `AskUserQuestion`: `[WhatsApp]` / `[Slack]` / `[Email]`
904. **Always preview before sending.** Use `AskUserQuestion` to confirm:
91 
92```
93Ready to send via [channel]:
94 To: [contact name] ([identifier])
95 Message: "[full message text]"
96 
97 [Send now] [Edit message] [Cancel]
98```
99 
100If user picks "Edit message", use `AskUserQuestion` with free-text to get the revised message, then re-preview.
101 
1025. Send via the chosen channel. Confirm with: `Sent to [contact] via [channel] ✓`
103 
104### WhatsApp send
105 
106**CRITICAL — READ BEFORE SENDING:** Before drafting ANY WhatsApp reply, you MUST:
107 
1081. Read the full conversation: `mcp__whatsapp__list_messages {chat_jid: "<JID>", limit: 20}`
1092. Understand which messages have `is_from_me: true` (user sent) vs `is_from_me: false` (contact sent)
1103. Summarize what the conversation is about and what the contact is asking
1114. Only THEN draft a reply that addresses what the contact actually said
112 
113**Never send a reply based on a single message.** A message like "can you pull it from Klaviyo?" means nothing without knowing what "it" refers to from prior messages.
114 
115**Pre-flight:** Check bridge is running: `lsof -i :8080 | grep LISTEN`. If not running, restart: `launchctl kickstart -k gui/$(id -u)/com.${USER}.whatsapp-bridge` and wait 5s.
116 
117```
118mcp__whatsapp__send_message {recipient: "[contact_jid]", message: "[message]"}
119```
120 
121### Slack send
122 
123Use `mcp__claude_ai_Slack__slack_send_message` with resolved channel/user ID.
124 
125### Email send (draft)
126 
127Use `mcp__claude_ai_Gmail__create_draft` — always create draft first. Then use `AskUserQuestion`:
128 
129```
130Draft created for [recipient]:
131 Subject: [subject]
132 Body: [preview]
133 
134 [Send now] [Keep as draft] [Edit]
135```
136 
137---
138 
139## Outbound judgment: verify before you assert
140 
141**Before drafting to a person, load `relations` for their brief, open
142commitments and the full-context draft order, and `vip` for their tier.** A
143tier-1 recipient is drafted with extra care and never handled from a digest
144line. Neither skill can send; Rule 6 still owns the send.
145 
146Rule 6 governs *whether* a message may go out. This section governs *whether the
147message is right*. Every item below is a failure mode seen on a real run, where
148the approval gate held and the content was still wrong.
149 
150### Research every load-bearing claim
151 
152Never state a fact in someone else's inbox from memory. A price, a date, an
153availability, a "who currently holds X" all get a cheap check first: a search, the
154actual thread, the calendar, the invoice.
155 
156**Claims about the recipient's own access get probed, not inferred.** Telling a
157colleague their access is broken is a claim about a system you can inspect. A
158wrong diagnosis sends a competent person chasing a non-problem, and they will
159believe you because you appear to hold the admin view. Sort each access item into
160fixed, never broken, or theirs, and say which. Telling someone "that already
161works" stops them building a workaround for a permission they already have.
162 
163### Verify the join, not just the endpoints
164 
165The hardest fabrications are not invented facts, they are invented *relationships*
166between two real things that share a name, an entity, or a keyword. Each half is
167true, the sentence joining them is not, and it reads as diligence rather than a
168guess.
169 
170"X should be coordinated with Y" is itself a factual claim and needs its own
171evidence. A company or person appearing in two matters is weak evidence they are
172the same workstream and often strong evidence of a naming coincidence. Be
173especially suspicious of an advisory clause nobody asked for: the recipient asked
174a yes or no question, and a "let's just align with..." rider is exactly where
175invented linkage hides. When challenged on such a clause, probe and delete it
176rather than rewording it into something defensible.
177 
178### Do not absorb the other party's job
179 
180An inbound message that mentions a task is not automatically a task for the user.
181When the sender owns the workstream, a heads-up about their own work in progress
182is not an instruction transferring that work.
183 
184Before staging any reply containing a first-person commitment ("I'll pick that
185up", "I'll clean that up"), check whose function it is, who has the access, and
186whether the commitment is even wanted. A message that only needs acknowledging
187gets acknowledgement. Volunteering labour reads as helpful and creates real
188obligations nobody tracks. When the work does need doing and the counterparty
189owns it, name it and hand it back rather than quietly taking it on.
190 
191### Do not route a decision the counterparty already made
192 
193A narrow A/B to the user is right for a real trade-off and wrong when the
194counterparty already supplied the answer inside the message being triaged.
195An identifier a person gives for their **own** account (login, email, phone,
196handle, bank account) is authoritative. A conflict with the admin view means the
197estate is misconfigured, not that they misremembered their own account. Use the
198probing to shape the message, not to defer. Escalate only when the action is
199irreversible and expensive; two systems disagreeing is neither.
200 
201The tell that this rule was broken is a closing question to the user whose answer
202is already quoted verbatim in the inbound message.
203 
204### A staged draft goes stale
205 
206A queue of drafts is researched at one moment and sent over hours. Any draft
207referencing a pending external process (a bank instruction, a signature, a
208shipment, a filing) must have that process re-checked immediately before the
209send, not just at research time. A draft that correctly said "still waiting, no
210news" became wrong when the counterparty's reply landed mid-queue.
211 
212Two consequences are easy to miss: the draft's central claim can flip outright,
213and new inbound often creates a *second* outbound that also needs drafting.
214Search results encountered mid-queue get triaged, not skimmed past.
215 
216**A parallel session may have already sent it.** Multiple agents can work the
217same inbox and send under the same identity. Treat any outbound from the user's
218own identity that this session did not write as another agent's work: verify its
219claims at the source, report the item closed with that evidence, and move on. Do
220not re-send, and do not assume it was correct just because it exists.
221 
222### A counterparty's restatement of your position is not authoritative
223 
224When someone replies "which of these did you mean?" or summarises your position
225back before proceeding, do not pick the more plausible reading. Open what the
226user actually sent and read their own words. A partner who guesses wrong and
227proceeds on that guess can commit the user, in writing, to a different deal than
228the one they proposed. Their politeness is not evidence; a soft hedge from a
229competent counterparty is exactly where an unnoticed inversion lives.
230 
231### When the user contradicts your draft, check before defending it
232 
233Low-confidence pushback ("check, I think we already signed", "I might have
234credentials too") is a hard stop and a research instruction, not a hedge to be
235reassured about. Re-probe the primary source and come back with what it says,
236including "partly, the mechanism is X not Y". Never defend a draft against the
237user's own recollection without checking first.
238 
239### Do not trust an audit's verdict over primary evidence
240 
241A subagent audit is a research artifact, not a finding. Before acting on a
242cancel-or-delete verdict, open one paid invoice and read the SKU, quantity,
243billing address, and payment history. "No API key in the environment" does not
244mean "unused": no-code SaaS runs entirely in a web UI. A "paused" or free-tier
245notice may belong to an entirely different account than the paid plan.
246 
247### Reply to their message, not your parallel checklist
248 
249When the user says "just reply", answer only the inbound ask. Do not bolt on
250extra forms, address updates, or multi-item checklists they did not ask for in
251that draft. Extra tracks get a separate message after the send if still needed.
252When the user corrects a draft to be shorter or more spoken, rewrite against
253*their* wording, not a re-packaged version of your own.
254 
255### A work handover must match the tracker exactly
256 
257When a message tells someone what to work on, do not paraphrase the tracker.
258Re-run the query and enumerate every open item in board order with identifier,
259priority, and state. Vagueness is how items get silently dropped and how the
260recipient invents their own ordering. If something is deliberately excluded, say
261so and why rather than omitting it silently.
262 
263### Sequencing irreversible actions: preserve, verify, then destroy
264 
265When an action destroys an asset (a contact list, a mailbox, a dataset), order
266the work export, then verify the export is real, then destroy. "Verify" means row
267count and headers inspected against an expectation stated up front, so the check
268is falsifiable. An export returning a few hundred rows from a plan holding tens
269of thousands is a failed export that looks like a successful one. Split it across
270two approvals: the export can run autonomously, the destroy waits for the user.
271 
272### Verify the send actually landed
273 
274Do not treat a send tool returning without an error as delivery. Confirm on the
275channel: the outbound row appears in the thread, or the message carries the
276`SENT` label. A `DRAFT` is not a delivery, and a broken sender alias can bounce
277seconds later. If verification fails, do not archive; an unverified send is an
278unanswered thread.
279 
280### Post-reply disposition, in the same step as the send
281 
282Every thread you reply to gets a disposition immediately. There is no "leave it
283and see".
284 
285- **Ball in their court** — archive once the send is verified.
286- **User still owes something** — snooze with a dated reminder naming the owed
287 action and the thread, then archive. An unfulfilled commitment must never stay
288 visible as a substitute for tracking it.
289- **Unactionable or already answered** — archive.
290 
291Only threads genuinely waiting on the user's own next action stay in the inbox.
292 
293### Auto-resume the next staged outbound
294 
295After one item is approved, sent, verified, and dispositioned, stage the next
296queue item automatically without waiting for "go" or "next". Stop only for the
297per-draft approval itself, a real decision that is the user's to make, or a
298blocker only a human can clear.
299 
300---
301 
302## Read flow: `read [channel]`
303 
304**WhatsApp:**
305 
306```
307mcp__whatsapp__list_chats {sort_by: "last_active"}
308```
309 
310Show last 10 chats with sender, preview, timestamp. Use `mcp__whatsapp__list_messages {chat_jid, limit: 5}` to preview each chat.
311 
312**Email:**
313Use `mcp__claude_ai_Gmail__search_threads` with `query: "in:inbox"` (NOT `is:unread` — scan full inbox including read messages), show thread list.
314 
315**Slack (multi-workspace):**
316 
317Read the **derived** `channels.slack.workspaces[]` object from the pre-gathered `bin/ops-unread` output (NOT the raw `preferences.json` → `slack_workspaces[]`, which has no `available` field — it only persists workspace metadata). The `bin/ops-unread` step resolves each workspace's `token_env` and emits `available: true|false` per entry. Iterate that array:
318 
319- For each `available: true` entry, use `mcp__claude_ai_Slack__slack_search_public_and_private` with `query: "in:channel"` (NOT `is:unread`) if the MCP token matches, or direct curl for non-bound workspaces. To resolve the token for direct curl, the entry's `token_env` field is the **name** of the env var; validate it matches `^[A-Za-z_][A-Za-z0-9_]*$` before indirect expansion (`${!token_env}`) to avoid bash aborting on invalid identifiers.
320- Label results with the workspace name: `Slack/<workspace_a>`, `Slack/<workspace_b>`, etc.
321- **`channels.slack.multi_workspace == false` / legacy mode**: fall back to `mcp__claude_ai_Slack__slack_search_public_and_private` if `channels.slack.available == true`, otherwise report "Slack not configured".
322 
323**Telegram:**
324Use `mcp__claude_ops_telegram__get_updates` (limit: 20) and `mcp__claude_ops_telegram__list_chats`.
325Fall back to: `telegram-cli --exec "dialog_list" 2>/dev/null || echo "Telegram MCP not configured"`
326 
327**Discord:**
328`${CLAUDE_PLUGIN_ROOT}/bin/ops-discord read "<CHANNEL_ID>" --limit 20 --json` — requires `DISCORD_BOT_TOKEN` (or credential-store `discord/bot-token`). Fall back to `bin/ops-discord channels --json` if the user doesn't know the channel ID and `DISCORD_GUILD_ID` is set.
329 
330**Notion:**
331Use `mcp__claude_ai_Notion__notion-search` with the user's query (or `query: ""` sorted by `last_edited_time` for general browsing). For each result:
332 
333- Fetch full page content with `mcp__claude_ai_Notion__notion-fetch` using the page URL/ID from search results
334- Get comments with `mcp__claude_ai_Notion__notion-get-comments`
335- Show page title, database name, last editor, and recent comments
336 
337**Notion API fallback:** If MCP tools fail and `NOTION_API_KEY` is set, use `curl -s -H "Authorization: Bearer $NOTION_API_KEY" -H "Notion-Version: 2022-06-28" -X POST https://api.notion.com/v1/search -d '{"query":"<QUERY>","page_size":10}'`
338 
339### Notion comment/reply
340 
341Use `mcp__claude_ai_Notion__notion-create-comment` with the page ID to reply to a comment thread. For creating new pages in a database, use `mcp__claude_ai_Notion__notion-create-pages`.
342 
343Always preview before commenting:
344 
345```
346Ready to comment on Notion page:
347 Page: [page title]
348 Comment: "[comment text]"
349 
350 [Post comment] [Edit] [Cancel]
351```
352 
353### Telegram send
354 
355Use `mcp__claude_ops_telegram__send_message` with `chat_id` (from list_chats) and `text`.
356 
357### Discord send
358 
359Shell out to `bin/ops-discord send`. Three invocation shapes:
360 
361```bash
362# By channel alias (resolves DISCORD_WEBHOOK_<UPPER> or DISCORD_WEBHOOK_URL)
363${CLAUDE_PLUGIN_ROOT}/bin/ops-discord send "<channel-alias>" "<message>" --json
364 
365# By channel snowflake (17-20 digit ID, routed through bot token)
366${CLAUDE_PLUGIN_ROOT}/bin/ops-discord send "<CHANNEL_ID>" "<message>" --json
367 
368# By full webhook URL (useful when the URL is stored per-project)
369${CLAUDE_PLUGIN_ROOT}/bin/ops-discord send "https://discord.com/api/webhooks/<ID>/<TOKEN>" "<message>" --json
370```
371 
372If the script exits 1 with `{"error":"no discord credential configured — run /ops:setup discord"}`, prompt the user via `AskUserQuestion` (≤4 options per Rule 1): `[Run /ops:setup discord]` / `[Paste webhook URL now]` / `[Skip]`. Do NOT silently skip — that violates Rule 3.
373 
374Note: `DISCORD_WEBHOOK_URL` is shared with the ops-fires notification sink (`scripts/ops-notify.sh`). When pre-existing, prefer it as the default for `/ops:comms discord send` rather than asking the user to set a separate value.
375 
376### Voice / phone / video
377 
378All voice traffic flows through `bin/ops-voice` (full surface documented in the `ops-voice` skill). Native channels (Phone.app, FaceTime, Zoom start|join) need no credentials; programmatic channels (Twilio voice/SMS, Bland AI, Zoom schedule) follow the standard credential-resolution order.
379 
380```bash
381# Native — no creds
382${CLAUDE_PLUGIN_ROOT}/bin/ops-voice phone "+1234567890" --json
383${CLAUDE_PLUGIN_ROOT}/bin/ops-voice facetime [email protected] --audio --json
384${CLAUDE_PLUGIN_ROOT}/bin/ops-voice zoom start
385${CLAUDE_PLUGIN_ROOT}/bin/ops-voice zoom join 1234567890 --pwd <password>
386 
387# Programmatic — gated by Rule 6 (per-message approval)
388${CLAUDE_PLUGIN_ROOT}/bin/ops-voice twilio-call "+1234567890" "$TWILIO_FROM_NUMBER" --twiml "<URL>" --json
389${CLAUDE_PLUGIN_ROOT}/bin/ops-voice twilio-sms "+1234567890" "$TWILIO_FROM_NUMBER" "<body>" --json
390${CLAUDE_PLUGIN_ROOT}/bin/ops-voice bland-call "+1234567890" "<task prompt>" --json
391```
392 
393**Send-flow integration:** when `$ARGUMENTS` looks like `call <contact>`, `facetime <contact>`, `text <contact> "<body>"`, or `have an AI call <contact> and ...`:
394 
3951. Resolve the contact's number/handle (`mcp__whatsapp__search_contacts` or `preferences.json` → `contacts`).
3962. For native calls (`phone`, `facetime`, `zoom`): preview `[Place call via <channel> to <contact>] [Cancel]` then invoke.
3973. For Twilio voice/SMS and Bland AI: stage the full draft (recipient, channel, body or task-prompt) and gate behind one `AskUserQuestion` per message (Rule 6). Never batch.
398 
399**How the approval is actually enforced** — see `scripts/outbound-guard/README.md`. One
400store, `/tmp/.claude-outbound-guard.json`, shared by every CLI. The owner arms it from
401their own shell with `! ok` (1 message), `! ok 3`, or `! ok all` (10, capped). A message
402is identified by recipient plus content, so the same message crossing the PreToolUse
403hook and the MCP proxy costs one unit rather than two. A counter does not replace Rule 6:
404you still stage each draft and wait for a yes.
405 
406Three traps that have each caused a real bypass:
407 
408- **Never let a helper script do the sending.** The Bash guard matches on the text of
409 the command, so a send hidden inside a script is invisible to it. Print the command
410 from a helper if you like, then run the real one inline.
411- **A `SENT` label is not delivery.** A send-as alias with bad SMTP credentials is
412 stamped `SENT` and bounced by `mailer-daemon` seconds later in the same thread. Check
413 for the bounce before reporting a message as delivered.
414- **Match tool names by pattern.** Multi-account installs expose
415 `mcp__whatsapp-<label>__send_message`; an exact-string allowlist silently stops
416 covering them while still appearing to run (Rule 8).
4174. If no credential resolves for a programmatic channel, prompt via `AskUserQuestion` with `[Run /ops:ops-voice setup]` / `[Paste credential now]` / `[Try native instead]` / `[Skip]` (Rule 3 — never silently skip).
418 
419---
420 
421## Empty arguments — channel picker
422 
423Display the header, then use **batched AskUserQuestion calls** (max 4 options each):
424 
425```
426━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
427 OPS ► COMMS
428━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
429```
430 
431**Before presenting options**, read `${CLAUDE_PLUGIN_DATA_DIR}/preferences.json` and check which channels are configured. Only show configured channels. If <=4 total options (configured channels + "Send a message"), present in a single call. If >4, batch:
432 
433AskUserQuestion call 1 — Read channels:
434 
435```
436 [Read WhatsApp]
437 [Read Email]
438 [Read Slack]
439 [More...]
440```
441 
442AskUserQuestion call 2 (only if "More..."):
443 
444```
445 [Read Telegram]
446 [Make a call (voice)]
447 [Send a message]
448```
449 
450If all channels are configured, that's 6+ options — always batch. If only 3 channels are configured, "Read X" + "Read Y" + "Read Z" + "Send a message" = 4, fits in one call. The `voice` channel is configured iff `preferences.json` → `channels.voice` is present OR `default_channels` contains `"voice"`.
451 
452Execute the selected action.
453 
454---
455 
456## Ledger Integration
457 
458**CLAIM_KEY by channel and message unit:**
459 
460- Slack thread: `slack:thread:<channel>:<ts>`
461- WhatsApp message: `slack:thread:wa:<jid>:<ts>` (reuse slack: namespace for threads)
462- Outbound draft (no inbound thread): `comms:draft:<channel>:<YYYY-MM-DDTHH-MM>`
463 
464### Pre-flight skip-check
465 
466```bash
467CLAIM_KEY="slack:thread:<channel>:<ts>" # adjust per channel
468ledger query --claim-key "$CLAIM_KEY" --since=-PT24H
469```
470 
471Skip any message/thread where a `done` or `in_progress` entry exists. Surface
472`awaiting_user` entries as "draft already staged — resend or edit?"
473 
474### Claim + resolve
475 
476```bash
477# Claim before drafting
478ledger write \
479 --claim-key "$CLAIM_KEY" \
480 --kind "draft" \
481 --status "in_progress" \
482 --title "Comms: <channel> — <brief description>" \
483 --ttl-sec 3600
484 
485# After user approves + send fires
486ledger write \
487 --claim-key "$CLAIM_KEY" \
488 --kind "send" \
489 --status "done" \
490 --title "Comms: <channel> — <brief description>" \
491 --context "sent via <channel>"
492```
493 
494## Additional resources
495 
496CLI detail: `references/cli.md`.
497 

Discussion