ops-voice · diff
git:20260822.2fde0ca to git:20260822.1ddc215
1 added, 1 removed. Audit A to A.
---
name: ops-voice
- description: "This skill should be used when the user asks to \"/ops:ops-voice\", \"run ops-voice\", or \"use ops-voice\". Voice operations — native macOS Phone (Continuity), FaceTime, Zoom, Google Meet, WhatsApp call, Twilio voice + SMS, Bland AI agent calls, ElevenLabs TTS, Whisper transcription. All curl-based, no SDK deps."
+ description: "This skill should be used when the user asks to \"make a call\", \"facetime\", or \"/ops:ops-voice\". Voice operations — native macOS Phone (Continuity), FaceTime, Zoom, Google Meet, WhatsApp call, Twilio voice + SMS, Bland AI agent calls, ElevenLabs TTS, Whisper transcription. All curl-based, no SDK deps."
argument-hint: '[phone|facetime|zoom|meet|whatsapp-call|twilio-call|twilio-sms|bland-call|tts|transcribe|setup]'
allowed-tools:
- Bash
- Read
- Write
- AskUserQuestion
- WebFetch
---
# OPS:VOICE — Voice Operations
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`).
Voice / phone / video interface. All API calls via curl — no SDK dependencies. Native macOS handlers (Phone.app, FaceTime, Zoom) require no credentials; programmatic channels (Twilio, Bland, ElevenLabs, Groq, Zoom schedule) resolve credentials via:
**Credential resolution order:** env vars → `ops_cred_get` (lib/credential-store.sh / keychain) → `preferences.json` → Doppler CLI (`doppler secrets get <KEY> --plain`) → password manager.
All sub-commands have a thin bash wrapper at `bin/ops-voice` — prefer it over inline curl when scripting.
**Outbound comms guardrail (Rule 6):** `twilio-call`, `twilio-sms`, and `bland-call` are 1:1 outbound channels and MUST follow the per-message approval gate — stage final draft, show full target+body, wait for explicit approval (`AskUserQuestion` or single-word chat approval), send one, then stage the next. Never batch.
---
## Sub-commands
Parse `$ARGUMENTS` for the command keyword, then execute. Native handlers exit fast; API calls report status and (where relevant) a poll command.
---
### `phone <number>` — Native Phone.app via Continuity
Routes through the linked iPhone. Requires macOS + iPhone signed in to the same iCloud account with **Calls on Other Devices** enabled. No credentials.
```bash
bin/ops-voice phone "+1234567890" --json
# {"ok":true,"channel":"phone","detail":"dialing +1234567890 via Phone.app (Continuity)"}
```
Under the hood: `open "tel:<E.164>"`.
---
### `facetime <number-or-email> [--audio]` — FaceTime video/audio
Defaults to video. Pass `--audio` for FaceTime Audio (free, Apple↔Apple). Accepts phone numbers or Apple-ID emails.
```bash
bin/ops-voice facetime user@example.com # video
bin/ops-voice facetime "+1234567890" --audio --json # audio
```
Under the hood: `open "facetime://<handle>"` or `open "facetime-audio://<handle>"`.
---
### `zoom start|join|schedule` — Zoom meetings
```bash
# Open zoom.us app and start a new instant meeting
bin/ops-voice zoom start
# Join an existing meeting
bin/ops-voice zoom join 1234567890 --pwd <password>
# Schedule a meeting via Zoom REST API (requires ZOOM_API_TOKEN — Server-to-Server OAuth access token)
bin/ops-voice zoom schedule "<topic>" --start "2026-05-22T15:00:00Z" --duration 30 --json
# {"ok":true,"channel":"zoom","detail":"scheduled meeting 12345 — https://us05web.zoom.us/j/..."}
```
Native start/join use `zoommtg://` URL scheme. Schedule requires `ZOOM_API_TOKEN` resolved via the order above. Generate a Server-to-Server OAuth app in your Zoom Marketplace, then exchange for an access token.
---
### `meet start|join` — Google Meet
```bash
# Open a brand-new instant meeting (https://meet.new)
bin/ops-voice meet start
# Join by meeting code (xxx-yyyy-zzz) or full URL
bin/ops-voice meet join abc-defg-hij
bin/ops-voice meet join https://meet.google.com/abc-defg-hij --json
```
`meet start` opens `https://meet.new` in the default browser (creates a fresh meeting and lands you in it). `meet join` accepts a 3-4-3 Google Meet code, a bare code, or a full URL — all are normalized to `https://meet.google.com/<code>`. No credentials needed; the user must be signed into Google in the browser.
---
### `whatsapp-call <number> [--video]` — WhatsApp Desktop voice/video call
```bash
# Voice call (default)
bin/ops-voice whatsapp-call "+1234567890"
# Video call
bin/ops-voice whatsapp-call "+1234567890" --video --json
```
Opens WhatsApp Desktop via the `whatsapp://call?phone=<digits>` or `whatsapp://video?phone=<digits>` URL scheme. Requires WhatsApp Desktop installed and signed in. The leading `+` is stripped — WhatsApp expects digits only after `phone=`. No credentials needed; the WhatsApp session lives in the desktop app.
---
### `join [--at now|next|HH:MM] [--window MIN] [--dry-run]` — Smart calendar-driven meeting joiner
Auto-joins the meeting that's happening **now** (within `±window` minutes, default 10) or the next future meeting if nothing is current. Reads `gog calendar events --all --today -j --sort start`, extracts a conference URL from `hangoutLink` → `conferenceData.entryPoints[]` → location → description scan (supports Zoom, Google Meet, Microsoft Teams, Webex), applies the smart AV policy below, and hands off to the native opener (which honors Rule 7 on SSH/mobile).
```bash
bin/ops-voice join # join current/next meeting
bin/ops-voice join --at next # skip current, go to next future
bin/ops-voice join --window 5 # only consider events within ±5min of now
bin/ops-voice join --dry-run # show what would happen — no launch
bin/ops-voice join --dry-run --json
```
**AV policy (smart heuristic):**
| Attendees | Camera | Microphone |
| --------- | ------ | ---------- |
| 1–2 | ON | ON |
| 3–9 | ON | MUTED |
| 10+ | OFF | MUTED |
**Per-event overrides** — tag the event description (case-insensitive):
```
[cam:on] [mic:off] → force camera on, mic muted
[cam:off] → force camera off (keep heuristic mic)
[mic:muted] → force mic muted
```
**Mic source — lid state:**
- **macOS** via `ioreg AppleClamshellState` → `Yes`=closed (external mic), `No`=open (MacBook mic).
- **Linux** via `/proc/acpi/button/lid/*/state` → "open"/"closed".
- **Other OS / unknown** → reports `default`; the meeting app uses whatever the system has selected.
Note: the script reports the policy and launches Camera Hub when present, but it does **not** programmatically flip Zoom/FaceTime/Meet in-app device settings — those apps remember the last-selected device, so flipping it once per app is permanent. (A future patch could AppleScript Zoom's preferences pane.)
**Elgato Virtual Camera:** if Elgato Camera Hub is installed (any OS), it's launched before the meeting opens so the virtual cam is registered. Detection paths:
- macOS: `/Applications/Elgato Camera Hub.app`, `~/Applications/Elgato Camera Hub.app`, `/Applications/Camera Hub.app`
- Linux: `elgato-camera-hub` on PATH, or AppImage at `~/Applications/Elgato*CameraHub*.AppImage`
- Windows/WSL: `${PROGRAMFILES}/Elgato/CameraHub/CameraHub.exe`
**Zoom URL rewriting:** when the picked event has a `https://zoom.us/j/<ID>?pwd=<PWD>` link, it's converted to `zoommtg://zoom.us/join?confno=<ID>&pwd=<PWD>` so the desktop app opens directly (no browser prompt). If `cam=off` from the policy, `&zc=0` is appended.
**Dry-run output (text mode):**
```
dry-run: would join "Weekly Sync" (meet, 3 attendees)
url=https://meet.google.com/abc-defg-hij
cam=on mic=muted
lid=closed mic_source=external
elgato_hub=/Applications/Elgato Camera Hub.app
```
---
### `twilio-call <to> <from> --twiml <URL>` — Programmatic outbound voice
Real telco call (per-minute cost). Requires `TWILIO_ACCOUNT_SID` + `TWILIO_AUTH_TOKEN`. The `--twiml` URL must return TwiML XML describing call behavior — e.g. `https://demo.twilio.com/docs/voice.xml` or a custom Function/Studio flow.
```bash
bin/ops-voice twilio-call "+1234567890" "+15551234567" \
--twiml "https://demo.twilio.com/docs/voice.xml" --json
```
---
### `twilio-sms <to> <from> "<body>"` — Programmatic outbound SMS
```bash
bin/ops-voice twilio-sms "+1234567890" "+15551234567" "<body>" --json
```
For inbound SMS / WhatsApp routing, point your Twilio webhook at the ops daemon (out of scope for v1).
---
### `bland-call <number> "<prompt>"` — Bland AI agent phone call
AI agent calls the number and follows the natural-language prompt. Recordings + transcripts available via the poll URL printed on success.
```bash
bin/ops-voice bland-call "+1234567890" "<task prompt>" --json
```
Poll with: `curl -H "authorization: $BLAND_AI_API_KEY" https://api.bland.ai/v1/calls/<call_id>`.
---
### `tts [text] [--voice voice_id] [--out file.mp3]` — ElevenLabs text-to-speech
**Requires:** `ELEVENLABS_API_KEY` (env / keychain / Doppler).
```bash
EL_KEY="${ELEVENLABS_API_KEY:-$(doppler secrets get ELEVENLABS_API_KEY --plain 2>/dev/null || true)}"
VOICE_ID="${ELEVENLABS_VOICE_ID:-21m00Tcm4TlvDq8ikWAM}" # Rachel
TEXT="<from $ARGUMENTS>"
OUT_FILE="${OUT_FILE:-/tmp/ops-tts-$(date +%s).mp3}"
curl -s -X POST "https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}" \
-H "xi-api-key: $EL_KEY" \
-H "Content-Type: application/json" \
-d "{
\"text\": \"$TEXT\",
\"model_id\": \"eleven_monolingual_v1\",
\"voice_settings\": {\"stability\": 0.5, \"similarity_boost\": 0.75}
}" \
--output "$OUT_FILE"
command -v afplay >/dev/null && afplay "$OUT_FILE" &
echo "Audio saved to: $OUT_FILE"
```
---
### `transcribe [file_path]` — Groq Whisper transcription
**Requires:** `GROQ_API_KEY` (env / keychain / Doppler).
```bash
GROQ_KEY="${GROQ_API_KEY:-$(doppler secrets get GROQ_API_KEY --plain 2>/dev/null || true)}"
AUDIO_FILE="<from $ARGUMENTS>"
[ -f "$AUDIO_FILE" ] || { echo "ERROR: $AUDIO_FILE not found"; exit 1; }
curl -s -X POST "https://api.groq.com/openai/v1/audio/transcriptions" \
-H "Authorization: Bearer $GROQ_KEY" \
-F "file=@$AUDIO_FILE" \
-F "model=whisper-large-v3" \
-F "response_format=json" | \
jq -r '.text'
```
---
### `setup` — Configure voice channels
Before asking for anything, auto-scan ALL sources in one background batch (Rule 4: `run_in_background: true`):
```bash
# Env vars
printenv \
TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_FROM_NUMBER \
BLAND_AI_API_KEY ELEVENLABS_API_KEY GROQ_API_KEY \
ZOOM_API_TOKEN ZOOM_ACCOUNT_ID ZOOM_CLIENT_ID ZOOM_CLIENT_SECRET 2>/dev/null
# Shell profiles
grep -hE 'TWILIO|BLAND|ELEVENLABS|GROQ|ZOOM' \
~/.zshrc ~/.bashrc ~/.zprofile ~/.envrc 2>/dev/null | grep -v '^#'
# Doppler — ALL projects
for proj in $(doppler projects --json 2>/dev/null | jq -r '.[].slug'); do
for cfg in dev stg prd; do
doppler secrets --project "$proj" --config "$cfg" --json 2>/dev/null | \
jq -r --arg p "$proj" --arg c "$cfg" \
'to_entries[]
| select(.key | test("TWILIO|BLAND|ELEVENLABS|GROQ|ZOOM"; "i"))
| "\(.key)=\(.value.computed | .[0:12])... (doppler:\($p)/\($c))"'
done
done
# Keychain
for svc in twilio bland-ai elevenlabs groq zoom; do
security find-generic-password -s "$svc" -w 2>/dev/null >/dev/null \
&& echo "[keychain] $svc ✓"
done
# Native macOS prerequisites
ls /Applications/zoom.us.app \
/System/Applications/FaceTime.app \
/System/Applications/Phone.app 2>/dev/null
```
Validate found keys (in parallel):
| Channel | Probe |
| ---------- | ------------------------------------------------------------------------------ |
| Twilio | `curl -u "$SID:$TOKEN" https://api.twilio.com/2010-04-01/Accounts/$SID.json` |
| Bland | `curl -H "authorization: $KEY" https://api.bland.ai/v1/me` |
| ElevenLabs | `curl -H "xi-api-key: $KEY" "https://api.elevenlabs.io/v1/voices?page_size=1"` |
| Groq | `curl -H "Authorization: Bearer $KEY" https://api.groq.com/openai/v1/models` |
| Zoom | `curl -H "Authorization: Bearer $TOKEN" https://api.zoom.us/v2/users/me` |
Report each as `[service] ✓ connected` or `[service] ✗ <error>`. **Rule 3 applies — never silently skip a channel.** For each unset service, present `AskUserQuestion` with `[Paste manually]` / `[Deep hunt — spawn agent]` / `[Skip]`.
Persist to `preferences.json`:
```json
{
"channels": {
"voice": {
"backend": "native+twilio+zoom+bland",
"native": { "phone": true, "facetime": true, "zoom": true },
"twilio": { "status": "configured", "from_number": "env:TWILIO_FROM_NUMBER" },
"bland": { "status": "configured" },
"zoom": { "status": "configured" }
}
},
"default_channels": ["whatsapp", "email", "telegram", "slack", "voice"]
}
```
---
## Routing from `/ops:comms`
Voice is wired into `/ops:comms` send-flow. The router resolves intent like:
| User says | Resolves to |
| ------------------------------------- | ------------------------------------- |
| `call <name>` | `ops-voice phone <number>` |
| `facetime <name>` | `ops-voice facetime <handle>` |
| `start a zoom` | `ops-voice zoom start` |
| `text <name> "..."` | `ops-voice twilio-sms ... "..."` |
| `have an AI call <name> and tell ...` | `ops-voice bland-call <number> "..."` |
Contact-number lookup uses the same contact resolver as WhatsApp (`mcp__whatsapp__search_contacts`) plus an optional `contacts.json` map in `preferences.json`.
---
## Mobile / SSH mode (Rule 7)
When `$SSH_CONNECTION$SSH_CLIENT$SSH_TTY` is set or `$OPS_MOBILE=1`:
- `bin/ops-voice` still works for API channels.
- Native channels (`phone`, `facetime`, `zoom start|join`) require a local macOS session — the script returns a plain-text instruction to open the URL on the host instead of calling `open` directly. The script must source `lib/opener.sh` and use `ops_open_url` for URL handoff.
(v1 of `bin/ops-voice` calls `/usr/bin/open` directly — Rule 7 adapter is a v1.1 follow-up; tracked in CHANGELOG.)
---
## Execution
1. Resolve the sub-command from `$ARGUMENTS` (first word).
2. For native handlers (phone/facetime/zoom start|join), shell out to `bin/ops-voice` — exit fast.
3. For API channels (twilio/bland/zoom-schedule/tts/transcribe), resolve credentials in order, then curl.
4. If a required key is missing, suggest `/ops:ops-voice setup`.
5. For 1:1 outbound channels (twilio-call/sms, bland-call): stage one draft → `AskUserQuestion` → send → next.