ops-voice
GitHubmacOS语音与视频操作技能,支持Phone、FaceTime、Zoom、Meet等原生应用及Twilio、Bland AI等API服务。通过curl调用,无SDK依赖,具备统一凭证解析机制和出站通信审批护栏。
Trigger Scenarios
Install
npx skills add Lifecycle-Innovations-Limited/claude-ops --skill ops-voice -g -y
SKILL.md
Frontmatter
{
"name": "ops-voice",
"description": "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.",
"allowed-tools": [
"Bash",
"Read",
"Write",
"AskUserQuestion",
"WebFetch"
],
"argument-hint": "[phone|facetime|zoom|meet|whatsapp-call|twilio-call|twilio-sms|bland-call|tts|transcribe|setup]"
}
OPS:VOICE — Voice Operations
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.
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.
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
# 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
# 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
# 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).
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-hubon 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.
bin/ops-voice twilio-call "+1234567890" "+15551234567" \
--twiml "https://demo.twilio.com/docs/voice.xml" --json
twilio-sms <to> <from> "<body>" — Programmatic outbound SMS
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.
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).
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).
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):
# 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:
{
"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-voicestill 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 callingopendirectly. The script must sourcelib/opener.shand useops_open_urlfor 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
- Resolve the sub-command from
$ARGUMENTS(first word). - For native handlers (phone/facetime/zoom start|join), shell out to
bin/ops-voice— exit fast. - For API channels (twilio/bland/zoom-schedule/tts/transcribe), resolve credentials in order, then curl.
- If a required key is missing, suggest
/ops:ops-voice setup. - For 1:1 outbound channels (twilio-call/sms, bland-call): stage one draft →
AskUserQuestion→ send → next.
Version History
- 64bad13 Current 2026-08-12 09:02


