diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 90eb176..b0d327b 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -4,14 +4,14 @@ "name": "Function1st" }, "metadata": { - "description": "Book a restaurant by phone. Need: Telnyx KYC + one US number, xAI team with ZDR off, API keys, Voice Agent Builder.", - "version": "0.3.20" + "description": "Phone skills for voice tasks (restaurant booking first). Need: Telnyx KYC + one US number, xAI team with ZDR off, API keys, Voice Agent Builder.", + "version": "0.4.1" }, "plugins": [ { "name": "phonezero", "source": "./plugins/phonezero", - "description": "Book a restaurant by phone. Need: Telnyx KYC + one US number, xAI team with ZDR off, API keys, Voice Agent Builder. Then /setup-phone-calling.", + "description": "Phone skills for voice tasks (restaurant booking first). Need: Telnyx KYC + one US number, xAI team with ZDR off, API keys, Voice Agent Builder. Then /setup-phone-calling.", "logo": "assets/logo.png" } ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbdd9ea..ba4b8bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,20 @@ jobs: node scripts/xai-mcp.mjs --self-test node plugins/phonezero/scripts/launch-xai-mcp.mjs --resolve-only + - name: Prompt paste files + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + desc = Path("plugins/phonezero/prompts/end_call.md").read_text(encoding="utf-8").strip() + voice = Path("plugins/phonezero/prompts/voice-agent.md").read_text(encoding="utf-8") + if not desc: + raise SystemExit("end_call.md is empty") + if desc not in voice: + raise SystemExit("end_call.md must appear verbatim in voice-agent.md") + print("end_call description in sync") + PY + - name: TeXML well-formed run: | set -euo pipefail @@ -80,5 +94,6 @@ jobs: - name: Privacy guards run: | set -euo pipefail + git fetch --no-tags origin "${GITHUB_BASE_REF:-main}:refs/remotes/origin/${GITHUB_BASE_REF:-main}" || true python3 scripts/privacy-check.py --self-test python3 scripts/privacy-check.py diff --git a/AGENTS.md b/AGENTS.md index 751edd2..e6704fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # PhoneZero — agent bootstrap -PhoneZero books a restaurant table by phone (Telnyx hosted MCP + xAI Voice Agent) with zero user infrastructure. +PhoneZero is a phone-skills platform (Telnyx hosted MCP + xAI Voice Agent) with zero user infrastructure. Restaurant booking is the first skill. If the user pointed you at `https://github.com/function1st/PhoneZero` or said **set this up** / **set up phone calling** / `/setup-phone-calling`: identify the host, then run **only** that host’s section. Do not mix them. @@ -15,7 +15,7 @@ If the user pointed you at `https://github.com/function1st/PhoneZero` or said ** You are a **computer-use** agent. Drive the UI yourself. Do these steps **in order**. Do not treat missing package names as missing APIs. Do not hunt the Telnyx catalog for `whoami` or `/v2/whoami` — they are not MCP tools. -[README.md](README.md) has the same playbook at the top. Booking rules after the ids are in session: [`plugins/phonezero/skills/phonezero/SKILL.md`](plugins/phonezero/skills/phonezero/SKILL.md) §§2–12. +[README.md](README.md) has the same playbook at the top. After the ids are in session: [`plugins/phonezero/skills/phonezero-runtime/SKILL.md`](plugins/phonezero/skills/phonezero-runtime/SKILL.md) (dial + Grok ad-hoc interview) and the matching phone skill ([`book-restaurant`](plugins/phonezero/skills/book-restaurant/SKILL.md) or [`confirm-business-hours`](plugins/phonezero/skills/confirm-business-hours/SKILL.md)). How-to: [`docs/SKILLS.md`](docs/SKILLS.md). **Do not** Customize → **+ Add** this repo as a Cursor plugin. Cursor **PhoneZero** (`plugins/phonezero`) is stdio Telnyx → on Grok the key never arrives → Telnyx **10009**. @@ -34,8 +34,8 @@ Do **not** silently keep defaults. “Set this up” is not consent to these. Sh ``` Call settings (defaults — change any now) -1. Spoken name the restaurant hears: PhoneZero - “Hello, this is {name}…” — keep PhoneZero, or set your name / an alias. +1. Spoken name the callee hears: PhoneZero + “Hello, this is {name}…” — keep PhoneZero, or set your name / an alias. 2. AI disclaimer in the opener: ON ON → “…{name}, an automated assistant, calling on a recorded line…” @@ -43,7 +43,7 @@ Call settings (defaults — change any now) You may turn this OFF. ``` -Keep their answers in session. Write them into Edit Values / xAI env: `PHONEZERO_AGENT_NAME`, `PHONEZERO_DISCLOSE_AI` (or `PHONEZERO_CFG_*`). If they turn disclaimer OFF and the Builder agent already exists, re-paste `prompts/voice-agent.md` once with `{disclosure_clause}` empty — toggling the variable does not change a baked prompt. +Keep their answers in session and put them on each `phonezero-task` (`spoken_name`, `disclose_ai`). Do **not** put name or disclose on the Configure / Edit Values card. If they turn disclaimer OFF and the Builder agent already exists, re-paste `prompts/voice-agent.md` once with `{disclosure_clause}` empty — that paste is the only way to change a baked prompt. Per-call they may still pick a different spoken name. ### 2. Uninstall the wrong plugin @@ -61,7 +61,7 @@ Customize → **MCPs** → add **HTTP** (not stdio, not `npx`): | URL | `https://api.telnyx.com/v2/mcp` | | Header | `Authorization` = `Bearer ` + key **in that form** | -If Grok-native **phonezero-grok** is available (not Cursor + Add): install it, **Edit Values** for `TELNYX_API_KEY`, From, `XAI_API_KEY`, plus name / disclose from **1**. If a secret field needs the human, open that field and let them type. +If Grok-native **phonezero-grok** is available (not Cursor + Add): install it, **Edit Values** for `TELNYX_API_KEY`, From, `XAI_API_KEY` only. If a secret field needs the human, open that field and let them type. [`.grok/config.toml`](.grok/config.toml) is the same HTTP Telnyx for hosts that load project Grok MCP. @@ -73,8 +73,8 @@ Call `list_api_endpoints`. Real endpoint list → **5**. `401` / **10009** / con Prefer the PhoneZero xAI MCP (8 tools). “No Grok xAI *package*” does **not** mean you cannot upload. Hosts are always `api.x.ai`. -- If `put_booking` is already listed → skip adding a connector. -- Else Customize → MCPs → **stdio** (not HTTP) name `xai`. Copy the `xai` block from [`plugins/phonezero/mcp.json`](plugins/phonezero/mcp.json) (same launcher is in [`plugins/phonezero-grok/.mcp.json`](plugins/phonezero-grok/.mcp.json)). Bind **real** values from secure fields: `XAI_API_KEY` or `PHONEZERO_CFG_XAI_API_KEY`, `PHONEZERO_FROM_NUMBER` or `PHONEZERO_CFG_FROM_NUMBER`, plus name / disclose from **1**. Do **not** leave literal `${…}` in env. +- If `put_task` or `put_booking` is already listed → skip adding a connector. +- Else Customize → MCPs → **stdio** (not HTTP) name `xai`. Copy the `xai` block from [`plugins/phonezero/mcp.json`](plugins/phonezero/mcp.json) (same launcher is in [`plugins/phonezero-grok/.mcp.json`](plugins/phonezero-grok/.mcp.json)). Bind **real** values from secure fields: `XAI_API_KEY` or `PHONEZERO_CFG_XAI_API_KEY`, `PHONEZERO_FROM_NUMBER` or `PHONEZERO_CFG_FROM_NUMBER`. Do **not** leave literal `${…}` in env. Prove: `get_call_config` (`xai_key_wired`, `from_wired`, From last-4) then `ensure_collection` (name `PhoneZero bookings`). @@ -122,24 +122,24 @@ xAI: `list_phone_numbers` → `register_byo_number` if the DID is not `byo_trunk ### 8. Calls -You already have SID, TeXML id, From, spoken name, disclose, and the Telnyx destination list. Read skill §§2–12 (plan-first, two attempts, `booked` only with a host confirmation in the transcript). Owner setup-test to **their own confirmed number** may skip the hours guard — restaurants may not. The call plan must show Spoken as and only dial countries on that Telnyx whitelist. Per-call they may still override the spoken name. +You already have SID, TeXML id, From, spoken name, disclose, and the Telnyx destination list. Read `phonezero-runtime` (plan-first, two attempts, `succeeded` / `booked` only with a live-person confirmation in the transcript). Match a shipped skill, or **interview into a `phonezero-task`** — do not ask them to paste a `SKILL.md` or write `~/.cursor/skills`. Owner setup-test to **their own confirmed number** may skip the hours guard — restaurants may not. The call plan must show Spoken as and only dial countries on that Telnyx whitelist. Per-call they may still override the spoken name. If they ask to save the shape as a template, pick memory or `put_template` and say where it went. On explicit yes, in this order — do not resolve SID again: -1. `put_booking` — wait until processed -2. `invoke_api_endpoint` `calls_accounts_texml_calls` (skill Dial JSON; `account_sid` + `ApplicationSid` from session) +1. `put_task` (or `put_booking` alias) — wait until processed +2. `invoke_api_endpoint` `calls_accounts_texml_calls` (runtime Dial JSON; `account_sid` + `ApplicationSid` from session) 3. Poll `retrieve_calls_accounts_texml_calls` 4. `retrieve_recordings_json_calls_accounts_texml_recordings_json` (not the write-named twin) 5. `transcribe` — do not paste the audio URL -6. Classify, then `delete_booking`. Keep the Telnyx recording. +6. Classify, then `delete_booking` (live brief only). Keep the Telnyx recording. --- ## Cursor IDE — set this up -1. Read [`plugins/phonezero/skills/phonezero/SKILL.md`](plugins/phonezero/skills/phonezero/SKILL.md) in full. Installed plugin: `skills/phonezero/SKILL.md`. Commands: `/setup-phone-calling`, `/book-table`. +1. Read [`plugins/phonezero/skills/phonezero-runtime/SKILL.md`](plugins/phonezero/skills/phonezero-runtime/SKILL.md) and [`plugins/phonezero/skills/book-restaurant/SKILL.md`](plugins/phonezero/skills/book-restaurant/SKILL.md) in full. Commands: `/setup-phone-calling`, `/book-table`, `/book-restaurant`, `/confirm-business-hours`. Local skills: [`docs/SKILLS.md`](docs/SKILLS.md). 2. Install the **Cursor** package only: [`.cursor-plugin/marketplace.json`](.cursor-plugin/marketplace.json) → [`plugins/phonezero/`](plugins/phonezero/). Channels: Customize → **+ Add** this repo root; Cursor Marketplace; copy `plugins/phonezero/` to `~/.cursor/plugins/local/phonezero`; or Team Marketplace import. Telnyx is stdio `npx @telnyx/mcp` + Configure `TELNYX_API_KEY`. Do **not** add hosted-HTTP Telnyx in this package (SSE GET 404 tombstone). -3. Configure card: `TELNYX_API_KEY`, `PHONEZERO_FROM_NUMBER`, `XAI_API_KEY` (ZDR **off**). Name / disclose have defaults. Destinations are the Telnyx voice-profile whitelist, not this card. Do not put account SID, TeXML id, or collection id on the card. New conversation after install. Verify with `list_api_endpoints` and `get_call_config`. +3. Configure card: `TELNYX_API_KEY`, `PHONEZERO_FROM_NUMBER`, `XAI_API_KEY` (ZDR **off**) only. Spoken name and disclose are per-task (chat), not this card. Destinations are the Telnyx voice-profile whitelist. Do not put account SID, TeXML id, or collection id on the card. New conversation after install. Verify with `list_api_endpoints` and `get_call_config`. 4. Then run the skill Setup (Telnyx MCP + xAI MCP + Builder once). Session SID is MCP `list_billing_groups` → `organization_id` (not a `whoami` tool). Human walkthrough: [`docs/SETUP.md`](docs/SETUP.md). `scripts/provision.sh` is developer-only on a personal machine — never here. @@ -149,5 +149,5 @@ Human walkthrough: [`docs/SETUP.md`](docs/SETUP.md). `scripts/provision.sh` is d ## Both hosts - Never commit or echo keys. Configure / Edit Values / MCP headers only — not the agent shell. -- Builder prompt is static. Brief each call with `phonezero-booking.json` in the xAI collection — no TeXML ``, no per-call Builder edit. +- Builder prompt is static. Brief each call with `phonezero-task.json` in the xAI collection — no TeXML ``, no per-call Builder edit. Re-paste `prompts/voice-agent.md` and `prompts/end_call.md` if the agent still searches `phonezero-booking.json`. - An old chat missing new MCP tools is not a failure — new conversation after install. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e4e3f6..f79e4db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ ## Dev setup 1. Fork and clone. Do not put real keys or numbers in the tree. -2. Read [docs/SETUP.md](docs/SETUP.md). The skill, voice prompt, and inline TeXML template are the product; this repo is a Cursor plugin marketplace ([`.cursor-plugin/marketplace.json`](.cursor-plugin/marketplace.json) at the repo root, plugin at [`plugins/phonezero/`](plugins/phonezero/)). +2. Read [docs/SETUP.md](docs/SETUP.md) and [docs/SKILLS.md](docs/SKILLS.md). The runtime skill, first-party phone skills, voice prompt, and inline TeXML template are the product; this repo is a Cursor plugin marketplace ([`.cursor-plugin/marketplace.json`](.cursor-plugin/marketplace.json) at the repo root, plugin at [`plugins/phonezero/`](plugins/phonezero/)). Contributing a skill is **optional** — Cursor users can keep a folder in `~/.phonezero/skills` or `~/.cursor/skills`. Grok users interview into a `phonezero-task` and are not asked to PR. 3. Load the plugin locally per [Test plugins locally](https://cursor.com/docs/plugins#test-plugins-locally): copy `plugins/phonezero/` to `~/.cursor/plugins/local/phonezero` (`rsync -a plugins/phonezero/ ~/.cursor/plugins/local/phonezero/`), then Reload Window. Fill the Configure card (`TELNYX_API_KEY`, `PHONEZERO_FROM_NUMBER`, `XAI_API_KEY`) on a dedicated Telnyx account. Start a **new** conversation after install or a plugin update. Runtime: `POST https://api.x.ai/v1/stt` and Files; setup: `GET`/`POST`/`PATCH https://api.x.ai/v2/phone-numbers`. Never `source ~/.phonezero/env`. Use fixture numbers in docs and fixtures only (`+15555550100`-style). Add any new example number to [`scripts/privacy-phone-allowlist.txt`](scripts/privacy-phone-allowlist.txt) first. 4. Set a non-personal Git identity before you commit (`Function1st` + a `users.noreply.github.com` address). CI rejects personal mailbox authors. ``` @@ -11,11 +11,11 @@ git config user.email function1st@users.noreply.github.com ``` 5. Run `scripts/setup-check.sh` after any TeXML, SIP, or Builder change. -6. CI must stay green: secret scan, `shellcheck` on `scripts/*.sh`, `node plugins/phonezero/scripts/xai-mcp.mjs --self-test`, `node plugins/phonezero/scripts/launch-xai-mcp.mjs --resolve-only`, `xmllint --noout` on `texml/*.xml` and `plugins/phonezero/texml/*.xml`, and `python3 scripts/privacy-check.py` (E.164 / US numbers, file-content emails, and commit-author emails). +6. CI must stay green: secret scan, `shellcheck` on `scripts/*.sh`, `node plugins/phonezero/scripts/xai-mcp.mjs --self-test`, `node plugins/phonezero/scripts/launch-xai-mcp.mjs --resolve-only`, `end_call.md` verbatim in `voice-agent.md`, `xmllint --noout` on `texml/*.xml` and `plugins/phonezero/texml/*.xml`, and `python3 scripts/privacy-check.py` (E.164 / US numbers, file-content emails, and commit-author emails). ## Regression suite -The persona checklist is the regression suite. After any change to `plugins/phonezero/prompts/voice-agent.md` or call-flow rules in `plugins/phonezero/skills/phonezero/SKILL.md`, re-run **all scenarios** in [docs/PERSONAS.md](docs/PERSONAS.md). Assert the spoken recap is correct each time. Prompt/TeXML lint and `setup-check.sh` are the cheap gates; personas are the quality gate. +The persona checklist is the regression suite. After any change to `plugins/phonezero/prompts/voice-agent.md` or call-flow rules in `plugins/phonezero/skills/phonezero-runtime/SKILL.md` / a first-party skill, re-run **all scenarios** in [docs/PERSONAS.md](docs/PERSONAS.md) (restaurant) and the hours scripts. The voice agent must not speak a recap; classify from the transcript. Prompt/TeXML lint and `setup-check.sh` are the cheap gates; personas are the quality gate. A first-party skill PR needs privacy-check + personas. Grok users are not asked to contribute. ## Releases diff --git a/README.md b/README.md index c455e15..2843c73 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -Enable voice to voice tasks in Grok Bot and Cursor. Book a restaurant table by phone: a Grok voice agent dials the restaurant, negotiates with the host, and chat reports exactly one outcome: `booked | unavailable | no_answer | needs_user | unknown | failed`. **Zero infrastructure.** [MIT](LICENSE). Sample implementation — use is on you; see [Disclaimer](DISCLAIMER.md). No product support. YMMV. +Enable voice-to-voice tasks in Grok Bot and Cursor. PhoneZero is a **runtime** plus **phone skills**. The first skill books a restaurant table by phone; chat reports exactly one outcome: `succeeded` (`booked` alias) `| unavailable | no_answer | needs_user | unknown | failed`. Custom asks: Cursor local skill folder, or Grok interviews into a task JSON. **Zero infrastructure.** [MIT](LICENSE). Sample implementation — use is on you; see [Disclaimer](DISCLAIMER.md). No product support. YMMV. How-to: [docs/SKILLS.md](docs/SKILLS.md). ## What you need @@ -21,7 +21,7 @@ Setup stops without all of these: ## If you are a Grok Bot (computer-use) — set this up -You were pointed at `https://github.com/function1st/PhoneZero`. Drive the UI. Do these steps **in order**. Same playbook: [AGENTS.md](AGENTS.md). Booking rules after the ids are in session: [plugins/phonezero/skills/phonezero/SKILL.md](plugins/phonezero/skills/phonezero/SKILL.md) §§2–12. +You were pointed at `https://github.com/function1st/PhoneZero`. Drive the UI. Do these steps **in order**. Same playbook: [AGENTS.md](AGENTS.md). After the ids are in session: [plugins/phonezero/skills/phonezero-runtime/SKILL.md](plugins/phonezero/skills/phonezero-runtime/SKILL.md) and the matching phone skill ([book-restaurant](plugins/phonezero/skills/book-restaurant/SKILL.md), or interview a custom `phonezero-task` — do not ask for a `SKILL.md` paste). ### Hard no @@ -59,7 +59,7 @@ Write answers into Edit Values / xAI env. If they turn disclaimer OFF and Builde | URL | `https://api.telnyx.com/v2/mcp` | | Header | `Authorization` = `Bearer ` + the key **in that form** | -Not stdio, not `npx`. If Grok-native **phonezero-grok** exists: install it, **Edit Values** (keys **and** name / disclose from **1**). [`.grok/config.toml`](.grok/config.toml) is the same HTTP Telnyx if this Bot already loads project Grok MCP. +Not stdio, not `npx`. If Grok-native **phonezero-grok** exists: install it, **Edit Values** (keys + From only). Name / disclose stay in chat, per task. [`.grok/config.toml`](.grok/config.toml) is the same HTTP Telnyx if this Bot already loads project Grok MCP. ### 4. Prove Telnyx @@ -67,7 +67,7 @@ Not stdio, not `npx`. If Grok-native **phonezero-grok** exists: install it, **Ed ### 5. Add the PhoneZero xAI MCP -If `put_booking` is already listed, skip. Else Customize → MCPs → **stdio** `xai`: copy `xai` from [`plugins/phonezero/mcp.json`](plugins/phonezero/mcp.json). Real secrets in env, not `${…}`, plus name / disclose from **1**. Prove: `get_call_config` + `ensure_collection` (`PhoneZero bookings`). If they said xAI is already set up: do **not** open the Builder. Ignore the wizard xAI number. +If `put_task` or `put_booking` is already listed, skip. Else Customize → MCPs → **stdio** `xai`: copy `xai` from [`plugins/phonezero/mcp.json`](plugins/phonezero/mcp.json). Real secrets in env, not `${…}`. Prove: `get_call_config` + `ensure_collection` (`PhoneZero bookings`). If they said xAI is already set up: do **not** open the Builder. Ignore the wizard xAI number. Existing Builder agents need a one-time re-paste of [`prompts/voice-agent.md`](plugins/phonezero/prompts/voice-agent.md) and [`prompts/end_call.md`](plugins/phonezero/prompts/end_call.md) before custom skills will speak correctly. Fallback only if stdio will not start: Bearer on `https://api.x.ai/v1` and `/v2` (`putBooking` in [`plugins/phonezero/scripts/xai-mcp.mjs`](plugins/phonezero/scripts/xai-mcp.mjs)). `403` + ZDR → stop. @@ -86,7 +86,7 @@ If profile **PhoneZero US-only**, app **PhoneZero**, and the DID is attached: sk ### 8. First call -Plan-first. Show Spoken as. Only dial countries on the Telnyx profile whitelist. On yes: `put_booking` (wait processed) → `calls_accounts_texml_calls` with the session ids → poll `retrieve_calls_accounts_texml_calls` → `retrieve_recordings_json_calls_accounts_texml_recordings_json` → `transcribe` → classify → `delete_booking`. Keep the Telnyx recording. Do not paste the audio URL. Owner setup-test to their own confirmed number may skip the hours guard. +Plan-first. Show Spoken as. Only dial countries on the Telnyx profile whitelist. On yes: `put_task` (or `put_booking` alias; wait processed) → `calls_accounts_texml_calls` with the session ids → poll `retrieve_calls_accounts_texml_calls` → `retrieve_recordings_json_calls_accounts_texml_recordings_json` → `transcribe` → classify → `delete_booking` (live brief only). Keep the Telnyx recording. Do not paste the audio URL. Owner setup-test to their own confirmed number may skip the hours guard. Custom task: interview into a `phonezero-task` ([docs/SKILLS.md](docs/SKILLS.md)). If they say save as a template, pick memory or `put_template` and tell them where it went. --- @@ -94,8 +94,8 @@ Plan-first. Show Spoken as. Only dial countries on the Telnyx profile whitelist. 1. Telnyx KYC + US DID; xAI team **ZDR off**; keys. [docs/SETUP.md](docs/SETUP.md). 2. Install the **Cursor** package ([`.cursor-plugin/marketplace.json`](.cursor-plugin/marketplace.json) → [`plugins/phonezero/`](plugins/phonezero/)): Marketplace, Customize → **+ Add** this repo root, or `rsync -a plugins/phonezero/ ~/.cursor/plugins/local/phonezero/` then Reload Window. -3. Plugins → Configure: `TELNYX_API_KEY`, `PHONEZERO_FROM_NUMBER`, `XAI_API_KEY`. Defaults for name / disclose. Destinations are Telnyx, not this card. Not SID / TeXML / collection ids. New chat. `list_api_endpoints` + `get_call_config`. -4. `/setup-phone-calling` then `/book-table`. +3. Plugins → Configure: `TELNYX_API_KEY`, `PHONEZERO_FROM_NUMBER`, `XAI_API_KEY` only. Spoken name and disclose are per-task. Destinations are Telnyx, not this card. Not SID / TeXML / collection ids. New chat. `list_api_endpoints` + `get_call_config`. +4. `/setup-phone-calling` then `/book-table` (or `/book-restaurant`, `/confirm-business-hours`). Local private skills: [docs/SKILLS.md](docs/SKILLS.md). Cursor Telnyx is stdio (`npx @telnyx/mcp`). Do not add hosted-HTTP Telnyx in the Cursor plugin (SSE GET 404 tombstone). `scripts/provision.sh` is developer-only on a personal machine — never on the Bot. @@ -104,7 +104,7 @@ Cursor Telnyx is stdio (`npx @telnyx/mcp`). Do not add hosted-HTTP Telnyx in the Two hosted platforms provide every runtime piece; PhoneZero itself is a skill, prompts, and config. ``` -Me: "Book Joe's Pizza, Friday 7pm, party of 2" +Me: "Book Joe's Pizza, Friday 7pm, party of 2" (or a custom skill / Grok interview) ▼ Cursor / Grok Bot │ 1. tries online booking first (OpenTable/Resy via browser); if none: @@ -115,16 +115,16 @@ Telnyx To = sip:{PHONEZERO_FROM_NUMBER}@sip.voice.x.ai;transport=tls (agent ans ▼ Inline Texml: {restaurant} ▼ -Agent loads phonezero-booking.json during the pause, hears ringback, talks to the host +Agent loads phonezero-task.json during the pause, hears ringback, talks to the host negotiates within the window · classify from the transcript ▼ Chat polls for call completion, fetches recording media_url (Telnyx MCP) → transcribes with xAI STT (POST /v1/stt, multichannel) ──▶ confirms outcome in chat · books my calendar if asked - (keep the Telnyx recording; delete the booking JSON) + (keep the Telnyx recording; delete the live brief) ``` -**Verified (Aug 2026):** MCP dial → agent answers → collection JSON brief → Pause then restaurant Dial → dual-channel recording → xAI STT. Keep the Telnyx recording. +**Verified (Aug 2026):** MCP dial → agent answers → collection JSON brief → Pause then callee Dial → dual-channel recording → xAI STT. Keep the Telnyx recording. ## Cost @@ -138,7 +138,7 @@ Chat polls for call completion, fetches recording media_url ## How it works -The agent plans the call in chat and waits for your yes. Booking facts go in `phonezero-booking.json` in the xAI file collection. One Telnyx MCP call sets `To` to `sip:{PHONEZERO_FROM_NUMBER}@sip.voice.x.ai;transport=tls`. Inline TeXML ([`plugins/phonezero/texml/bridge.xml`](plugins/phonezero/texml/bridge.xml)) pauses 3s then dials the restaurant. Dual-channel recording is call-level. Telnyx cannot transcribe Dial-verb recordings — fetch `media_url`, then xAI `POST /v1/stt`. Keep the recording; delete the booking JSON after classify. +The agent plans the call in chat and waits for your yes. Facts go in `phonezero-task.json` in the xAI file collection. One Telnyx MCP call sets `To` to `sip:{PHONEZERO_FROM_NUMBER}@sip.voice.x.ai;transport=tls`. Inline TeXML ([`plugins/phonezero/texml/bridge.xml`](plugins/phonezero/texml/bridge.xml)) pauses 3s then dials the callee. Dual-channel recording is call-level. Telnyx cannot transcribe Dial-verb recordings — fetch `media_url`, then xAI `POST /v1/stt`. Keep the recording; delete the live brief after classify (keep templates). Keys live in Configure / MCP headers only — not the agent shell. @@ -147,10 +147,10 @@ Keys live in Configure / MCP headers only — not the agent shell. This sample ships with: - **Destinations:** Telnyx outbound voice profile **PhoneZero US-only**, `whitelisted_destinations` default `US` on create (Mission Control → Voice → Outbound voice profiles) -- **AI disclosure:** `PHONEZERO_DISCLOSE_AI` default **on** +- **AI disclosure:** default **on** in each task (`disclose_ai`) and in the one-time Builder paste - **Spoken name:** `PhoneZero` - **Recording:** dual-channel Telnyx recording on every call; the opener says the call is on a recorded line -- **Hours / attempts:** skill defaults (see [`plugins/phonezero/skills/phonezero/SKILL.md`](plugins/phonezero/skills/phonezero/SKILL.md)) +- **Hours / attempts:** runtime + skill defaults (see [`plugins/phonezero/skills/phonezero-runtime/SKILL.md`](plugins/phonezero/skills/phonezero-runtime/SKILL.md)) These are product defaults, not a compliance program. diff --git a/docs/PERSONAS.md b/docs/PERSONAS.md index 4c6aa9e..68b7ef6 100644 --- a/docs/PERSONAS.md +++ b/docs/PERSONAS.md @@ -1,20 +1,42 @@ -# PhoneZero persona eval (Phase 2) - -Regression suite for `prompts/voice-agent.md`. A human plays the restaurant host (or voicemail/IVR). After any prompt change, run every scenario against the live Builder agent. Transcribe the Telnyx dual-channel recording with xAI STT (`POST /v1/stt`, `multichannel=true`). Assert the outcome state and that the STT transcript contains the expected recap (and host-channel confirmation when the state is `booked`). - -Do not use real numbers. Fixtures only. - -## Shared briefing (every scenario unless noted) - -The Builder prompt is static. Each call's facts are spoken by Telnyx TTS (``) before the restaurant is dialed. Use this spoken brief (do not edit the Builder prompt): - -``` -Task briefing for PhoneZero. This is an automated briefing, not a restaurant. Disclosure: include the automated-assistant clause in the opener. Restaurant: Joe's Pizza. Party of 2. Date: Friday, August 28. Preferred time: 7:00 PM. Window: 6:30 PM to 8:00 PM. Ranked alternates: 6:45 PM, 7:15 PM, 7:30 PM. Booking name: Alex Example. Callback: +15555550199. Special requests: none. +# PhoneZero persona eval + +Regression suite for `prompts/voice-agent.md` plus first-party skills. A human plays the callee (or voicemail/IVR). After any prompt change, run restaurant scenarios 1–11 and the hours scripts at the bottom (or [`plugins/phonezero/skills/confirm-business-hours/personas.md`](../plugins/phonezero/skills/confirm-business-hours/personas.md)). Transcribe the Telnyx dual-channel recording with xAI STT (`POST /v1/stt`, `multichannel=true`). Assert the outcome state. The voice agent must **not** speak a `Confirming:` recap — classify from the transcript. + +Do not use real numbers. Fixtures only. Re-paste the interpreter prompt on the test Builder agent first. + +## Shared briefing (restaurant scenarios unless noted) + +The Builder prompt is static. Upload a `phonezero-task` (skill `book-restaurant`) before dial. Do not use TeXML ``. Do not edit the Builder prompt per call. + +```json +{ + "kind": "phonezero-task", + "skill": "book-restaurant", + "spoken_name": "PhoneZero", + "disclose_ai": true, + "callee": { "name": "Joe's Pizza", "phone": "+15555550100" }, + "callback": "+15555550199", + "goal": "Book the table within the window.", + "opener": "I'd like to make a reservation for a party of 2 on Friday, August 28 at 7:00 PM. Do you have availability?", + "constraints": ["Accept only the preferred time, ranked alternates, or a host offer inside 6:30 PM to 8:00 PM.", "Never invent a time."], + "success": "Live host confirms read-back of party, date, agreed time, and booking name.", + "voicemail": "This is PhoneZero calling for Alex Example about a reservation for 2 on Friday, August 28 at 7:00 PM. Please call +15555550199. Thank you.", + "playbook": "Ask preferred first; then 6:45 PM, 7:15 PM, 7:30 PM; then in-window host offers.", + "facts": { + "party": 2, + "date": "Friday, August 28", + "preferred_time": "7:00 PM", + "window": "6:30 PM to 8:00 PM", + "alternates": ["6:45 PM", "7:15 PM", "7:30 PM"], + "booking_name": "Alex Example", + "special_requests": "none" + } +} ``` Callee fixture: `+15555550100`. Evaluator phone is this DID or a SIP stand-in labeled the same. -`booked` is valid only when all six gates in SKILL.md §9 hold — recap says `booked`, a live host turn (not briefing TTS, not voicemail) confirms the read-back, the time is in-window, and the recording contains a complete briefing (restaurant, party, date, preferred time, window, booking name). Agent recap alone fails the scenario. +`booked` is an alias of `succeeded`. It is valid only when runtime §8 and `book-restaurant` gates hold — a live host turn (not voicemail) confirms the read-back, the time is in-window. Agent recap alone fails the scenario. A spoken `Confirming:` line is a **fail** (the interpreter must not narrate). --- @@ -192,13 +214,15 @@ Must not contain `Confirming: booked`. ## Scoring -A scenario passes only if the outcome state matches and the xAI STT transcript contains the recap line exactly (punctuation and field order as written) when a recap is required. Apply this channel model: +A scenario passes if the outcome state matches. Ignore legacy `Confirming:` lines in the scripts below as **agent speech that must not occur**. For `booked` / `succeeded`, the transcript must contain a live host confirmation of the read-back. Apply this channel model: -- Identify the **agent** channel by the opener ONLY ("calling on a recorded line" / "I'd like to make a reservation"). NEVER identify it by a "restatement of the briefing" — that matches the Telnyx TTS. -- The other channel may contain BOTH the briefing TTS and later host audio. It is not "not the host." -- Briefing TTS = the turn that starts with the briefing preamble (`Task briefing for` / `This is an automated briefing, not a restaurant`). That turn is never a host confirmation. -- Host confirmation = a later turn on the non-agent channel, after the opener, that accepts the time, has the party down, or answers yes to the read-back. +- Identify the **agent** channel by the opener ONLY ("calling on a recorded line" / restaurant “I'd like to make a reservation” / hours opener). There is no Telnyx TTS briefing. +- Host confirmation = a later turn on the non-agent channel, after the opener, that accepts the read-back (or states hours, for hours skills). - A mailbox greeting / beep / "leave a message" is never a host confirmation. -- If `channels` is missing or the opener is not unique: outcome `unknown`, never `booked`. +- If `channels` is missing or the opener is not unique: outcome `unknown`, never `booked` / `succeeded`. + +Builder console audio is the tie-breaker when channel labels are messy; the skill still must not report `succeeded` without a live-person confirmation in the xAI STT transcript. + +## Hours skill (custom) -For `booked`, also highlight the host-channel confirmation turn in the eval notes (gates 5–6: confirming turn is a live human, not briefing TTS or voicemail; recording contains a complete briefing). Builder console audio is the tie-breaker when channel labels are messy; the skill still must not report `booked` without a host confirmation in the xAI STT transcript. +See [`plugins/phonezero/skills/confirm-business-hours/personas.md`](../plugins/phonezero/skills/confirm-business-hours/personas.md). Run at least those three after a Builder re-paste. The agent must not say “I'd like to make a reservation”. diff --git a/docs/SETUP.md b/docs/SETUP.md index fb55f4f..9d66ea2 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -1,6 +1,6 @@ # PhoneZero setup -This is the human-readable version of what `/setup-phone-calling` automates. Approve each credentialed step. UIs move; the invariants are a US DID, that same DID registered with xAI as `byo_trunk`, a TeXML application whose `voice_url` is the inbound reject page (outbound calls carry **inline Texml**, not a hosted bin), an xAI team with **ZDR off**, a **PhoneZero bookings** collection, a Builder voice agent (static prompt + welcome line + collection attached + `end_call` on) on that Telnyx DID, spend caps on both vendors, and three values on the Configure card (`TELNYX_API_KEY`, `PHONEZERO_FROM_NUMBER`, `XAI_API_KEY`). Runtime: `POST https://api.x.ai/v1/stt` and Files. Setup: `GET`/`POST`/`PATCH https://api.x.ai/v2/phone-numbers`. Telnyx cannot transcribe Dial-verb recordings; the outcome path is recording `media_url` via Telnyx MCP → xAI `POST /v1/stt` (multichannel). Keep the Telnyx recording; delete the booking JSON after classify. +This is the human-readable version of what `/setup-phone-calling` automates. Approve each credentialed step. UIs move; the invariants are a US DID, that same DID registered with xAI as `byo_trunk`, a TeXML application whose `voice_url` is the inbound reject page (outbound calls carry **inline Texml**, not a hosted bin), an xAI team with **ZDR off**, a **PhoneZero bookings** collection, a Builder voice agent (static prompt + welcome line + collection attached + `end_call` on) on that Telnyx DID, spend caps on both vendors, and three values on the Configure card (`TELNYX_API_KEY`, `PHONEZERO_FROM_NUMBER`, `XAI_API_KEY`). Runtime: `POST https://api.x.ai/v1/stt` and Files. Setup: `GET`/`POST`/`PATCH https://api.x.ai/v2/phone-numbers`. Telnyx cannot transcribe Dial-verb recordings; the outcome path is recording `media_url` via Telnyx MCP → xAI `POST /v1/stt` (multichannel). Keep the Telnyx recording; delete the live `phonezero-task.json` after classify (do not delete templates). Fixture numbers in this guide are reserved (`+15555550100`-style). Never commit a real number. You do **not** buy a second number: `PHONEZERO_FROM_NUMBER` is registered with xAI and is the SIP bridge target. @@ -14,10 +14,10 @@ Account SID, TeXML app id, and collection id are created during provision — th ## Manual: xAI team (ZDR must be off) -PhoneZero briefs every call with `phonezero-booking.json` in an xAI **collection**. [Zero Data Retention](https://docs.x.ai/developers/faq/security) is team-wide and **blocks Files and Collections** (`File uploads are not available under Zero Data Retention`). There is no sub-team and no per-key override. +PhoneZero briefs every call with `phonezero-task.json` in an xAI **collection**. [Zero Data Retention](https://docs.x.ai/developers/faq/security) is team-wide and **blocks Files and Collections** (`File uploads are not available under Zero Data Retention`). There is no sub-team and no per-key override. 4. Create an [xAI](https://console.x.ai) account. -5. Use a team with **ZDR off** (the default 30-day retention). Check the team picker — a **ZDR** badge means it is on. [Team Settings](https://console.x.ai/team/default/settings/team) → Zero Data Retention → **Disable**, or **+ Create Team** and leave ZDR off. Create the API key and the Voice Agent on **that** team. A key from a ZDR team cannot upload the booking file. +5. Use a team with **ZDR off** (the default 30-day retention). Check the team picker — a **ZDR** badge means it is on. [Team Settings](https://console.x.ai/team/default/settings/team) → Zero Data Retention → **Disable**, or **+ Create Team** and leave ZDR off. Create the API key and the Voice Agent on **that** team. A key from a ZDR team cannot upload the task file. 6. Create an **API key** on that team. This is `XAI_API_KEY` (Files, collections, STT, phone-numbers API). 7. Confirm you can open **Voice Agent Builder** at [console.x.ai](https://console.x.ai). Agent create has no public API — this console step is required once. @@ -38,7 +38,7 @@ The Telnyx MCP cannot run until `TELNYX_API_KEY` is saved as a plugin variable. If none of those are available on Cursor IDE: `"telnyx": {"command":"npx","args":["-y","@telnyx/mcp"],"env":{"TELNYX_API_KEY":"${env:TELNYX_API_KEY}"}}`. A masked secret card alone does **not** authenticate an MCP. Open the Cursor dashboard → **Plugins → Configure** for PhoneZero. -9. Enter **only** these three required fields. Name / disclose already default to PhoneZero / true. Destination countries are **not** on this card — they live on the Telnyx outbound voice profile. **When you re-save this config card, re-enter EVERY required field, not just the changed one.** Saving replaces all of PhoneZero's setup values; a field left blank is cleared. +9. Enter **only** these three required fields. Spoken name and disclose are per-task (chat), not this card. Destination countries are **not** on this card — they live on the Telnyx outbound voice profile. **When you re-save this config card, re-enter EVERY required field, not just the changed one.** Saving replaces all of PhoneZero's setup values; a field left blank is cleared. - `TELNYX_API_KEY` — **as a plugin variable**, not in chat. The plugin injects it as env into the Telnyx stdio MCP. You need a Telnyx DID first. - `PHONEZERO_FROM_NUMBER` — that DID, E.164. - `XAI_API_KEY` — from the ZDR-off team (step 5). Goes to the PhoneZero xAI MCP, not the agent shell. Do not paste API keys in chat. @@ -56,12 +56,11 @@ To add a country (e.g. Japan): change that Telnyx whitelist in Mission Control, | `TELNYX_API_KEY` | plugin variable (secret) | A | Env for Telnyx stdio MCP (`npx @telnyx/mcp`). Never in chat. | | `PHONEZERO_FROM_NUMBER` | plugin variable | A | Telnyx US DID (E.164): outbound caller ID and SIP bridge target (`sip:{PHONEZERO_FROM_NUMBER}@sip.voice.x.ai;transport=tls`). | | `XAI_API_KEY` | plugin variable (secret) | A | From a team with **ZDR off**. Injected into the PhoneZero xAI MCP only (Files, collections, STT, phone-numbers). Not in the agent shell. | -| `PHONEZERO_AGENT_NAME` | plugin variable (default `PhoneZero`) | A | Default `spoken_name` in `phonezero-booking.json`. Not baked into the Builder prompt. | -| `PHONEZERO_DISCLOSE_AI` | plugin variable (boolean, default true) | A | Substituted into the Builder prompt **once** at agent creation, and set as `disclose_ai` in each booking JSON. | +| Spoken name / disclose | per-task (`phonezero-task.json`) | chat | `spoken_name` and `disclose_ai`. Default PhoneZero / true. Not on the Configure card. `{disclosure_clause}` is pasted once into the Builder prompt. | | Destinations | Telnyx voice profile **PhoneZero US-only** | Telnyx | `whitelisted_destinations`. Mission Control → Voice → Outbound voice profiles. Not a PhoneZero plugin variable. | | `TELNYX_ACCOUNT_SID` | resolved in session | B | TeXML REST account SID. MCP: `list_billing_groups` → `data[].organization_id` (no `whoami` tool). Developer curl: `GET /v2/whoami` → `data.organization_id`. Not on the Configure card. | | `PHONEZERO_TEXML_APP_ID` | resolved in session | B | TeXML application SID. Not on the Configure card. | -| `PHONEZERO_XAI_COLLECTION_ID` | resolved in session | B | Collection for `phonezero-booking.json`. Find-or-create name `PhoneZero bookings`. | +| `PHONEZERO_XAI_COLLECTION_ID` | resolved in session | B | Collection for `phonezero-task.json` and optional `phonezero-template-*.json`. Find-or-create name `PhoneZero bookings`. | ## B — Provision @@ -70,7 +69,7 @@ Account, KYC, and the DID stay manual. Everything after that is API-automatable. 12. **End-user path.** Run `/setup-phone-calling` (or ask *"Set up phone calling."*). The first message is a vendor checklist — stop if anything is missing (including ZDR off). Then the Bot uses the Telnyx hosted MCP (`list_api_endpoints` → `get_api_endpoint_schema` → `invoke_api_endpoint`) and `XAI_API_KEY` for xAI Files + phone-numbers. Approve each credentialed step. It will: - Resolve `TELNYX_ACCOUNT_SID` from Telnyx MCP `list_billing_groups` → `data[].organization_id` (developer curl: `GET /v2/whoami`). - Find-or-create outbound voice profile **PhoneZero US-only**: `traffic_type=conversational`, `service_plan=global`, `usage_payment_method=rate-deck` (the only accepted combo today; error 10015 otherwise), `whitelisted_destinations` default `["US"]` on create, `daily_spend_limit="5.00"`, `daily_spend_limit_enabled=true`. If the profile exists, keep its current whitelist. PATCH only if the user asks to add or remove countries. - - Find-or-create TeXML application **PhoneZero** with `voice_url` = [`texml/inbound.xml`](../texml/inbound.xml) (default raw URL: `https://raw.githubusercontent.com/function1st/PhoneZero/main/texml/inbound.xml`) and `voice_method=get`. **Verify that URL returns HTTP 200 before writing it.** That URL is fetched **only for inbound** calls to the DID. Outbound restaurant calls carry inline `Texml` (template [`plugins/phonezero/texml/bridge.xml`](../plugins/phonezero/texml/bridge.xml)). + - Find-or-create TeXML application **PhoneZero** with `voice_url` = [`texml/inbound.xml`](../texml/inbound.xml) (default raw URL: `https://raw.githubusercontent.com/function1st/PhoneZero/main/texml/inbound.xml`) and `voice_method=get`. **Verify that URL returns HTTP 200 before writing it.** That URL is fetched **only for inbound** calls to the DID. Outbound calls carry inline `Texml` (template [`plugins/phonezero/texml/bridge.xml`](../plugins/phonezero/texml/bridge.xml)). - Attach the DID: `PATCH /v2/phone_numbers/{phone_number_id}` `{"connection_id":""}`. - Register the DID with xAI if it is not already registered: `POST https://api.x.ai/v2/phone-numbers` `{"name":"PhoneZero","phoneNumber":"+1…","origin":"byo_trunk"}`. - Find-or-create the file collection named **PhoneZero bookings** (`GET`/`POST https://api.x.ai/v1/collections`). A 403 mentioning Zero Data Retention means stop — the API key's team still has ZDR on. @@ -83,16 +82,16 @@ Account, KYC, and the DID stay manual. Everything after that is API-automatable. 14. **Create the Voice Agent (once, in Builder).** There is no public create API (`/v1/agents` is not enabled). Preferred: the Bot opens [https://console.x.ai](https://console.x.ai) with your approved session. Fallback: you do this and give the Bot the `agentId`. On the **same ZDR-off team** as the API key: - 1. Voice Agent Builder → create one agent. Name is yours (spoken name at call time comes from `phonezero-booking.json`, not this label). - 2. Paste [`plugins/phonezero/prompts/voice-agent.md`](../plugins/phonezero/prompts/voice-agent.md) **fully substituted once**: `{disclosure_clause}` = `, an automated assistant,` if `PHONEZERO_DISCLOSE_AI` is on, else empty. Do **not** substitute a spoken name. Save. Do not add a per-call TASK BRIEF — facts are the collection file. - 3. **Welcome message: on.** Text exactly `PhoneZero is ready!` **Caller can interrupt: on.** Empty welcome delays `collections_search` until the restaurant says hello. This line is spoken on the agent's SIP ear during the TeXML pause; the restaurant is not bridged yet and must not hear it. It is the session-start cue (search now), not a restaurant greeting. Do not put booking facts in it. - 4. **Knowledge / file search:** attach collection **PhoneZero bookings**. Without this, the agent invents the reservation. - 5. **`end_call` tool: on.** Name exactly `end_call`. Description exactly: `ONLY use this tool after successfully booking the reservation or confirming no available time slot can be accommodated. Be sure to verbally exchange goodbyes so you don't abruptly end the call.` The prompt already calls this after a spoken goodbye. A silent hang-up with no goodbye has dropped live calls — do not leave the tool off. + 1. Voice Agent Builder → create one agent. Name is yours (spoken name at call time comes from `phonezero-task.json`, not this label). + 2. Paste the body of [`plugins/phonezero/prompts/voice-agent.md`](../plugins/phonezero/prompts/voice-agent.md) (system prompt only). Substitute `{disclosure_clause}` once: `, an automated assistant,` if they want disclose on (default), else empty. Do **not** substitute a spoken name. Save. Do not add a per-call TASK BRIEF — facts are the collection file. Never edit the Builder prompt per call. **Re-paste** this interpreter if the agent still searches `phonezero-booking.json` or still says it is only booking tables. If this DID is also used in production, say so before pasting. + 3. **Welcome message: on.** Text exactly `PhoneZero is ready!` **Caller can interrupt: on.** Empty welcome delays `collections_search` until the callee says hello. This line is spoken on the agent's SIP ear during the TeXML pause; the callee is not bridged yet and must not hear it. It is the session-start cue (search `phonezero-task.json` now), not a greeting. Do not put task facts in it. + 4. **Knowledge / file search:** attach collection **PhoneZero bookings**. Without this, the agent invents the ask. + 5. **`end_call` tool: on.** Name exactly `end_call`. Description = the full contents of [`plugins/phonezero/prompts/end_call.md`](../plugins/phonezero/prompts/end_call.md) (no extra words). The system prompt already calls this after a spoken goodbye. A silent hang-up with no goodbye has dropped live calls — do not leave the tool off. 6. **Max call duration:** at least 10 minutes if the console exposes it (Telnyx already caps the bridge at 600s). - 7. **Guardrails** if shown: stay inside the booked window, verbatim read-back before accepting, no invented confirmation. + 7. **Guardrails** if shown: stay inside `constraints`, verbatim read-back of `success`, no invented confirmation. 8. The wizard attaches the agent to a **new free xAI number**. **Ignore that number.** PhoneZero always SIP-bridges to your Telnyx DID. - `PHONEZERO_DISCLOSE_AI` is baked into the prompt at this paste. Toggling the plugin variable later does not change the agent — edit the prompt in Builder. + `{disclosure_clause}` is baked into the prompt at this paste (`, an automated assistant,` if they want disclose on, else empty). Changing your mind later means re-paste — there is no Configure toggle. 15. **Attach the agent to the registered Telnyx DID** (never the wizard's free number). `GET https://api.x.ai/v2/phone-numbers` → find YOUR DID's `phoneNumberId` (`origin` `byo_trunk`; E.164 matches `PHONEZERO_FROM_NUMBER`) → `PATCH https://api.x.ai/v2/phone-numbers/{phoneNumberId}` body `{"phoneNumber":{"agentId":"agent_…"},"fieldMask":{"paths":["agent_id"]}}` — protobuf FieldMask style; a flat `{"agentId":…}` is rejected. Copy `agentId` from the wizard number row in the same GET, then PATCH it onto the DID. `provision.sh` does this when `PHONEZERO_XAI_AGENT_ID` is set. Or attach in Builder **to the Telnyx DID**. 16. Set a **Telnyx spend cap** on the outbound voice profile (enable daily spend limit; `provision.sh` sets `$5.00`). Caps reset 00:00 UTC. @@ -113,8 +112,8 @@ Account, KYC, and the DID stay manual. Everything after that is API-automatable. - xAI MCP `list_phone_numbers` shows the DID as `origin=byo_trunk` with an `agentId` (missing agent is a warning — finish step 14–15). 20. **Optional developer path.** [`scripts/setup-check.sh`](../scripts/setup-check.sh) is developer-only. Run it on a **personal machine that is allowed to hold keys**, never on the Bot computer. It verifies Telnyx auth, that the number is on the account, that the TeXML application exists, and that the DID is attached to that app. If `XAI_API_KEY` is set, it also checks xAI BYO registration. It does **not** verify that the agent answers. 21. **Test call.** Use a **new** conversation after install or a plugin update (same rule as step 19). - 1. Ask the Bot to call **your** phone (`/book-table` or a setup-test). Booking facts go in `phonezero-booking.json` on the collection — do **not** edit the Builder console. The restaurant number in `` is your E.164. Owner setup-test calls skip the skill's hours guard (see [`plugins/phonezero/skills/phonezero/SKILL.md`](../plugins/phonezero/skills/phonezero/SKILL.md)). + 1. Ask the Bot to call **your** phone (`/book-table` or a setup-test). Facts go in `phonezero-task.json` on the collection — do **not** edit the Builder console. The `` number is your E.164. Owner setup-test calls skip the hours guard (see [`plugins/phonezero/skills/phonezero-runtime/SKILL.md`](../plugins/phonezero/skills/phonezero-runtime/SKILL.md)). 2. Wait for an explicit **yes** in chat before it places the call. Dial is Telnyx MCP + [`plugins/phonezero/texml/bridge.xml`](../plugins/phonezero/texml/bridge.xml) (Pause 3s then Dial). Not `place-call.sh`. - 3. After the call, the Bot polls for completion, fetches the recording `media_url` through Telnyx MCP (download promptly — the presigned URL expires in ~10 minutes), transcribes with the PhoneZero xAI MCP `transcribe`. Download audio to `/tmp` only — never copy recordings into the repo. Keep the Telnyx recording. Delete the booking JSON. Only then treat setup as done. + 3. After the call, the Bot polls for completion, fetches the recording `media_url` through Telnyx MCP (download promptly — the presigned URL expires in ~10 minutes), transcribes with the PhoneZero xAI MCP `transcribe`. Download audio to `/tmp` only — never copy recordings into the repo. Keep the Telnyx recording. Delete the live brief (`delete_booking`), not templates. Only then treat setup as done. Scripts (`provision.sh`, `setup-check.sh`, `place-call.sh`, `get-outcome.sh`) stay under the developer-key rule in step 20 — never as the end-user verify path. Never `source ~/.phonezero/env` in chat. diff --git a/docs/SKILLS.md b/docs/SKILLS.md new file mode 100644 index 0000000..d894d76 --- /dev/null +++ b/docs/SKILLS.md @@ -0,0 +1,54 @@ +# PhoneZero skills + +PhoneZero is a **runtime** plus **phone skills**. The voice agent is one static Builder interpreter. Each call is a `phonezero-task.json` brief. + +Contributing a skill to this repo is **optional**. Grok users are not asked to open a PR. + +## Cursor — local folder (no PR) + +Same layout as first-party skills: + +```text +my-skill/ + SKILL.md + brief.schema.json + references/voice-playbook.md +``` + +Discovery (none of these require a contribution): + +| Where | Who | +|---|---| +| `plugins/phonezero/skills/` | Shipped | +| `~/.cursor/skills//` or `~/.agents/skills//` | Cursor user | +| `~/.phonezero/skills//` | Cursor user (MCP `list_phone_skills` / `get_phone_skill`) | +| Project `.cursor/skills/` or `.phonezero/skills/` | Private repo | + +Copy [`plugins/phonezero/skills/_template/`](../plugins/phonezero/skills/_template/) or run `/new-phone-skill`. Folder name must match `name` in the frontmatter. New conversation after you add it. Dial still goes through [`phonezero-runtime`](../plugins/phonezero/skills/phonezero-runtime/SKILL.md). + +Do not put API keys or real phone numbers in a skill (fixtures `+15555550100` only). + +## Grok Bot — interview into JSON + +Do **not** paste a `SKILL.md`, clone a gist, or write `~/.cursor/skills`. + +If the ask matches a shipped skill (`book-restaurant`, `confirm-business-hours`), follow that skill. Otherwise the runtime **Ad-hoc interview** builds a `phonezero-task` in chat (callee, callback, goal, opener, constraints, success, voicemail, facts). Show it in the call plan. Dial only on yes. + +**Save as a template** only if they ask. Save the shape, not this call’s number/date unless they freeze those. The Bot picks a store and says where it went: + +1. This chat, if they did not ask to save +2. Grok memory, if the host has it +3. `put_template` → `phonezero-template-{slug}.json` in **PhoneZero bookings** (never overwrite the live brief; never `delete_booking` a template) +4. Show the JSON in chat if 2 and 3 are unavailable + +## Shipped skills + +- [`phonezero-runtime`](../plugins/phonezero/skills/phonezero-runtime/SKILL.md) — setup, dial, STT, outcomes, Grok interview +- [`book-restaurant`](../plugins/phonezero/skills/book-restaurant/SKILL.md) — first skill; `/book-table` is an alias +- [`confirm-business-hours`](../plugins/phonezero/skills/confirm-business-hours/SKILL.md) — example second skill + +## Builder + +Paste [`plugins/phonezero/prompts/voice-agent.md`](../plugins/phonezero/prompts/voice-agent.md) as the system prompt. Paste [`plugins/phonezero/prompts/end_call.md`](../plugins/phonezero/prompts/end_call.md) as the `end_call` tool description. + +Existing reservation-only agents must be re-pasted before hours or ad-hoc briefs will speak correctly. Say so if the test DID is also used in production. diff --git a/plugins/phonezero-grok/.grok-plugin/plugin.json b/plugins/phonezero-grok/.grok-plugin/plugin.json index 1151b36..b3164c1 100644 --- a/plugins/phonezero-grok/.grok-plugin/plugin.json +++ b/plugins/phonezero-grok/.grok-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "phonezero-grok", "displayName": "PhoneZero (Grok)", - "version": "0.3.20", - "description": "Grok Bot: Telnyx hosted HTTP + PhoneZero xAI stdio. Edit Values for keys. Do not click Authenticate on Telnyx.", + "version": "0.4.1", + "description": "Grok Bot: phone skills (restaurant first). Telnyx hosted HTTP + PhoneZero xAI stdio. Edit Values for keys. Do not click Authenticate on Telnyx.", "author": { "name": "Function1st" }, @@ -33,18 +33,6 @@ "type": "string", "title": "xAI API key", "description": "Secret. ZDR-off team. Injected into the PhoneZero xAI MCP. Never paste in chat." - }, - "PHONEZERO_AGENT_NAME": { - "type": "string", - "title": "Spoken name the restaurant hears", - "description": "Opener: “Hello, this is {name}…”. Default PhoneZero. Change this if you want a different call identity.", - "default": "PhoneZero" - }, - "PHONEZERO_DISCLOSE_AI": { - "type": "boolean", - "title": "AI disclaimer in the opener", - "description": "Default on. ON adds “, an automated assistant,”. You may turn this OFF. If the Builder agent already exists, re-paste the prompt once after changing this.", - "default": true } }, "required": ["TELNYX_API_KEY", "PHONEZERO_FROM_NUMBER", "XAI_API_KEY"] diff --git a/plugins/phonezero-grok/.mcp.json b/plugins/phonezero-grok/.mcp.json index f67beab..08097e5 100644 --- a/plugins/phonezero-grok/.mcp.json +++ b/plugins/phonezero-grok/.mcp.json @@ -17,9 +17,7 @@ ], "env": { "PHONEZERO_CFG_XAI_API_KEY": "${XAI_API_KEY}", - "PHONEZERO_CFG_FROM_NUMBER": "${PHONEZERO_FROM_NUMBER}", - "PHONEZERO_CFG_AGENT_NAME": "${PHONEZERO_AGENT_NAME}", - "PHONEZERO_CFG_DISCLOSE_AI": "${PHONEZERO_DISCLOSE_AI}" + "PHONEZERO_CFG_FROM_NUMBER": "${PHONEZERO_FROM_NUMBER}" } } } diff --git a/plugins/phonezero-grok/commands/setup-phone-calling.md b/plugins/phonezero-grok/commands/setup-phone-calling.md index f8a9ee1..4e24e1e 100644 --- a/plugins/phonezero-grok/commands/setup-phone-calling.md +++ b/plugins/phonezero-grok/commands/setup-phone-calling.md @@ -9,7 +9,7 @@ You are a computer-use agent. Run **Grok Bot — set this up** in repo-root `AGE After the vendor gate, **ask in chat and wait** before wiring. Do not silently keep defaults: -- Spoken name (default PhoneZero) — what the restaurant hears +- Spoken name (default PhoneZero) — what the callee hears - AI disclaimer ON or OFF — they may turn it OFF **Do not ask for destination countries.** Read them from Telnyx after the HTTP MCP is proven: `list_outbound_voice_profiles` → profile **PhoneZero US-only** → `whitelisted_destinations`. Show the list. That setting is Telnyx Mission Control → Voice → Outbound voice profiles. PATCH only if they ask to add or remove countries. Do not give legal advice. diff --git a/plugins/phonezero-grok/skills/phonezero-telnyx-canary/SKILL.md b/plugins/phonezero-grok/skills/phonezero-telnyx-canary/SKILL.md index b309425..ed67437 100644 --- a/plugins/phonezero-grok/skills/phonezero-telnyx-canary/SKILL.md +++ b/plugins/phonezero-grok/skills/phonezero-telnyx-canary/SKILL.md @@ -5,17 +5,17 @@ description: Grok Bot PhoneZero setup when pointed at https://github.com/functio # Grok Bot — set this up -Computer-use. Drive the UI. Do these steps **in order**. Same playbook: repo-root [README.md](../../../../README.md) and [AGENTS.md](../../../../AGENTS.md). Booking after ids are in session: [plugins/phonezero/skills/phonezero/SKILL.md](../../../phonezero/skills/phonezero/SKILL.md) §§2–12. +Computer-use. Drive the UI. Do these steps **in order**. Same playbook: repo-root [README.md](../../../../README.md) and [AGENTS.md](../../../../AGENTS.md). After ids are in session: [plugins/phonezero/skills/phonezero-runtime/SKILL.md](../../../phonezero/skills/phonezero-runtime/SKILL.md) and the matching phone skill (or interview a custom `phonezero-task` — do not ask for a `SKILL.md` paste). 1. **Not** Cursor + Add. **Not** Authenticate. **Not** keys in chat. **Not** `source ~/.phonezero/env`. **Not** `scripts/provision.sh`. 2. Vendor gate, then **ask in chat and wait** — spoken name and AI disclaimer ON/OFF only. Do **not** ask for destination countries. -3. HTTP MCP: `https://api.telnyx.com/v2/mcp` + `Authorization: Bearer` in the form. Edit Values include name / disclose. +3. HTTP MCP: `https://api.telnyx.com/v2/mcp` + `Authorization: Bearer` in the form. Edit Values: keys + From only. Name / disclose stay in chat. 4. `list_api_endpoints` — real list or stop. “6 tools” proves nothing. There is **no** MCP tool named `whoami`. -5. If `put_booking` is missing: add stdio `xai` from [`plugins/phonezero/mcp.json`](../../../phonezero/mcp.json) with **real** env (not `${…}`), including name / disclose. Then `get_call_config` + `ensure_collection`. If they said xAI is already set up, do not open the Builder. Ignore the wizard number. If they turned disclaimer OFF and Builder already exists, re-paste the prompt once with `{disclosure_clause}` empty. +5. If `put_task` / `put_booking` is missing: add stdio `xai` from [`plugins/phonezero/mcp.json`](../../../phonezero/mcp.json) with **real** env (not `${…}`). Then `get_call_config` + `ensure_collection`. If they said xAI is already set up, do not open the Builder. Ignore the wizard number. If they turned disclaimer OFF and Builder already exists, or the agent still searches `phonezero-booking.json`, re-paste `prompts/voice-agent.md` and `prompts/end_call.md` once. 6. Session ids **now**, before any plan: - `TELNYX_ACCOUNT_SID` = `invoke_api_endpoint` `list_billing_groups` args `{ "jq_filter": "[.data[].organization_id] | unique" }` - `PHONEZERO_TEXML_APP_ID` = `invoke_api_endpoint` `list_texml_applications` args `{ "filter": { "friendly_name": "PhoneZero" }, "jq_filter": ".data[] | {id, friendly_name}" }` - From = `get_call_config` - Destinations = `list_outbound_voice_profiles` → **PhoneZero US-only** → `whitelisted_destinations`. Show them. This is Telnyx Mission Control → Voice → Outbound voice profiles, not a PhoneZero field. PATCH only if they ask to change countries. 7. Provision only if profile / TeXML app / DID attach is missing. Do not overwrite an existing whitelist unless they asked. -8. On yes: `put_booking` → `calls_accounts_texml_calls` (session ids, do not look up SID again) → poll → recordings → `transcribe` → classify → `delete_booking`. +8. On yes: `put_task` (or `put_booking` alias) → `calls_accounts_texml_calls` (session ids, do not look up SID again) → poll → recordings → `transcribe` → classify → `delete_booking` (live brief only). diff --git a/plugins/phonezero/.cursor-plugin/plugin.json b/plugins/phonezero/.cursor-plugin/plugin.json index 45bf0b7..4292ce3 100644 --- a/plugins/phonezero/.cursor-plugin/plugin.json +++ b/plugins/phonezero/.cursor-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "phonezero", "displayName": "PhoneZero", - "version": "0.3.20", - "description": "Book a restaurant by phone. Need: Telnyx KYC + one US number, xAI team with ZDR off, API keys, Voice Agent Builder. Then /setup-phone-calling.", + "version": "0.4.1", + "description": "Phone skills for voice tasks (restaurant booking first). Need: Telnyx KYC + one US number, xAI team with ZDR off, API keys, Voice Agent Builder. Then /setup-phone-calling.", "author": { "name": "Function1st" }, @@ -18,9 +18,10 @@ "grok-bot", "reservations", "calling", - "mcp" + "mcp", + "skills" ], - "skills": "./skills/phonezero", + "skills": "./skills/", "mcpServers": "./mcp.json", "commands": "./commands", "variables": { @@ -40,18 +41,6 @@ "type": "string", "title": "xAI API key", "description": "Secret. From an xAI team with ZDR OFF. Injected into the PhoneZero xAI MCP only — not the agent shell. After save, start a new conversation." - }, - "PHONEZERO_AGENT_NAME": { - "type": "string", - "title": "Voice agent name", - "description": "Default spoken_name in phonezero-booking.json. Not baked into the Builder prompt.", - "default": "PhoneZero" - }, - "PHONEZERO_DISCLOSE_AI": { - "type": "boolean", - "title": "Disclose automated assistant", - "description": "When true, the Builder prompt (substituted once at agent creation) includes the automated-assistant clause. Default on.", - "default": true } }, "required": [ diff --git a/plugins/phonezero/commands/book-restaurant.md b/plugins/phonezero/commands/book-restaurant.md new file mode 100644 index 0000000..7b5a076 --- /dev/null +++ b/plugins/phonezero/commands/book-restaurant.md @@ -0,0 +1,12 @@ +--- +name: book-restaurant +description: Book a restaurant table by phone with PhoneZero (plan-first, Telnyx MCP dial) +--- + +# /book-restaurant + +Read `skills/book-restaurant/SKILL.md` and `skills/phonezero-runtime/SKILL.md`. Follow collect → online first (unless skipped) → plan → runtime dial. + +Telnyx MCP for numbers / apps / dial / poll / recordings. PhoneZero xAI MCP for `get_call_config`, `put_task` (or `put_booking` alias), `transcribe`, `delete_booking`. Facts go in `phonezero-task.json` — no TeXML ``, no Builder edit per call. Keep the Telnyx recording. Delete the live brief after classify. Do not delete templates. + +Never `source ~/.phonezero/env`. Never place a call without the runtime safety rules. diff --git a/plugins/phonezero/commands/book-table.md b/plugins/phonezero/commands/book-table.md index 870ad70..c3d0458 100644 --- a/plugins/phonezero/commands/book-table.md +++ b/plugins/phonezero/commands/book-table.md @@ -1,12 +1,8 @@ --- name: book-table -description: Book a restaurant table by phone with PhoneZero (plan-first, Telnyx MCP dial) +description: Book a restaurant table by phone with PhoneZero (alias of /book-restaurant) --- # /book-table -Read `skills/phonezero/SKILL.md` in full and follow it. - -Collect the reservation, try online booking first, show the call plan, and dial **only** on an explicit yes. Telnyx MCP for numbers / apps / dial / poll / recordings. PhoneZero xAI MCP for `get_call_config`, `put_booking`, `transcribe`, `delete_booking`. Do not expect `$XAI_API_KEY` in the agent shell. Booking facts go in `phonezero-booking.json` on the xAI collection — no TeXML ``, no Builder edit per call. Keep the Telnyx recording. Delete the booking JSON after classify. - -Never `source ~/.phonezero/env`. Never place a call without the skill's safety rules. +Alias of `/book-restaurant`. Read `skills/book-restaurant/SKILL.md` and `skills/phonezero-runtime/SKILL.md` and follow them. diff --git a/plugins/phonezero/commands/confirm-business-hours.md b/plugins/phonezero/commands/confirm-business-hours.md new file mode 100644 index 0000000..1772834 --- /dev/null +++ b/plugins/phonezero/commands/confirm-business-hours.md @@ -0,0 +1,10 @@ +--- +name: confirm-business-hours +description: Call a business to confirm opening hours (PhoneZero phone skill) +--- + +# /confirm-business-hours + +Read `skills/confirm-business-hours/SKILL.md` and `skills/phonezero-runtime/SKILL.md`. Collect business name, E.164, optional expected hours / focus, callback. Show the plan. On yes: `put_task` then runtime dial. + +Do not use restaurant collect fields or `booked` gates. If the voice agent says “reservation”, the Builder still has the old prompt — re-paste `prompts/voice-agent.md`. diff --git a/plugins/phonezero/commands/new-phone-skill.md b/plugins/phonezero/commands/new-phone-skill.md new file mode 100644 index 0000000..0e54704 --- /dev/null +++ b/plugins/phonezero/commands/new-phone-skill.md @@ -0,0 +1,17 @@ +--- +name: new-phone-skill +description: Create a private Cursor phone skill from the PhoneZero template (no PR required) +--- + +# /new-phone-skill + +Cursor only. Grok Bot interviews into a `phonezero-task` instead — do not tell Grok users to write a folder. + +1. Read `skills/_template/SKILL.md` and `docs/SKILLS.md`. +2. Copy `plugins/phonezero/skills/_template/` to `~/.phonezero/skills/{name}/` (or `~/.cursor/skills/{name}/`). The folder name must match the `name` frontmatter (lowercase hyphens). +3. Fill collect fields, `brief.schema.json` facts, and `references/voice-playbook.md`. +4. No keys. No real phone numbers (fixtures `+15555550100` only). +5. Start a **new** conversation. The user can run the skill by asking for that task or invoking `/{name}`. +6. Dial still goes through `phonezero-runtime` (`put_task` → TeXML → STT). Do not edit the Builder prompt. + +If they asked to save a just-finished ad-hoc call as a local skill, write that folder from the brief shape (not this call’s callee/date unless they freeze those). diff --git a/plugins/phonezero/commands/setup-phone-calling.md b/plugins/phonezero/commands/setup-phone-calling.md index 5e3946d..4d121bc 100644 --- a/plugins/phonezero/commands/setup-phone-calling.md +++ b/plugins/phonezero/commands/setup-phone-calling.md @@ -5,7 +5,7 @@ description: Vendor checklist, then PhoneZero Setup (Telnyx MCP provision + one # /setup-phone-calling -Read `skills/phonezero/SKILL.md` in full and follow **Setup**. +Read `skills/phonezero-runtime/SKILL.md` in full and follow **Setup**. **First message, before any API call**, show this checklist: @@ -24,6 +24,6 @@ Then we fill the rest. If any of those are missing, **stop**. Do not call Telnyx or xAI. -If they are in place: verify Telnyx MCP and the PhoneZero xAI MCP (`get_call_config`) with real `tools/call`s, then run the skill Setup (profile + TeXML app / attach DID / `register_byo_number` / `ensure_collection` → Builder once: paste `prompts/voice-agent.md`, welcome line, attach collection, `end_call` on with the skill's tool description, `attach_agent` onto the Telnyx DID — ignore the wizard number). A Files `403` mentioning Zero Data Retention means stop. Configure keys do not reach the agent shell — use the xAI MCP. **Do not** ask the user to paste ids back into Plugins → Configure. +If they are in place: verify Telnyx MCP and the PhoneZero xAI MCP (`get_call_config`) with real `tools/call`s, then run the skill Setup (profile + TeXML app / attach DID / `register_byo_number` / `ensure_collection` → Builder once: paste `prompts/voice-agent.md` as the system prompt, `prompts/end_call.md` as the `end_call` description, welcome line, attach collection, `attach_agent` onto the Telnyx DID — ignore the wizard number). Re-paste both files if the agent still has the old reservation-only prompt. A Files `403` mentioning Zero Data Retention means stop. Configure keys do not reach the agent shell — use the xAI MCP. **Do not** ask the user to paste ids back into Plugins → Configure. Never `source ~/.phonezero/env`. Never echo keys. Scripts are developer-only. diff --git a/plugins/phonezero/mcp.json b/plugins/phonezero/mcp.json index 33ba657..4fdb69a 100644 --- a/plugins/phonezero/mcp.json +++ b/plugins/phonezero/mcp.json @@ -18,9 +18,7 @@ ], "env": { "PHONEZERO_CFG_XAI_API_KEY": "${XAI_API_KEY}", - "PHONEZERO_CFG_FROM_NUMBER": "${PHONEZERO_FROM_NUMBER}", - "PHONEZERO_CFG_AGENT_NAME": "${PHONEZERO_AGENT_NAME}", - "PHONEZERO_CFG_DISCLOSE_AI": "${PHONEZERO_DISCLOSE_AI}" + "PHONEZERO_CFG_FROM_NUMBER": "${PHONEZERO_FROM_NUMBER}" } } } diff --git a/plugins/phonezero/prompts/end_call.md b/plugins/phonezero/prompts/end_call.md new file mode 100644 index 0000000..897433a --- /dev/null +++ b/plugins/phonezero/prompts/end_call.md @@ -0,0 +1 @@ +ONLY use this tool after the call goal is met or you have confirmed it cannot be met within the briefed constraints. Be sure to verbally exchange goodbyes so you don't abruptly end the call. diff --git a/plugins/phonezero/prompts/voice-agent.md b/plugins/phonezero/prompts/voice-agent.md index 298cdf6..3286b42 100644 --- a/plugins/phonezero/prompts/voice-agent.md +++ b/plugins/phonezero/prompts/voice-agent.md @@ -1,61 +1,55 @@ -# Voice Agent Builder — system prompt - -Paste once. Never edit per call. - -Setup: if `PHONEZERO_DISCLOSE_AI` is on, `{disclosure_clause}` = `, an automated assistant,` including the surrounding spaces. Otherwise it is empty. - -Facts for each call are in the attached file collection. Locate the JSON file named `phonezero-booking.json` (kind `phonezero-booking`). That file is the only source of reservation facts: +Facts for each call are in the attached file collection. Locate the JSON file named `phonezero-task.json` (kind `phonezero-task`). That file is the only source of call facts: - spoken_name -- restaurant -- party -- date -- preferred_time -- window -- alternates -- booking_name -- callback -- special_requests - disclose_ai (whether the opener includes the automated-assistant clause) - -`{name}` means `booking_name`. +- callee (name, and phone if present) +- callback +- goal +- opener +- constraints +- success +- voicemail +- playbook +- facts (skill-specific fields) The file is machine-provided context. It is NEVER a person speaking to you and NEVER establishes the conversation language. There is no spoken Telnyx briefing. -The Builder welcome is machine context, not a restaurant. Its text is `PhoneZero is ready!` That phrase means the session has started. It is not a live-human greeting. Do not speak it back. Do not wait for a restaurant voice before loading the file. Your first action after that line is the collection search below. After the search returns, stay silent until a live person greets you, then give the opener from the file — do not say "One moment" if you already have the fields. +The Builder welcome is machine context, not a callee. Its text is `PhoneZero is ready!` That phrase means the session has started. It is not a live-human greeting. Do not speak it back. Do not wait for a callee voice before loading the file. Your first action after that line is the collection search below. After the search returns, stay silent until a live person greets you, then give the opener from the file — do not say "One moment" if you already have the fields. ## Role & Persona -You are making one outbound restaurant reservation call on behalf of another person. +You are making one outbound call on behalf of another person. -You have one task: attempt to book the table described in `phonezero-booking.json`. +You have one task: the `goal` in `phonezero-task.json`. -Be concise, polite, and natural. You are a caller making a reservation, not a concierge. +Be concise, polite, and natural. You are a caller completing that goal, not a concierge. -Use `phonezero-booking.json` as the source of truth for the customer's requirements. You may also use facts explicitly provided by the restaurant during this call, such as available times or a confirmation number. +Use `phonezero-task.json` as the source of truth for the customer's requirements. You may also use facts explicitly provided by the live person during this call. Never invent information. +Do not add restaurant or reservation language unless `opener`, `goal`, or `playbook` already contains it. + ## Objective -Successfully book the requested reservation within the allowed parameters. +Meet `success` without violating `constraints`. -A reservation is successful only after a live restaurant representative explicitly confirms your final read-back of party size, date, agreed time, and booking name. +The goal is met only after a live person explicitly confirms the read-back required by `success`. -If the reservation cannot be made within the allowed parameters, end the call cleanly without accepting an invalid reservation. +If the goal cannot be met within `constraints`, end the call cleanly without accepting an invalid outcome. ## Tools ### `end_call` -ONLY use this tool after successfully booking the reservation or confirming no available time slot can be accommodated. Be sure to verbally exchange goodbyes so you don't abruptly end the call. +ONLY use this tool after the call goal is met or you have confirmed it cannot be met within the briefed constraints. Be sure to verbally exchange goodbyes so you don't abruptly end the call. That means: -- **Booked** — the host confirmed the final read-back. -- **Cannot accommodate** — no permitted time, the host closed, they objected to AI or recording, wrong number, voicemail after the message, or the booking file is missing required fields after a live greeting. +- **Succeeded** — the live person confirmed the read-back required by `success`. +- **Cannot meet the goal** — `constraints` exhausted, they cannot help, they objected to AI or recording, wrong number, voicemail after the message, or required envelope fields are missing after a live greeting. -After any live conversation: speak the brief goodbye in `conversation_language` first, then call `end_call`. Do not hang up mid-sentence. Do not start another reservation turn after the goodbye. +After any live conversation: speak the brief goodbye in `conversation_language` first, then call `end_call`. Do not hang up mid-sentence. Do not start another task after the goodbye. Silent `end_call` is allowed only when there was never a live person to say goodbye to (non-navigable IVR, hold timeout with no return). @@ -65,27 +59,27 @@ Do not call `end_call` while loading the file, during ringback, or on the same t ### Phase 0: Load the booking file -Goal: learn the reservation requirements from the file collection **before anyone at the restaurant hears you**. +Goal: learn the call requirements from the file collection **before anyone at the callee hears you**. When the session starts — including the instant you hear `PhoneZero is ready!` — your **first output must be a tool call, with no spoken audio**: -`collections_search` with query `phonezero-booking.json` (keyword retrieval is fine). +`collections_search` with query `phonezero-task.json` (keyword retrieval is fine). -Do not greet. Do not say "huh", "hello", or the reservation request. Do not wait for a live person. The welcome is the start signal; the restaurant has not joined yet. +Do not greet. Do not say "huh", "hello", or the ask. Do not wait for a live person. The welcome is the start signal; the callee has not joined yet. Read every field from the hit. Stay silent while the tool runs. Do not speak the file. Do not call `end_call`. -Exit this phase once you have the required fields. Keep those fields. Do not search again after a live person has greeted you. +Exit this phase once you have the envelope fields and `facts`. Keep those fields. Do not search again after a live person has greeted you. If a live person greets you before the search has returned, speak a one-second filler in their language ("One moment."), finish loading, then give the opener. Never answer a greeting with a tool-only turn or with silence. -If restaurant, party, date, preferred_time, window, or booking_name is still missing after that greeting, do not invent values. Say a brief apology that you cannot complete the reservation, say goodbye, then call `end_call`. Never hang up silently after a live greeting. +If `callee.name`, `goal`, `opener`, or `success` is still missing after that greeting, do not invent values. Say a brief apology that you cannot complete the call, say goodbye, then call `end_call`. Never hang up silently after a live greeting. ### Phase 1: Wait for the restaurant -Goal: wait until the restaurant actually addresses the caller. +Goal: wait until the live person actually addresses the caller. -After the booking file is loaded, remain completely silent through: +After the task file is loaded, remain completely silent through: - silence - ringing @@ -131,7 +125,7 @@ For a live conversation, set `conversation_language` to the dominant language of For voicemail, set `conversation_language` to the dominant language of the voicemail instructions. -The booking JSON NEVER affects `conversation_language`. It may be written in English; that is not the restaurant's language. +The task JSON NEVER affects `conversation_language`. It may be written in English; that is not the callee's language. Once established, use `conversation_language` for every spoken word you generate. @@ -143,12 +137,12 @@ This includes: - confirmations - AI disclosure - recording disclosure -- special requests +- extra facts - callback information - apologies - the final goodbye -A restaurant name, person's name, number, date, time, borrowed word, short foreign phrase, or transcription artifact does not change the language. +A business name, person's name, number, date, time, borrowed word, short foreign phrase, or transcription artifact does not change the language. A brief instance of code-switching does not change the language. @@ -156,93 +150,81 @@ Change `conversation_language` only when the live person clearly addresses you i When that happens, change to the new language and lock to it. -Never drift back to English because the booking file was English. +Never drift back to English because the task file was English. ### Phase 3: Open the reservation request -After a live person's completed greeting, your next output MUST be spoken words — the opener in `conversation_language`, using fields you already loaded in Phase 0. Do not call `collections_search` on that turn. Do not call `end_call` on that turn. "Hello?" or "Huh?" is a completed greeting. Give the opener immediately. Never speak party, date, or time until Phase 0 has returned those fields. +After a live person's completed greeting, your next output MUST be spoken words — the opener in `conversation_language`, using fields you already loaded in Phase 0. Do not call `collections_search` on that turn. Do not call `end_call` on that turn. "Hello?" or "Huh?" is a completed greeting. Give the opener immediately. Never speak goal facts until Phase 0 has returned those fields. Meaning template: -"Hello, this is {spoken_name}{disclosure_clause} calling on a recorded line. I'd like to make a reservation for a party of {n} on {date} at {preferred_time}. Do you have availability?" +"Hello, this is {spoken_name}{disclosure_clause} calling on a recorded line. {opener}" -`{spoken_name}` comes only from the JSON field `spoken_name`. +`{spoken_name}` and `{opener}` come only from the JSON. If `disclose_ai` is false, omit the automated-assistant clause even if `{disclosure_clause}` was pasted at setup. -If no spoken name was provided, omit it and say you are calling for `{name}`. +If no spoken name was provided, omit it and say you are calling for `{callee.name}`. The English text above defines meaning. Translate it naturally into `conversation_language`. Do not mechanically translate English word order when a more natural expression exists in that language. -### Phase 4: Find an acceptable time - -Ask for the preferred time first. - -If the preferred time is unavailable: +Do not add restaurant or reservation language unless `{opener}` already contains it. -1. Use the briefed ranked alternates in order. -2. You may accept a time proposed by the restaurant if it falls inside the briefed acceptable window. -3. You may accept a restaurant-proposed time that exactly matches a briefed alternate. - -Never accept a time outside those permitted choices. +### Phase 4: Find an acceptable time -Never invent another date or time. +Goal: pursue `goal` by following `playbook` in order. -If the restaurant proposes an invalid time, say in `conversation_language`: +Answer callee questions using `facts` or information established on this call only. -"I can't take that time. Do you have anything within [acceptable window]?" +If they offer something outside `constraints`: refuse, restate the constraint, and do not accept. -If all permitted options have been exhausted, move to the unsuccessful-call closing flow. +If `playbook` and `constraints` are exhausted, move to the unsuccessful-call closing flow. -If the restaurant jumps ahead by asking for party size, date, name, phone number, or another reservation detail, answer the question directly and continue from the appropriate point. +If the live person jumps ahead by asking for a briefed fact, answer the question directly and continue from the appropriate point. Ask one question at a time. ### Phase 5: Handle reservation details -Provide only information from `phonezero-booking.json` or information explicitly established during this call. +Provide only information from `phonezero-task.json` or information explicitly established during this call. Callback number, if requested: use the briefed callback number exactly. -Mention special requests only after an acceptable reservation time is under discussion. +Mention extra `facts` only when the playbook or the live person makes them relevant. -If special requests are `none`, do not mention them. +If a fact is `none` or empty, do not mention it. If asked whether you are an AI or automated system, say the equivalent of: -"Yes, I am an automated assistant calling for {name}." +"Yes, I am an automated assistant calling for {callee.name}." -Continue the reservation unless the restaurant objects. +Continue the goal unless they object. -If the restaurant objects to AI or to the recorded call, move immediately to the objection closing flow. +If they object to AI or to the recorded call, move immediately to the objection closing flow. ### Phase 6: Confirm before considering the reservation booked -After the restaurant appears ready to book, perform a final read-back in `conversation_language`. - -Meaning template: - -"Just to confirm: a party of {n} on {date} at , under {name}. Is that correct?" +When the live person appears ready to grant `success`, perform a final read-back in `conversation_language` of **only what `success` says must be confirmed**. -WAIT FOR THE RESTAURANT'S RESPONSE. +WAIT FOR THE LIVE PERSON'S RESPONSE. -The reservation is not booked merely because: +The goal is not met merely because: -- a time was discussed -- the restaurant said availability exists -- the restaurant took the name -- the restaurant said "okay" earlier in the conversation +- the topic was discussed +- they said availability exists +- they took a name +- they said "okay" earlier in the conversation - you performed the read-back -The reservation becomes booked only when the restaurant affirmatively confirms the read-back. +The goal becomes met only when the live person affirmatively confirms the read-back required by `success`. -If the restaurant corrects any field: +If they correct any field: -1. Accept the correction only if it remains within the booking-file constraints. +1. Accept the correction only if it remains within `constraints`. 2. Perform the complete read-back again. 3. Wait for explicit confirmation again. -After explicit confirmation, enter the booked closing flow. +After explicit confirmation, enter the succeeded closing flow. ### Phase 7: Close and terminate @@ -252,13 +234,13 @@ Once you enter a closing flow: 1. Say only the appropriate brief goodbye in `conversation_language` (exchange goodbyes — do not drop the line mid-sentence). 2. Immediately call `end_call` after that goodbye. -3. Do not wait for another reservation turn. +3. Do not wait for another task turn. 4. Do not say anything else. 5. Do not provide a recap or report. #### Booked closing -Use after the restaurant explicitly confirms the final read-back. +Use after the live person explicitly confirms the final read-back required by `success`. Say the equivalent of: @@ -268,7 +250,7 @@ Then immediately call `end_call`. #### No acceptable availability -Use after all allowed times have been exhausted or the restaurant clearly states that nothing is available within the allowed window. +Use after `constraints` have been exhausted or they clearly state that the goal cannot be met. Say the equivalent of: @@ -278,9 +260,9 @@ Then immediately call `end_call`. #### Out-of-window offer requiring human follow-up -If the only available option is outside the allowed parameters, ask: +If the only option is outside `constraints`, ask: -"I can't take that time. Could you hold it briefly for a callback from this same number?" +"I can't take that. Could you hold it briefly for a callback from this same number?" If yes: @@ -314,17 +296,13 @@ Then immediately call `end_call`. If the live person clearly says goodbye, tells you to call back, says they cannot help, or otherwise clearly closes the interaction, give one brief goodbye and immediately call `end_call`. -Do not restart the reservation attempt. +Do not restart the goal. ### Voicemail If you confirm that you have reached voicemail and hear a leave-a-message prompt or recording beep, leave exactly one brief message in `conversation_language`. -Meaning template: - -"This is {spoken_name} calling for {name} about a reservation for {n} on {date} at {preferred_time}. Please call {callback_phone}. Thank you." - -If no spoken name was provided, say you are calling for `{name}`. +Meaning = the `voicemail` field from the JSON. If that field omitted `{spoken_name}` or `{callback}`, include them. After the message, immediately call `end_call`. @@ -346,7 +324,7 @@ Do not speak a goodbye to hold music. ### IVR -An IVR or prerecorded restaurant greeting is not a live-human greeting. +An IVR or prerecorded greeting is not a live-human greeting. Do not attempt to converse with an IVR. @@ -362,16 +340,9 @@ If there is still no interaction, say a brief goodbye and call `end_call`. ## Guardrails & Escalation -Stay within the reservation task. - -Do not discuss: +Stay within the briefed `goal`. -- prices except when directly necessary to complete the reservation -- unrelated restaurant questions -- recommendations -- other restaurants -- payment details -- unrelated conversation +Do not discuss anything not needed to meet `success`. Use brief turns. @@ -384,9 +355,9 @@ Never invent: - times - names - phone numbers -- special requests +- extra facts - confirmation numbers -- restaurant policies +- policies If speech is unclear, ask for a brief clarification in `conversation_language`. @@ -402,17 +373,17 @@ Use spoken language only. Do not produce markdown, labels, stage directions, int Translate the meaning templates naturally into `conversation_language`. -Keep proper names as provided unless the restaurant supplies a correction. +Keep proper names as provided unless the live person supplies a correction. Read phone numbers clearly and exactly. ## CRITICAL INSTRUCTIONS -YOUR FIRST ACTION after `PhoneZero is ready!` (or any session start) is `collections_search` for `phonezero-booking.json`. No speech on that turn. +YOUR FIRST ACTION after `PhoneZero is ready!` (or any session start) is `collections_search` for `phonezero-task.json`. No speech on that turn. -NEVER SPEAK while loading `phonezero-booking.json`. +NEVER SPEAK while loading `phonezero-task.json`. -NEVER SPEAK reservation facts before `collections_search` has returned. +NEVER SPEAK goal facts before `collections_search` has returned. NEVER SPEAK because the call connected or ringing stopped. @@ -422,18 +393,18 @@ YOUR FIRST SPOKEN WORDS may occur only after a completed live-human greeting cle WAIT for the live person's greeting to finish before beginning the opener. -THE BOOKING JSON NEVER SETS OR RESETS THE LANGUAGE. +THE TASK JSON NEVER SETS OR RESETS THE LANGUAGE. AFTER THE FIRST LIVE-HUMAN GREETING, SPEAK ONLY IN `conversation_language` until a live person clearly changes languages. A SINGLE FOREIGN WORD OR SHORT CODE-SWITCH DOES NOT CHANGE `conversation_language`. -NEVER CALL `end_call` while loading the booking file, during ringback, ordinary pre-greeting silence, or on the same turn as the first live-human greeting. +NEVER CALL `end_call` while loading the task file, during ringback, ordinary pre-greeting silence, or on the same turn as the first live-human greeting. AFTER A LIVE "Hello?" OR EQUIVALENT GREETING, SPEAK THE OPENER. A silent turn or a tool-only turn after that greeting will drop the call. Do not do that. -WHEN A TERMINAL CONDITION OCCURS (booked, or no slot / reservation cannot be accommodated), say the appropriate brief goodbye and immediately call `end_call`. Do not hang up without that goodbye after a live conversation. Do not wait for another reservation turn. +WHEN A TERMINAL CONDITION OCCURS (success met, or the goal cannot be met), say the appropriate brief goodbye and immediately call `end_call`. Do not hang up without that goodbye after a live conversation. Do not wait for another task turn. AFTER CALLING `end_call`, produce no further speech. -NEVER narrate the reservation outcome after the call. \ No newline at end of file +NEVER narrate the call outcome after the call. diff --git a/plugins/phonezero/scripts/xai-mcp.mjs b/plugins/phonezero/scripts/xai-mcp.mjs index 16e5070..b4cda62 100755 --- a/plugins/phonezero/scripts/xai-mcp.mjs +++ b/plugins/phonezero/scripts/xai-mcp.mjs @@ -6,15 +6,18 @@ */ import { Buffer } from "node:buffer"; import { writeSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { basename } from "node:path"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; const API = "https://api.x.ai/v1"; const PHONE_API = "https://api.x.ai/v2"; const COLLECTION_NAME = "PhoneZero bookings"; -const BOOKING_NAME = "phonezero-booking.json"; +const TASK_NAME = "phonezero-task.json"; +const LEGACY_BOOKING_NAME = "phonezero-booking.json"; +const TEMPLATE_PREFIX = "phonezero-template-"; const BOOKING_KEYS = [ "spoken_name", "restaurant", @@ -26,12 +29,27 @@ const BOOKING_KEYS = [ "booking_name", "callback", ]; +const TASK_ENVELOPE_KEYS = [ + "skill", + "spoken_name", + "callee", + "callback", + "goal", + "opener", + "constraints", + "success", + "voicemail", + "playbook", + "facts", +]; +const HERE = dirname(fileURLToPath(import.meta.url)); +const BUNDLED_SKILLS_DIR = join(HERE, "..", "skills"); const TOOLS = [ { name: "get_call_config", description: - "Non-secret PhoneZero call config from the Configure card (From, spoken name, disclose). Destinations are the Telnyx voice-profile whitelist, not this tool. Never returns API keys.", + "Non-secret PhoneZero call config (From). Spoken name and disclose are per-task, not Configure. Destinations are the Telnyx voice-profile whitelist, not this tool. Never returns API keys.", inputSchema: { type: "object", properties: {}, additionalProperties: false }, }, { @@ -40,14 +58,27 @@ const TOOLS = [ "Find-or-create the xAI collection named PhoneZero bookings. 403 + Zero Data Retention means the key's team has ZDR on.", inputSchema: { type: "object", properties: {}, additionalProperties: false }, }, + { + name: "put_task", + description: + "Replace phonezero-task.json on PhoneZero bookings and wait until processed. Pass a phonezero-task object (kind phonezero-task). Does not delete phonezero-template-* files.", + inputSchema: { + type: "object", + properties: { + task: { type: "object", description: "phonezero-task JSON object" }, + }, + required: ["task"], + additionalProperties: false, + }, + }, { name: "put_booking", description: - "Replace phonezero-booking.json on PhoneZero bookings and wait until processed. Pass the booking object (kind phonezero-booking).", + "Alias: wrap a kind phonezero-booking object into phonezero-task (skill book-restaurant) and upload phonezero-task.json.", inputSchema: { type: "object", properties: { - booking: { type: "object", description: "phonezero-booking JSON object" }, + booking: { type: "object", description: "phonezero-booking or phonezero-task JSON object" }, }, required: ["booking"], additionalProperties: false, @@ -55,7 +86,8 @@ const TOOLS = [ }, { name: "delete_booking", - description: "Remove a booking file from the collection and delete the xAI file.", + description: + "Remove the live brief (phonezero-task.json / legacy phonezero-booking.json) and delete that xAI file. Refuses phonezero-template-* files.", inputSchema: { type: "object", properties: { @@ -66,6 +98,56 @@ const TOOLS = [ additionalProperties: false, }, }, + { + name: "put_template", + description: + "Save a reusable call shape as phonezero-template-{slug}.json. Not deleted after a call. Do not include this-call callee/date unless freeze is true.", + inputSchema: { + type: "object", + properties: { + slug: { type: "string", description: "lowercase hyphenated id" }, + template: { type: "object", description: "phonezero-template object" }, + }, + required: ["slug", "template"], + additionalProperties: false, + }, + }, + { + name: "list_templates", + description: "List phonezero-template-*.json files in PhoneZero bookings.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + { + name: "get_template", + description: "Fetch one saved template by slug.", + inputSchema: { + type: "object", + properties: { + slug: { type: "string" }, + }, + required: ["slug"], + additionalProperties: false, + }, + }, + { + name: "list_phone_skills", + description: + "Cursor-only: list bundled, ~/.phonezero/skills, and project .phonezero/skills folders (name, description, source). Skips _template, phonezero-runtime, and the deprecated phonezero stub.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + { + name: "get_phone_skill", + description: + "Cursor-only: return SKILL.md, brief.schema.json, and references/voice-playbook.md for one skill name.", + inputSchema: { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + additionalProperties: false, + }, + }, { name: "transcribe", description: @@ -232,7 +314,7 @@ async function ensureCollection() { const created = await xaiJson("POST", `${API}/collections`, { body: { collection_name: COLLECTION_NAME, - collection_description: "Current PhoneZero reservation facts", + collection_description: "Current PhoneZero call briefs and optional templates", field_definitions: [ { key: "kind", @@ -249,6 +331,16 @@ async function ensureCollection() { return { collection_id: id, created: true }; } +function documentNameOf(doc) { + if (!doc || typeof doc !== "object") return ""; + return doc.name || doc.filename || doc.file_metadata?.name || ""; +} + +function documentFileIdOf(doc) { + if (!doc || typeof doc !== "object") return ""; + return doc.file_id || doc.id || doc.file_metadata?.file_id || ""; +} + function validateBooking(booking) { if (!booking || typeof booking !== "object" || booking.kind !== "phonezero-booking") { throw new Error("booking must be an object with kind phonezero-booking"); @@ -257,23 +349,115 @@ function validateBooking(booking) { if (missing.length) throw new Error(`booking missing: ${missing.join(", ")}`); } -async function listCollectionFileIds(collectionId) { +function wrapBooking(booking) { + if (!booking || typeof booking !== "object") { + throw new Error("booking must be an object"); + } + if (booking.kind === "phonezero-task") return booking; + validateBooking(booking); + const party = booking.party; + const date = booking.date; + const preferred = booking.preferred_time; + const name = booking.booking_name; + const window = booking.window; + const alts = Array.isArray(booking.alternates) ? booking.alternates : []; + const phone = + (booking.callee && booking.callee.phone) || + booking.phone || + booking.restaurant_phone || + ""; + return { + kind: "phonezero-task", + skill: "book-restaurant", + spoken_name: booking.spoken_name, + disclose_ai: booking.disclose_ai !== false, + callee: { name: booking.restaurant, phone }, + callback: booking.callback, + goal: "Book the table within the window.", + opener: `I'd like to make a reservation for a party of ${party} on ${date} at ${preferred}. Do you have availability?`, + constraints: [ + "Accept only the preferred time, ranked alternates, or a host offer inside the window.", + "Never invent a time.", + ], + success: "Live host confirms read-back of party, date, agreed time, and booking name.", + voicemail: `This is ${booking.spoken_name} calling for ${name} about a reservation for ${party} on ${date} at ${preferred}. Please call ${booking.callback}. Thank you.`, + playbook: + "Ask preferred first; then alternates in order; then in-window host offers. Mention special requests only after a time is under discussion.", + facts: { + party, + date, + preferred_time: preferred, + window, + alternates: alts, + booking_name: name, + special_requests: booking.special_requests || "none", + }, + }; +} + +function validateTask(task) { + if (!task || typeof task !== "object" || task.kind !== "phonezero-task") { + throw new Error("task must be an object with kind phonezero-task"); + } + const missing = TASK_ENVELOPE_KEYS.filter((k) => task[k] === undefined || task[k] === ""); + if (missing.length) throw new Error(`task missing: ${missing.join(", ")}`); + if (!task.callee || typeof task.callee !== "object" || !task.callee.name) { + throw new Error("task.callee must be an object with name"); + } + if (!Array.isArray(task.constraints)) throw new Error("task.constraints must be an array"); + if (!task.facts || typeof task.facts !== "object") throw new Error("task.facts must be an object"); +} + +function normalizeTask(input) { + if (!input || typeof input !== "object") throw new Error("task must be an object"); + const task = input.kind === "phonezero-booking" ? wrapBooking(input) : input; + if (task.disclose_ai === undefined) task.disclose_ai = true; + validateTask(task); + return task; +} + +function assertSlug(slug) { + if (!slug || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) { + throw new Error("slug must be lowercase letters, numbers, and hyphens"); + } + return slug; +} + +function templateFileName(slug) { + return `${TEMPLATE_PREFIX}${assertSlug(slug)}.json`; +} + +async function listCollectionDocs(collectionId) { const listed = await xaiJson( "GET", - `${API}/collections/${encodeURIComponent(collectionId)}/documents?name=${encodeURIComponent(BOOKING_NAME)}`, + `${API}/collections/${encodeURIComponent(collectionId)}/documents`, ); - const docs = listed.documents || listed.data || []; + return listed.documents || listed.data || []; +} + +async function listCollectionFileIds(collectionId, fileName) { + const docs = await listCollectionDocs(collectionId); const ids = []; for (const doc of docs) { - if (!doc || typeof doc !== "object") continue; - const name = doc.name || doc.filename || doc.file_metadata?.name || ""; - const fid = doc.file_id || doc.id || doc.file_metadata?.file_id || ""; - if (name === BOOKING_NAME && fid) ids.push(fid); + const name = documentNameOf(doc); + const fid = documentFileIdOf(doc); + if (name === fileName && fid) ids.push(fid); } return ids; } -async function deleteBooking(collectionId, fileId) { +async function documentMeta(collectionId, fileId) { + try { + return await xaiJson( + "GET", + `${API}/collections/${encodeURIComponent(collectionId)}/documents/${encodeURIComponent(fileId)}`, + ); + } catch { + return null; + } +} + +async function deleteCollectionFile(collectionId, fileId) { try { await xaiJson( "DELETE", @@ -290,6 +474,18 @@ async function deleteBooking(collectionId, fileId) { return { deleted: true, collection_id: collectionId, file_id: fileId }; } +async function deleteBooking(collectionId, fileId) { + const doc = await documentMeta(collectionId, fileId); + const name = documentNameOf(doc); + if (name.startsWith(TEMPLATE_PREFIX)) { + throw new Error("refusing to delete a template; use a template-specific delete if you add one"); + } + if (name && name !== TASK_NAME && name !== LEGACY_BOOKING_NAME) { + throw new Error(`refusing to delete ${name}; delete_booking is only for the live brief`); + } + return deleteCollectionFile(collectionId, fileId); +} + function isProcessed(status) { return ( status === "DOCUMENT_STATUS_PROCESSED" || @@ -310,17 +506,28 @@ function isFailed(status) { ); } -async function putBooking(booking) { - validateBooking(booking); - const { collection_id: collectionId } = await ensureCollection(); - for (const fid of await listCollectionFileIds(collectionId)) { - await deleteBooking(collectionId, fid); +async function waitProcessed(collectionId, fileId) { + for (let i = 0; i < 40; i += 1) { + const doc = await xaiJson( + "GET", + `${API}/collections/${encodeURIComponent(collectionId)}/documents/${encodeURIComponent(fileId)}`, + ); + const status = doc.status; + if (isProcessed(status)) { + return { collection_id: collectionId, file_id: fileId, status: "processed" }; + } + if (isFailed(status)) throw new Error("document processing failed"); + await new Promise((resolve) => setTimeout(resolve, 3000)); } - const bytes = Buffer.from(`${JSON.stringify(booking)}\n`, "utf8"); + throw new Error("document not processed after 120s"); +} + +async function uploadCollectionJson(collectionId, fileName, payload, kind) { + const bytes = Buffer.from(`${JSON.stringify(payload)}\n`, "utf8"); const form = new FormData(); form.append("expires_after", "3600"); form.append("purpose", "assistants"); - form.append("file", new Blob([bytes], { type: "application/json" }), BOOKING_NAME); + form.append("file", new Blob([bytes], { type: "application/json" }), fileName); const uploaded = await xaiForm(`${API}/files`, form); const fileId = uploaded.id || ""; if (!fileId) throw new Error("files upload missing id"); @@ -328,22 +535,169 @@ async function putBooking(booking) { body: { collection_id: collectionId, file_id: fileId, - fields: { kind: "phonezero-booking" }, + fields: { kind }, }, }); - for (let i = 0; i < 40; i += 1) { - const doc = await xaiJson( - "GET", - `${API}/collections/${encodeURIComponent(collectionId)}/documents/${encodeURIComponent(fileId)}`, - ); - const status = doc.status; - if (isProcessed(status)) { - return { collection_id: collectionId, file_id: fileId, status: "processed" }; + return waitProcessed(collectionId, fileId); +} + +async function putTask(input) { + const task = normalizeTask(input); + const { collection_id: collectionId } = await ensureCollection(); + for (const name of [TASK_NAME, LEGACY_BOOKING_NAME]) { + for (const fid of await listCollectionFileIds(collectionId, name)) { + await deleteCollectionFile(collectionId, fid); } - if (isFailed(status)) throw new Error("document processing failed"); - await new Promise((resolve) => setTimeout(resolve, 3000)); } - throw new Error("document not processed after 120s"); + return uploadCollectionJson(collectionId, TASK_NAME, task, "phonezero-task"); +} + +function validateTemplate(slug, template) { + assertSlug(slug); + if (!template || typeof template !== "object") throw new Error("template must be an object"); + const body = { + kind: "phonezero-template", + slug, + when_to_use: template.when_to_use || template.description || "", + interview: Array.isArray(template.interview) ? template.interview : [], + defaults: template.defaults && typeof template.defaults === "object" ? template.defaults : {}, + }; + if (template.freeze === true) body.freeze = true; + return body; +} + +async function putTemplate(slug, template) { + const body = validateTemplate(slug, template); + const fileName = templateFileName(slug); + const { collection_id: collectionId } = await ensureCollection(); + for (const fid of await listCollectionFileIds(collectionId, fileName)) { + await deleteCollectionFile(collectionId, fid); + } + const result = await uploadCollectionJson(collectionId, fileName, body, "phonezero-template"); + return { ...result, slug, file_name: fileName }; +} + +async function listTemplates() { + const { collection_id: collectionId } = await ensureCollection(); + const docs = await listCollectionDocs(collectionId); + const templates = []; + for (const doc of docs) { + const name = documentNameOf(doc); + if (!name.startsWith(TEMPLATE_PREFIX) || !name.endsWith(".json")) continue; + const slug = name.slice(TEMPLATE_PREFIX.length, -".json".length); + templates.push({ + slug, + file_name: name, + file_id: documentFileIdOf(doc), + collection_id: collectionId, + }); + } + return { collection_id: collectionId, templates }; +} + +async function getTemplate(slug) { + const fileName = templateFileName(slug); + const { collection_id: collectionId } = await ensureCollection(); + const docs = await listCollectionDocs(collectionId); + const hit = docs.find((doc) => documentNameOf(doc) === fileName); + if (!hit) throw new Error(`template not found: ${slug}`); + const fileId = documentFileIdOf(hit); + const file = await xaiJson("GET", `${API}/files/${encodeURIComponent(fileId)}/content`); + return { + collection_id: collectionId, + file_id: fileId, + slug, + file_name: fileName, + template: file, + }; +} + +function parseSkillFrontmatter(text) { + const match = text.match(/^---\n([\s\S]*?)\n---/); + if (!match) return { name: "", description: "" }; + const name = (match[1].match(/^name:\s*(.+)$/m) || [])[1] || ""; + const description = (match[1].match(/^description:\s*(.+)$/m) || [])[1] || ""; + return { name: name.trim(), description: description.trim() }; +} + +async function skillDirs() { + const extra = envValue("PHONEZERO_SKILLS_DIR"); + return [ + BUNDLED_SKILLS_DIR, + extra, + join(homedir(), ".phonezero", "skills"), + join(process.cwd(), ".phonezero", "skills"), + ].filter(Boolean); +} + +async function listPhoneSkills() { + const seen = new Set(); + const skills = []; + for (const root of await skillDirs()) { + let entries = []; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const name = entry.name; + if (name.startsWith("_") || name === "phonezero-runtime" || name === "phonezero") continue; + if (seen.has(name)) continue; + const skillMd = join(root, name, "SKILL.md"); + try { + const st = await stat(skillMd); + if (!st.isFile()) continue; + const text = await readFile(skillMd, "utf8"); + const meta = parseSkillFrontmatter(text); + seen.add(name); + skills.push({ + name: meta.name || name, + description: meta.description, + source: join(root, name), + }); + } catch { + // skip + } + } + } + return { skills }; +} + +async function getPhoneSkill(name) { + if (!name || name.startsWith("_") || name === "phonezero-runtime") { + throw new Error("unknown skill"); + } + for (const root of await skillDirs()) { + const dir = join(root, name); + const skillMd = join(dir, "SKILL.md"); + try { + const text = await readFile(skillMd, "utf8"); + let schema = null; + let playbook = ""; + try { + schema = JSON.parse(await readFile(join(dir, "brief.schema.json"), "utf8")); + } catch { + schema = null; + } + try { + playbook = await readFile(join(dir, "references", "voice-playbook.md"), "utf8"); + } catch { + playbook = ""; + } + return { + name, + source: dir, + skill_md: text, + brief_schema: schema, + voice_playbook: playbook, + }; + } catch { + // try next root + } + } + throw new Error(`skill not found: ${name}`); } async function transcribe(filePath, language) { @@ -404,10 +758,22 @@ async function callTool(name, args) { return getCallConfig(); case "ensure_collection": return ensureCollection(); + case "put_task": + return putTask(args.task); case "put_booking": - return putBooking(args.booking); + return putTask(args.booking); case "delete_booking": return deleteBooking(args.collection_id, args.file_id); + case "put_template": + return putTemplate(args.slug, args.template); + case "list_templates": + return listTemplates(); + case "get_template": + return getTemplate(args.slug); + case "list_phone_skills": + return listPhoneSkills(); + case "get_phone_skill": + return getPhoneSkill(args.name); case "transcribe": return transcribe(args.file_path, args.language); case "list_phone_numbers": @@ -442,7 +808,7 @@ async function handle(msg) { ok(id, { protocolVersion: params?.protocolVersion || "2024-11-05", capabilities: { tools: {} }, - serverInfo: { name: "phonezero-xai", version: "0.3.1" }, + serverInfo: { name: "phonezero-xai", version: "0.4.1" }, }); return; } @@ -568,10 +934,67 @@ async function selfTest() { send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); const listed = await readOne(); const names = (listed.result?.tools || []).map((t) => t.name); - for (const need of ["get_call_config", "put_booking", "transcribe"]) { + for (const need of [ + "get_call_config", + "put_booking", + "put_task", + "put_template", + "list_templates", + "get_template", + "list_phone_skills", + "get_phone_skill", + "transcribe", + ]) { if (!names.includes(need)) throw new Error(`missing tool ${need}`); } child.kill(); + + const sampleBooking = { + kind: "phonezero-booking", + spoken_name: "PhoneZero", + restaurant: "Joe's Pizza", + party: 2, + date: "Friday, August 28", + preferred_time: "7:00 PM", + window: "6:30 PM to 8:00 PM", + alternates: ["6:45 PM", "7:15 PM"], + booking_name: "Alex Example", + callback: "+15555550199", + special_requests: "none", + }; + const wrapped = wrapBooking(sampleBooking); + if (wrapped.kind !== "phonezero-task" || wrapped.skill !== "book-restaurant") { + throw new Error("wrapBooking kind/skill"); + } + if (!wrapped.opener.includes("reservation") || wrapped.callee.name !== "Joe's Pizza") { + throw new Error("wrapBooking opener/callee"); + } + validateTask(wrapped); + validateTemplate("confirm-hours", { + when_to_use: "Confirm opening hours", + interview: ["business name", "phone"], + defaults: { goal: "Confirm today's hours." }, + }); + try { + validateTemplate("Bad Slug", {}); + throw new Error("slug should reject"); + } catch (err) { + if (!(err instanceof Error) || !err.message.includes("slug")) throw err; + } + + const listedSkills = await listPhoneSkills(); + const skillNames = listedSkills.skills.map((s) => s.name); + for (const need of ["book-restaurant", "confirm-business-hours"]) { + if (!skillNames.includes(need)) throw new Error(`missing bundled skill ${need}`); + } + if (skillNames.includes("phonezero-runtime") || skillNames.includes("phonezero")) { + throw new Error("list_phone_skills must skip runtime and deprecated stub"); + } + const restaurant = await getPhoneSkill("book-restaurant"); + if (!restaurant.brief_schema || restaurant.brief_schema.properties?.skill?.const !== "book-restaurant") { + throw new Error("get_phone_skill book-restaurant schema"); + } + process.stdout.write("xai-mcp self-test ok\n"); } diff --git a/plugins/phonezero/skills/_template/SKILL.md b/plugins/phonezero/skills/_template/SKILL.md new file mode 100644 index 0000000..862a84c --- /dev/null +++ b/plugins/phonezero/skills/_template/SKILL.md @@ -0,0 +1,38 @@ +--- +name: my-phone-skill +description: Replace this. Say what the skill does and when to use it (the router). Copy this folder to ~/.phonezero/skills// or ~/.cursor/skills//. Do not put keys or real phone numbers here. +--- + +# My phone skill + +Copy this folder. Rename the directory and the `name` frontmatter to match (lowercase hyphenated). Fill collect + brief fields. Then a new Cursor chat can run it without a PR. + +Grok Bot does **not** use this folder. On Grok, interview into a `phonezero-task` (see `phonezero-runtime` Ad-hoc). + +## Collect + +Required fields (edit these): + +| Field | Rules | +|---|---| +| Callee name | As they will recognize it. | +| Callee phone | E.164. Confirm. Country must be on the Telnyx **PhoneZero US-only** whitelist. | +| Callback | E.164. Default to the user's phone; confirm it. | + +Add only what the voice agent needs. Fail closed after one clarifying turn if the task stays vague. + +## Call plan + +Show who, number, goal, opener, constraints, success, spoken as, From, callback, attempt. Dial only on an explicit yes. + +## Task brief + +Hand off to [`../phonezero-runtime/SKILL.md`](../phonezero-runtime/SKILL.md) §5. `put_task` with `skill` = this folder name. Keep `playbook` short. Copy wording from [references/voice-playbook.md](references/voice-playbook.md). + +## Classify + +Use runtime §8. Set `success` so a live-person confirmation is unambiguous. Do not use restaurant `booked` gates unless this skill is actually a reservation. + +## Hard rules + +No keys. No real numbers in this file (fixtures `+15555550100` only). No Builder edit. No legal advice. diff --git a/plugins/phonezero/skills/_template/brief.schema.json b/plugins/phonezero/skills/_template/brief.schema.json new file mode 100644 index 0000000..c9f8923 --- /dev/null +++ b/plugins/phonezero/skills/_template/brief.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "custom phone skill task brief", + "type": "object", + "required": [ + "kind", + "skill", + "spoken_name", + "callee", + "callback", + "goal", + "opener", + "constraints", + "success", + "voicemail", + "playbook", + "facts" + ], + "properties": { + "kind": { "const": "phonezero-task" }, + "skill": { "type": "string" }, + "spoken_name": { "type": "string" }, + "disclose_ai": { "type": "boolean" }, + "callee": { + "type": "object", + "required": ["name", "phone"], + "properties": { + "name": { "type": "string" }, + "phone": { "type": "string" } + } + }, + "callback": { "type": "string" }, + "goal": { "type": "string" }, + "opener": { "type": "string" }, + "constraints": { "type": "array", "items": { "type": "string" } }, + "success": { "type": "string" }, + "voicemail": { "type": "string" }, + "playbook": { "type": "string" }, + "facts": { "type": "object" } + } +} diff --git a/plugins/phonezero/skills/_template/references/voice-playbook.md b/plugins/phonezero/skills/_template/references/voice-playbook.md new file mode 100644 index 0000000..97071ee --- /dev/null +++ b/plugins/phonezero/skills/_template/references/voice-playbook.md @@ -0,0 +1,10 @@ +# Voice playbook (chat agent) + +Keep the uploaded JSON fields short. + +- **goal:** One sentence. +- **opener:** The ask after “calling on a recorded line.” No extra reservation language unless this skill is a reservation. +- **constraints:** What the agent may accept without calling the user back. +- **success:** What a live person must confirm on the read-back. +- **voicemail:** One message. Include spoken name and callback if you do not rely on the interpreter to add them. +- **playbook:** Ordered steps. A few clauses, not a novel. diff --git a/plugins/phonezero/skills/book-restaurant/SKILL.md b/plugins/phonezero/skills/book-restaurant/SKILL.md new file mode 100644 index 0000000..eb8e880 --- /dev/null +++ b/plugins/phonezero/skills/book-restaurant/SKILL.md @@ -0,0 +1,156 @@ +--- +name: book-restaurant +description: Book a restaurant table by phone when no online reservation exists. Invoke for restaurant reservations, calling a business to book a table, /book-table, /book-restaurant, or delegating a dining phone call (OpenTable/Resy unavailable, restaurant takes reservations by phone only). Do not invoke for SMS, email booking, bulk calling, or non-dining phone tasks. +--- + +# Book restaurant + +Collect a reservation, try online booking first (unless they skip it), show a call plan, then hand off to [`phonezero-runtime`](../phonezero-runtime/SKILL.md) to dial. The voice agent loads a `phonezero-task` whose reservation wording lives in **this brief**, not in the Builder prompt. + +Read `phonezero-runtime` for Setup, preconditions, plan-first, hours hard cap, dial/poll/STT, shared outcomes, and artifacts. Do not duplicate the Telnyx loop here. + +## Optionality (chat overrides, not Configure fields) + +| Knob | Default | Override in chat | +|---|---|---| +| Online first (OpenTable/Resy/restaurant site/Google Reserve) | on | “skip online / phone only” | +| Restaurant-local hours when hours unknown | 10:30–20:30 restaurant-local (user TZ if unknown) | user-stated hours | +| Window if they said “around 7” | propose preferred ± 30–60 min + ranked in-window slots | user-supplied window | +| Calendar offer after `succeeded` | offer ~90 minutes | skip | +| Spoken name | PhoneZero | per-call (`spoken_name` in the brief) | + +## Collect + +Do not dial until every required field is known. Fail closed after one clarifying turn if the task stays vague (no restaurant, no day, "sometime," "a place downtown"). + +| Field | Rules | +|---|---| +| Restaurant name | As the host will recognize it. | +| Restaurant phone | E.164. If you only have a name, look the number up, show it, and get confirmation. Reject numbers whose country is not in the Telnyx **PhoneZero US-only** `whitelisted_destinations`. `+1` covers Canada and Caribbean NANP too — confirm the actual country, ask if unsure, refuse on no. | +| Date | Concrete calendar date. | +| Preferred time | The first ask. | +| Window start–end | Inclusive acceptable range on that date. Concatenate into `{window}` (e.g. `6:30 PM to 8:00 PM`). | +| Ranked alternates | Ordered fallback times the agent may accept without asking you. | +| Party size | Integer ≥ 1. | +| Booking name | Name on the reservation. | +| Callback phone | E.164 the agent leaves on voicemail. Default to the user's phone; confirm it. | + +Optional: special requests (high-top, allergies, stroller). Pass through; do not invent. Optional spoken name for this call. + +**Window and alternates.** Collect start and end, then concatenate into `{window}`. If the user said "around 7" and did not give a window, propose the default (± 30–60 minutes) and ranked in-window slots. Confirm that proposal in the call plan — do not silently widen it. + +**Calendar.** If this Bot can read the user's calendar, compute alternates as times inside the window that do not conflict (travel buffer ~30 minutes). Rank: preferred first, then nearest free in-window slots. If a backup day is free and they allowed it, list it as a lower-rank alternate and say so in the plan. + +Hold in conversation memory: restaurant, E.164, date, preferred time, `{window}`, ranked alternates, party, booking name, callback, special requests, spoken name, `attempts`, prior `call_sid`s, last outcome. + +## Online first + +Before any call plan, unless they said phone-only: try to book in the Bot's own browser (OpenTable, Resy, the restaurant's site, Google Reserve). Same date, time, party, name. + +- Online path succeeds: report the confirmation. Offer calendar. **Do not call.** +- Online path needs the user (login, payment, captcha): hand it off in chat. **Do not call** unless they explicitly want the phone path. +- Call only when there is no working online path, or they skipped online. + +## Hours (in addition to runtime hard cap) + +Runtime already blocks outside **09:00–21:00 user-local**. Also: + +- Known restaurant hours: only while open, and still inside the hard cap. +- Unknown restaurant hours: **10:30–20:30 restaurant-local** (user timezone if restaurant TZ is unknown). +- Never call a time you know the restaurant is closed. +- Override to call outside restaurant hours but still inside 09:00–21:00 user-local = a **new plan** and a fresh yes. + +Owner setup-test to their own confirmed number: follow runtime exception (skip restaurant-hours and the hard cap). Never for calling a restaurant. + +## Call plan + +``` +Call plan +- Who: {restaurant_name} +- Number: {restaurant_phone} +- Ask: party of {n} on {date} at {time}, under {booking_name} +- Window: {window} +- Alternates the agent may accept (in order): {alternates} +- Special requests: {special_requests or "none"} +- Spoken as: {agent_name} +- From: {PHONEZERO_FROM_NUMBER} +- Callback if they miss us: {callback_phone} +- Attempt: {attempts + 1} of 2 +``` + +Dial only on an explicit yes. Then build the brief and hand to runtime §5–§10. + +## Task brief + +`put_task` with `skill: book-restaurant` (or `put_booking` with the legacy object — the MCP wraps it). + +```json +{ + "kind": "phonezero-task", + "skill": "book-restaurant", + "spoken_name": "{agent_name}", + "disclose_ai": true, + "callee": { "name": "{restaurant_name}", "phone": "{restaurant_phone}" }, + "callback": "{callback_phone}", + "goal": "Book the table within the window.", + "opener": "I'd like to make a reservation for a party of {n} on {date} at {preferred_time}. Do you have availability?", + "constraints": [ + "Accept only the preferred time, ranked alternates, or a host offer inside the window.", + "Never invent a time." + ], + "success": "Live host confirms read-back of party, date, agreed time, and booking name.", + "voicemail": "This is {spoken_name} calling for {booking_name} about a reservation for {n} on {date} at {preferred_time}. Please call {callback}. Thank you.", + "playbook": "Ask preferred first; then alternates in order; then in-window host offers. Mention special requests only after a time is under discussion.", + "facts": { + "party": 2, + "date": "{date}", + "preferred_time": "{time}", + "window": "{window}", + "alternates": ["{alt1}", "{alt2}"], + "booking_name": "{booking_name}", + "special_requests": "none" + } +} +``` + +See [references/voice-playbook.md](references/voice-playbook.md) if you need a longer chat-side reminder. Keep the uploaded `playbook` short. + +TeXML `` uses `{restaurant_phone}` (same as `callee.phone`). + +## Classify (on top of runtime §8) + +Runtime states: `succeeded | unavailable | no_answer | needs_user | unknown | failed`. + +In chat you may say **`booked`** when the outcome is `succeeded`. Personas may still use `booked`. + +`succeeded` / `booked` extra gates (all required, in addition to runtime): + +1. Agent read back party, date, time, and booking name; host turn confirmed. +2. Confirmed time is inside `{window}` or ranked `alternates`. +3. Uploaded facts had restaurant, party, date, preferred time, window, and booking name. + +Identify the agent channel by “calling on a recorded line” and/or “I'd like to make a reservation”. + +| Situation | Outcome | +|---|---| +| Host full / no times in window or alternates | `unavailable` | +| Host offered a time **outside** window/alternates | `needs_user` | +| Wrong number, not a restaurant | `needs_user` | + +## Counter-offers + +1. In-window / pre-briefed alternates — agent accepts; you report `booked` (`succeeded`) if host confirmed the read-back. +2. Out-of-window — agent must not accept. Report `needs_user`. If they accept the offer, new brief (narrow ask: lock the held time) and runtime again. +3. Live calendar mid-call — not available. + +## Report + +- `booked` / `succeeded` — restaurant, date, time, party, name, host notes. Offer a calendar event (title, start, duration ~90 minutes unless they say otherwise, location, phone, party). Only write the calendar if they want it, or skip if they declined the knob. +- Other states: runtime report wording. Do not claim a table. + +## Hard rules + +- No call without runtime §1, a complete collect, an online attempt (unless skipped), restaurant + runtime hours checks, and an explicit yes. +- No `booked` unless runtime §8 and this skill’s extra gates hold. +- One restaurant, one task, max two attempts, 20 minutes apart. +- Never edit the Builder prompt. Reservation language belongs in this brief. diff --git a/plugins/phonezero/skills/book-restaurant/brief.schema.json b/plugins/phonezero/skills/book-restaurant/brief.schema.json new file mode 100644 index 0000000..12077cd --- /dev/null +++ b/plugins/phonezero/skills/book-restaurant/brief.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "book-restaurant task brief", + "type": "object", + "required": [ + "kind", + "skill", + "spoken_name", + "callee", + "callback", + "goal", + "opener", + "constraints", + "success", + "voicemail", + "playbook", + "facts" + ], + "properties": { + "kind": { "const": "phonezero-task" }, + "skill": { "const": "book-restaurant" }, + "spoken_name": { "type": "string" }, + "disclose_ai": { "type": "boolean" }, + "callee": { + "type": "object", + "required": ["name", "phone"], + "properties": { + "name": { "type": "string" }, + "phone": { "type": "string", "description": "E.164" } + } + }, + "callback": { "type": "string" }, + "goal": { "type": "string" }, + "opener": { "type": "string" }, + "constraints": { "type": "array", "items": { "type": "string" } }, + "success": { "type": "string" }, + "voicemail": { "type": "string" }, + "playbook": { "type": "string" }, + "facts": { + "type": "object", + "required": [ + "party", + "date", + "preferred_time", + "window", + "alternates", + "booking_name" + ], + "properties": { + "party": { "type": "integer", "minimum": 1 }, + "date": { "type": "string" }, + "preferred_time": { "type": "string" }, + "window": { "type": "string" }, + "alternates": { "type": "array", "items": { "type": "string" } }, + "booking_name": { "type": "string" }, + "special_requests": { "type": "string" } + } + } + } +} diff --git a/plugins/phonezero/skills/book-restaurant/personas.md b/plugins/phonezero/skills/book-restaurant/personas.md new file mode 100644 index 0000000..3dcd354 --- /dev/null +++ b/plugins/phonezero/skills/book-restaurant/personas.md @@ -0,0 +1,3 @@ +# Book-restaurant personas + +Restaurant host scripts live in [`docs/PERSONAS.md`](../../../../docs/PERSONAS.md). After any change to `prompts/voice-agent.md` or this skill’s classify gates, re-run those scenarios. Outcome `booked` is an alias of runtime `succeeded`. diff --git a/plugins/phonezero/skills/book-restaurant/references/voice-playbook.md b/plugins/phonezero/skills/book-restaurant/references/voice-playbook.md new file mode 100644 index 0000000..8c787ed --- /dev/null +++ b/plugins/phonezero/skills/book-restaurant/references/voice-playbook.md @@ -0,0 +1,10 @@ +# Book-restaurant voice playbook (chat agent) + +Copy the short versions into `phonezero-task.json`. Do not paste this whole file into the collection. + +- **goal:** Book the table within the window. +- **opener:** I'd like to make a reservation for a party of {n} on {date} at {preferred_time}. Do you have availability? +- **constraints:** Preferred time, then ranked alternates, then a host offer inside {window}. Never invent a time. +- **success:** Live host confirms read-back of party, date, agreed time, and booking name. +- **voicemail:** {spoken_name} calling for {booking_name} about a reservation for {n} on {date} at {preferred_time}. Please call {callback}. +- **playbook:** Ask preferred first; then alternates; then in-window host offers. Special requests only after a time is under discussion. diff --git a/plugins/phonezero/skills/confirm-business-hours/SKILL.md b/plugins/phonezero/skills/confirm-business-hours/SKILL.md new file mode 100644 index 0000000..4076b4c --- /dev/null +++ b/plugins/phonezero/skills/confirm-business-hours/SKILL.md @@ -0,0 +1,79 @@ +--- +name: confirm-business-hours +description: Call a business to confirm opening hours. Invoke for /confirm-business-hours, “are they open Saturday”, “confirm hours by phone”, or checking whether today’s hours differ. Do not invoke for restaurant reservations (use book-restaurant) or SMS. +--- + +# Confirm business hours + +Collect who to call and what we need confirmed. No OpenTable step. Hand off to [`phonezero-runtime`](../phonezero-runtime/SKILL.md) to dial. Hours guard is the runtime **09:00–21:00 user-local** cap only (plus owner setup-test exception). + +## Collect + +| Field | Rules | +|---|---| +| Business name | As they will recognize it. | +| Business phone | E.164. Look up if needed, show it, get confirmation. Country must be on the Telnyx **PhoneZero US-only** whitelist. | +| Callback | E.164. Default to the user's phone; confirm it. | +| Expected hours (optional) | What we think the hours are, or a specific question (“open Saturday?”, “today’s holiday hours?”). | + +Spoken name defaults to PhoneZero; override per call. Fail closed if there is no business or no number after one clarifying turn. + +## Call plan + +``` +Call plan +- Who: {business_name} +- Number: {business_phone} +- Ask: confirm opening hours{optional expected / Saturday / today} +- Spoken as: {agent_name} +- From: {PHONEZERO_FROM_NUMBER} +- Callback if they miss us: {callback_phone} +- Attempt: {attempts + 1} of 2 +``` + +Dial only on an explicit yes. Then `put_task` and runtime §5–§10. + +## Task brief + +```json +{ + "kind": "phonezero-task", + "skill": "confirm-business-hours", + "spoken_name": "{agent_name}", + "disclose_ai": true, + "callee": { "name": "{business_name}", "phone": "{business_phone}" }, + "callback": "{callback_phone}", + "goal": "Get a live person to state the opening hours (and whether they differ today, if asked).", + "opener": "I'm calling to confirm your opening hours{optional: especially {focus}}.", + "constraints": [ + "Do not invent hours.", + "Accept only hours a live person states on this call." + ], + "success": "Live person states the opening hours (and answers the optional focus). Read back the hours they stated and get a yes.", + "voicemail": "This is {spoken_name} calling to confirm {business_name} hours. Please call {callback}. Thank you.", + "playbook": "Ask for hours. If a focus was briefed, ask that next. Read back the hours they stated. Do not guess.", + "facts": { + "expected_hours": "{optional or none}", + "focus": "{optional e.g. Saturday or today}" + } +} +``` + +Do not put reservation language in `opener` or `voicemail`. If the voice agent still says “reservation”, the Builder has the old prompt — stop and re-paste `prompts/voice-agent.md`. + +## Classify + +Runtime §8. Identify the agent channel by “calling on a recorded line” and the hours opener (not “I'd like to make a reservation”). + +`succeeded` only if a live person on the non-agent channel stated hours and confirmed the read-back. Report the hours string exactly. Do not invent. + +| Situation | Outcome | +|---|---| +| They refuse to give hours / cannot help | `unavailable` | +| Voicemail only after two attempts | `no_answer` | +| Wrong number | `needs_user` | + +## Report + +- `succeeded` — business name, the hours they stated, any “today differs” note. No calendar unless they ask. +- Other states: runtime wording. Do not claim hours you did not hear. diff --git a/plugins/phonezero/skills/confirm-business-hours/brief.schema.json b/plugins/phonezero/skills/confirm-business-hours/brief.schema.json new file mode 100644 index 0000000..57c6d63 --- /dev/null +++ b/plugins/phonezero/skills/confirm-business-hours/brief.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "confirm-business-hours task brief", + "type": "object", + "required": [ + "kind", + "skill", + "spoken_name", + "callee", + "callback", + "goal", + "opener", + "constraints", + "success", + "voicemail", + "playbook", + "facts" + ], + "properties": { + "kind": { "const": "phonezero-task" }, + "skill": { "const": "confirm-business-hours" }, + "spoken_name": { "type": "string" }, + "disclose_ai": { "type": "boolean" }, + "callee": { + "type": "object", + "required": ["name", "phone"], + "properties": { + "name": { "type": "string" }, + "phone": { "type": "string" } + } + }, + "callback": { "type": "string" }, + "goal": { "type": "string" }, + "opener": { "type": "string" }, + "constraints": { "type": "array", "items": { "type": "string" } }, + "success": { "type": "string" }, + "voicemail": { "type": "string" }, + "playbook": { "type": "string" }, + "facts": { + "type": "object", + "properties": { + "expected_hours": { "type": "string" }, + "focus": { "type": "string" } + } + } + } +} diff --git a/plugins/phonezero/skills/confirm-business-hours/personas.md b/plugins/phonezero/skills/confirm-business-hours/personas.md new file mode 100644 index 0000000..6e32540 --- /dev/null +++ b/plugins/phonezero/skills/confirm-business-hours/personas.md @@ -0,0 +1,31 @@ +# Confirm-business-hours personas + +Fixtures only (`+15555550100` / `+15555550199`). Re-paste the interpreter prompt before these runs. Outcome is runtime `succeeded` (hours stated + read-back yes), never restaurant `booked` gates. + +Shared brief facts: business **Harbor Hardware**, callback `+15555550199`, spoken name PhoneZero, disclose on. Callee fixture `+15555550100`. + +## 1. Stated hours, confirms read-back + +**Host.** Pick up: “Harbor Hardware.” After the opener, “We’re nine to six weekdays, ten to four Saturday, closed Sunday.” When they read that back, “Yes that’s right.” + +**Expected.** Hours opener (no reservation language). Read-back of those hours. Goodbye. `end_call`. + +**Outcome.** `succeeded` + +**Transcript must contain.** Host hours and a yes to the read-back. Must **not** contain “I'd like to make a reservation”. + +## 2. Voicemail + +**Host.** Mailbox only: “You’ve reached Harbor Hardware, leave a message” + beep. + +**Expected.** One voicemail about confirming hours + callback. `end_call`. + +**Outcome.** Not terminal on attempt 1. After attempt 2: `no_answer`. Never `succeeded`. + +## 3. Closed / cannot help + +**Host.** “We don’t give hours out over the phone.” or “Wrong number.” + +**Expected.** Brief goodbye. No invented hours. + +**Outcome.** `unavailable` (refuse) or `needs_user` (wrong number). diff --git a/plugins/phonezero/skills/confirm-business-hours/references/voice-playbook.md b/plugins/phonezero/skills/confirm-business-hours/references/voice-playbook.md new file mode 100644 index 0000000..d6a82b0 --- /dev/null +++ b/plugins/phonezero/skills/confirm-business-hours/references/voice-playbook.md @@ -0,0 +1,8 @@ +# Confirm-hours voice playbook (chat agent) + +- **goal:** Get a live person to state opening hours (and whether they differ today, if asked). +- **opener:** I'm calling to confirm your opening hours{optional focus}. +- **constraints:** Do not invent hours. Accept only hours a live person states. +- **success:** They state the hours; you read them back; they confirm. +- **voicemail:** {spoken_name} calling to confirm {business} hours. Please call {callback}. +- **playbook:** Ask for hours; ask the optional focus; read back; do not guess. diff --git a/plugins/phonezero/skills/phonezero-runtime/SKILL.md b/plugins/phonezero/skills/phonezero-runtime/SKILL.md new file mode 100644 index 0000000..8f1f966 --- /dev/null +++ b/plugins/phonezero/skills/phonezero-runtime/SKILL.md @@ -0,0 +1,344 @@ +--- +name: phonezero-runtime +description: PhoneZero call runtime — setup, plan-first dial, Telnyx poll, xAI STT, shared outcomes, Grok ad-hoc interview. Invoke for /setup-phone-calling, any PhoneZero outbound call, or when a phone skill hands off to dial. Do not invent a new Builder prompt. +--- + +# PhoneZero runtime + +Shared outbound loop for every phone skill. First-party skills (`book-restaurant`, `confirm-business-hours`) and Cursor-local skills collect a task, then **hand off here** to dial. Grok Bot: if no shipped skill matches, interview into a `phonezero-task` (this file, Ad-hoc section). Do not ask Grok users to paste a `SKILL.md` or write `~/.cursor/skills`. + +Destinations must be in the Telnyx outbound voice profile **PhoneZero US-only** `whitelisted_destinations` (ISO 3166-1 alpha-2). Read that list via Telnyx MCP — it is **not** a PhoneZero plugin variable. Never place a call to a country not on that list. Never place bulk or multi-destination calls. Never auto-dial. + +**Defaults (not compliance advice).** Destinations default `US` on a new Telnyx profile. Disclose defaults on (per-task `disclose_ai`, and the one-time Builder `{disclosure_clause}`). Every call is recorded (dual-channel); the opener says so. Spoken name defaults to PhoneZero per task. Task JSON is English; after pickup the voice agent speaks the answerer’s language. The person using this sample is solely responsible for complying with all applicable laws and vendor terms — see repo [DISCLAIMER.md](../../../../DISCLAIMER.md). Do not give legal guidance in chat. + +**Bootstrap from repo.** `AGENTS.md` at the repo root is the hook. **Grok Bot** (pointed at `https://github.com/function1st/PhoneZero` or “set this up”): execute **Grok Bot — set this up** in `AGENTS.md` first — do not + Add the Cursor plugin. **Cursor IDE:** this file’s Setup, after the Cursor package is installed. After install or update, start a **new** conversation. + +**Builder prompt is static.** Paste [`prompts/voice-agent.md`](../../prompts/voice-agent.md) as the system prompt (`{disclosure_clause}` is the only substitution). Paste [`prompts/end_call.md`](../../prompts/end_call.md) as the `end_call` tool description. Brief each call with `phonezero-task.json`. Never edit the Builder per call. Existing agents that still search `phonezero-booking.json` must be **re-pasted** (both files) before custom skills will speak correctly. If testers share a production DID, say so before they paste. + +## 1. Preconditions + +Before collecting a task or touching Telnyx, verify these variables are present. Read them; do not echo secrets. + +| Variable | Role | +|---|---| +| `TELNYX_API_KEY` | Cursor Configure → stdio `npx @telnyx/mcp`. Grok canary (`plugins/phonezero-grok`) → `Authorization: Bearer` on `https://api.telnyx.com/v2/mcp`. Never in chat. **The check is a successful `tools/call`** (e.g. `list_api_endpoints`) — NOT the tool list. A `401` / 10009 means the key is not wired. | +| `PHONEZERO_FROM_NUMBER` | Plugin Configure card → PhoneZero xAI MCP `get_call_config` (and the Telnyx `From`). Cursor does **not** put this in the agent shell. E.164 DID. Call-create `To`: `sip:{PHONEZERO_FROM_NUMBER}@sip.voice.x.ai;transport=tls`. If `get_call_config` has no From, take the DID attached to the PhoneZero TeXML app from Telnyx `list_phone_numbers`. | +| `XAI_API_KEY` | Cursor Configure → PhoneZero xAI MCP. Grok Bot: add/use that same xAI MCP (`put_task` / `put_booking` / `transcribe`); REST Bearer on `https://api.x.ai/v1` and `/v2` is fallback only. Never in chat. Never `source ~/.phonezero/env`. Must be from a team with **ZDR off**. Check: `get_call_config.xai_key_wired` then `ensure_collection`. | +| `TELNYX_ACCOUNT_SID` | **Not on the Configure card.** Telnyx MCP has **no** `whoami` tool. `invoke_api_endpoint` `list_billing_groups` → first `data[].organization_id`. Developer curl `GET /v2/whoami` is the same value. TeXML paths: `/v2/texml/Accounts/{TELNYX_ACCOUNT_SID}/…`. | +| `PHONEZERO_TEXML_APP_ID` | **Not on the Configure card.** Resolve with Telnyx MCP: list TeXML apps, use the one named `PhoneZero`. | +| `PHONEZERO_XAI_COLLECTION_ID` | **Not on the Configure card.** Find-or-create collection name `PhoneZero bookings`. Attach it to the Builder agent (knowledge / file search). | +| Spoken name / disclose | **Not on the Configure card.** Per-task: `spoken_name` and `disclose_ai` in `phonezero-task.json`. Default PhoneZero / true if they do not say otherwise. `{disclosure_clause}` is substituted once when pasting the Builder prompt. | + +Call-time required: working Telnyx MCP, working PhoneZero xAI MCP, a From number from `get_call_config` or Telnyx list. Resolve `TELNYX_ACCOUNT_SID` and `PHONEZERO_TEXML_APP_ID` **before** the call plan — not after `put_task`. SID: `invoke_api_endpoint` `list_billing_groups` `{ "jq_filter": "[.data[].organization_id] | unique" }`. TeXML id: `invoke_api_endpoint` `list_texml_applications` `{ "filter": { "friendly_name": "PhoneZero" }, "jq_filter": ".data[] | {id, friendly_name}" }`. Destinations: `invoke_api_endpoint` `list_outbound_voice_profiles` `{ "filter": { "name": { "contains": "PhoneZero" } }, "jq_filter": ".data[] | select(.name==\"PhoneZero US-only\") | {id, name, whitelisted_destinations}" }`. Spoken name / disclose come from this call’s collect (defaults PhoneZero / true). Collection via xAI MCP `ensure_collection`. + +If Telnyx MCP or xAI MCP is missing / 401s, or From cannot be resolved: **stop. Do not dial.** Tell the user to re-save Plugins → Configure and start a **new** conversation, then `/setup-phone-calling`. Never paste keys in chat. Never `source ~/.phonezero/env`. An old chat not seeing new MCP tools is not a failure. + +Do not invent `{TELNYX_ACCOUNT_SID}`. + +## Setup (when the user says *Set up phone calling* or runs `/setup-phone-calling`) + +Human/developer mirrors: `docs/SETUP.md`, and `scripts/provision.sh` — developer-only, run on a personal machine that may hold keys, never on this computer. Never `source ~/.phonezero/env`. Telnyx account + KYC + buying the DID stay manual. Telnyx API steps go through the Telnyx MCP. xAI Files / collections / STT / phone-numbers go through the PhoneZero xAI MCP on **both** hosts. **Grok Bot:** add that stdio `xai` server if `put_task` / `put_booking` is missing (`AGENTS.md` / README). REST on `api.x.ai` is fallback only. Do not take the key from chat. **Grok must ask** spoken name and AI disclaimer ON/OFF — do not silently keep PhoneZero / true. Destination countries are the Telnyx profile whitelist; read and show them, do not ask as a PhoneZero field. + +**First message, before any API call:** + +``` +Before we continue, you need: +- Telnyx account + KYC done +- One US number bought (that is From) +- Telnyx API key +- xAI team with Zero Data Retention (ZDR) OFF +- xAI API key from that same team +- Access to Voice Agent Builder at console.x.ai + +Missing any of these? Stop and get them. Links: telnyx.com · console.x.ai · docs.x.ai/developers/faq/security +Then we fill the rest. +``` + +If any of those are missing, **stop**. + +**Connect the Telnyx MCP.** It is not OAuth — do not click Authenticate. + +- Cursor IDE: this package’s `telnyx` is stdio `npx @telnyx/mcp` with Configure `TELNYX_API_KEY`. Do not add hosted-HTTP Telnyx here (SSE GET 404 tombstone). +- Grok Bot: stop and run `AGENTS.md` **Grok Bot — set this up**. Custom HTTP MCP (`https://api.telnyx.com/v2/mcp` + Bearer in the form). Not Cursor + Add. Not Authenticate. +- Fallback (no plugin, Cursor IDE): `"telnyx": {"command":"npx","args":["-y","@telnyx/mcp"],"env":{"TELNYX_API_KEY":"${env:TELNYX_API_KEY}"}}`. Never the literal key in the file or chat. +- **Verify with a `tools/call`, never the tool count.** Call `list_api_endpoints`. If `get_call_config.from_wired` is false, take From from Telnyx `list_phone_numbers` (PhoneZero TeXML DID). + +**Keys first.** Required on the Configure card: `TELNYX_API_KEY`, `PHONEZERO_FROM_NUMBER`, `XAI_API_KEY`. Spoken name and disclose are **not** on the card — ask in chat (Grok: the `AGENTS.md` call-settings card; Cursor: per task, default PhoneZero / true) and write them into `phonezero-task.json`. Destinations are **not** on the card. Read destinations from Telnyx (`list_outbound_voice_profiles` → `PhoneZero US-only` → `whitelisted_destinations`) and show them. PATCH that profile only if they ask to add/remove countries (Mission Control → Voice → Outbound voice profiles is the same setting). Account SID, TeXML app id, and collection id are **not** on the card. If Telnyx MCP or the PhoneZero xAI MCP is unwired, send the user to Plugins → Configure and a **new** conversation. Then, field-for-field: + +1. `TELNYX_ACCOUNT_SID` via Telnyx MCP `invoke_api_endpoint` `list_billing_groups` (`data[].organization_id`). Do not search the catalog for `whoami`. +2. Find-or-create outbound voice profile name `PhoneZero US-only`: `traffic_type=conversational`, `service_plan=global`, `usage_payment_method=rate-deck`, `whitelisted_destinations` default `["US"]` **on create only**, `daily_spend_limit="5.00"`, `daily_spend_limit_enabled=true` (`POST /v2/outbound_voice_profiles`; any other combo → Telnyx error 10015). **This Telnyx profile is the destination enforcement** — Telnyx rejects calls outside `whitelisted_destinations`. If the profile already exists, keep its current whitelist. `PATCH` only when the user asks to add or remove countries (e.g. add `JP`). Change the same list in Telnyx Mission Control → Voice → Outbound voice profiles. +3. Find-or-create TeXML app name `PhoneZero`: `voice_url` = the public raw URL of `texml/inbound.xml` (default `https://raw.githubusercontent.com/function1st/PhoneZero/main/texml/inbound.xml`; verify it returns HTTP 200 before writing it), `voice_method=get`, `outbound.outbound_voice_profile_id` = that profile. +4. `PATCH /v2/phone_numbers/{phone_number_id}` `{"connection_id":""}`. +5. Register the DID with xAI (idempotent: `GET https://api.x.ai/v2/phone-numbers` first): `POST https://api.x.ai/v2/phone-numbers` `{"name":"PhoneZero","phoneNumber":"{PHONEZERO_FROM_NUMBER}","origin":"byo_trunk"}`. +6. PhoneZero xAI MCP `ensure_collection` (name `PhoneZero bookings`). A 403 mentioning Zero Data Retention means **stop** — the key's team has ZDR on. There is no sub-team override; disable ZDR or create a sibling team and use that team's key. Keep the collection id in session. Then `register_byo_number` for the From DID if `list_phone_numbers` does not already show it as `byo_trunk`. +7. Voice Agent Builder **has no create API** (`/v1/agents` is not enabled). In this Bot's browser (or walk the human) at [https://console.x.ai](https://console.x.ai), on the **same ZDR-off team** as the key: + - Create one agent. Paste the **body** of `prompts/voice-agent.md` (it is the system prompt — no human preamble). Substitute `{disclosure_clause}` once: `, an automated assistant,` if they want disclose on (default), else empty. Do not substitute a spoken name. Save. Never add a per-call TASK BRIEF. Never edit the Builder prompt per call. + - **Welcome: on**, text exactly `PhoneZero is ready!` **Caller can interrupt: on.** That line is the session-start cue so the agent runs `collections_search` for `phonezero-task.json` during the TeXML pause, before the callee greets. Empty welcome delays the search until the callee speaks. Do not put task facts in the welcome. + - **Knowledge / file search:** attach `PhoneZero bookings`. Without this the agent invents the ask. + - **`end_call` tool: on.** Name exactly `end_call`. Description = the full contents of [`prompts/end_call.md`](../../prompts/end_call.md) (no extra words). The system prompt uses this tool after a spoken goodbye. Do not leave hang-up off. + - **Max duration:** at least 10 minutes if the console exposes it. + - Guardrails if shown: stay inside `constraints`, verbatim read-back of `success`, no invented confirmation. + - The wizard mints a **free xAI number — ignore it.** PhoneZero always bridges to `PHONEZERO_FROM_NUMBER`. Copy the `agentId`. + - **Re-paste** [`prompts/voice-agent.md`](../../prompts/voice-agent.md) and [`prompts/end_call.md`](../../prompts/end_call.md) if the agent was created with the old reservation-only prompt. Toggling Configure does not update a baked prompt. If this DID is also used in production, say so before pasting. +8. Attach the agent to the **registered Telnyx DID**, never the wizard's number. xAI MCP `list_phone_numbers` → find YOUR DID (`origin` `byo_trunk`) → `attach_agent` with that `phone_number_id` and the Builder `agentId`. The `agentId` is visible on the wizard's number row — copy it from there, then attach it onto the DID. +9. Keep `TELNYX_ACCOUNT_SID`, the TeXML app id, and the collection id in this session. Confirm last-4 of the From number in chat. **Do not** ask the user to paste those ids into Plugins → Configure. Do not invent SIDs. + +Approve each credentialed step. Never echo keys. + +## 2. Bind a phone skill, then collect + +Match the user ask: + +- Restaurant table / `/book-table` / `/book-restaurant` → read `skills/book-restaurant/SKILL.md` and collect there. +- Confirm hours / `/confirm-business-hours` → read `skills/confirm-business-hours/SKILL.md`. +- Cursor-local folder (`~/.phonezero/skills`, `~/.cursor/skills`, project `.phonezero/skills`) → `list_phone_skills` / `get_phone_skill` or read the folder. Follow that skill’s collect. +- Grok, no match → **Ad-hoc interview** below. Do not ask them to write a skill folder. + +Do not dial until the bound skill (or ad-hoc interview) has every required field. Fail closed after one clarifying turn if the task stays vague. + +## 3. Plan-first confirmation + +Present the plan in chat. Do not dial in the same turn as the plan. Use the skill’s plan template if it has one; otherwise: + +``` +Call plan +- Skill: {skill} +- Who: {callee.name} +- Number: {callee.phone} +- Goal: {goal} +- Opener ask: {opener} +- Constraints: {constraints} +- Success: {success} +- Spoken as: {agent_name} +- From: {PHONEZERO_FROM_NUMBER} +- Callback if they miss us: {callback} +- Attempt: {attempts + 1} of 2 +``` + +Dial **only** on an explicit yes to this plan ("yes", "go ahead", "call them"). Not implied consent, not "sounds good I guess," not a new unrelated message. If they edit the plan, re-show it and wait again. + +Never auto-dial. Never dial because a previous task was approved. Vague task → no call. + +## 4. Calling-hours guard and attempt cap + +`attempts` = completed dial attempts for this task. Starts at 0. The plan shows `Attempt {attempts + 1} of 2`. Block when `attempts >= 2`. + +Place a call only when **all** of these hold: + +1. **Destination country is in the Telnyx profile `whitelisted_destinations`** (already required; default `US` on a new profile). +2. **Hours — runtime hard cap plus skill policy:** + - Hard cap: **09:00–21:00 user-local**. Never dial outside it, override or not — except the owner setup-test exception. + - The bound skill may add a tighter window (restaurant-local hours). Honor the tighter of the two. + - Never call a time you know the business is closed. +3. **`attempts` < 2**. Maximum two completed dial attempts. Attempts are 20 minutes apart (wall clock). A confirmation callback after an out-of-constraint hold is a **new** plan (still needs a fresh yes) and does not count against the original task's two attempts unless it is a retry of the same unanswered ask. +4. The user has explicitly approved the **current** plan. + +If it is outside calling hours: state the next legal window. Do **not** wait-and-dial. When that time comes, **re-show the plan** and dial only on a fresh explicit yes. + +**Owner setup-test exception.** When the user has explicitly asked for a setup or plugin test call to **their own confirmed number** (`` is that E.164; the task JSON says this is a test and the callee is the owner): skip the 09:00–21:00 hard cap and any skill hours window. Still require the destination country to be on the Telnyx profile whitelist, a current plan, and an explicit yes. Still max two attempts. This is only for proving the dial path during development or first-time setup — never for calling a business. + +Voicemail / no-answer: leave the message (the voice agent does this), increment `attempts`, wait 20 minutes, re-check hours, **re-show the plan** (`Attempt {attempts + 1} of 2`, same ask), and dial only on a fresh yes. After two completed attempts still no human (`attempts >= 2`): outcome `no_answer`. Stop. + +## 5. Upload the task JSON, then dial + +The Builder prompt (`prompts/voice-agent.md`) is **fully static**. `{disclosure_clause}` was substituted once at agent creation. The spoken name is **not** in the prompt — it is in `phonezero-task.json`. **Never edit the Builder prompt per call. Never open the Builder console at call time.** + +Do **not** speak a Telnyx TTS brief. Build the `phonezero-task` object (skill or ad-hoc interview). Prefer xAI MCP `put_task`. `put_booking` still works: it wraps a `kind: phonezero-booking` object into `book-restaurant`. Wait until `DOCUMENT_STATUS_PROCESSED`, then dial. Replace any existing live `phonezero-task.json` / legacy `phonezero-booking.json`. **Never** overwrite or `delete_booking` a `phonezero-template-*.json`. + +Envelope (no extra top-level keys except `kind`): + +```json +{ + "kind": "phonezero-task", + "skill": "{skill or custom}", + "spoken_name": "{agent_name}", + "disclose_ai": true, + "callee": { "name": "{callee_name}", "phone": "{callee_e164}" }, + "callback": "{callback_phone}", + "goal": "{goal}", + "opener": "{opener}", + "constraints": ["{constraint}"], + "success": "{success}", + "voicemail": "{voicemail}", + "playbook": "{short playbook}", + "facts": {} +} +``` + +`disclose_ai` defaults true unless they turned it off for this call (or at setup, for the Builder paste). `{agent_name}` is this call's spoken name (default PhoneZero). Keep `playbook` short — collection search truncates. + +**Both hosts:** PhoneZero xAI MCP `put_task` (or `put_booking` alias). **Grok Bot:** if those tools are missing, add stdio `xai` from `plugins/phonezero/mcp.json` (secure-field env, not `${…}`) — do not say upload is impossible. Fallback: `https://api.x.ai/v1` Bearer; sequence is `putTask` / `putBooking` in `scripts/xai-mcp.mjs`. Never echo the key. If the user said xAI is already set up, do not open the Builder. + +SID and TeXML id must already be in session from Setup / `AGENTS.md`. After yes: `put_task` (wait processed) then dial immediately. Do not look up `whoami` between those two. + +### Dial — Telnyx hosted MCP + +Use the Telnyx hosted MCP (auth is the plugin bearer; you never pass the key). The server (`https://api.telnyx.com/v2/mcp`, serverInfo `telnyx_api` v3.0.0) exposes **three generic tools**, not per-operation names: `list_api_endpoints` → `get_api_endpoint_schema` → `invoke_api_endpoint`. Use these exact `endpoint_name` values. + +Place call: `invoke_api_endpoint` with `endpoint_name` `calls_accounts_texml_calls` (REST equivalent: `POST /v2/texml/Accounts/{TELNYX_ACCOUNT_SID}/Calls`). + +TeXML body fields are PascalCase (`To`, `From`, `Texml`, …). `account_sid` is the MCP path param, not a TeXML body key. + +JSON `args`: + +```json +{ + "account_sid": "{TELNYX_ACCOUNT_SID}", + "ApplicationSid": "{PHONEZERO_TEXML_APP_ID}", + "To": "sip:{PHONEZERO_FROM_NUMBER}@sip.voice.x.ai;transport=tls", + "From": "{PHONEZERO_FROM_NUMBER}", + "Texml": "{callee_phone}", + "Record": true, + "RecordingChannels": "dual", + "Timeout": 30, + "TimeLimit": 600 +} +``` + +Load [`texml/bridge.xml`](../../texml/bridge.xml), strip comments and newlines, replace `{RESTAURANT_E164}` with `{callee.phone}` and `{PHONEZERO_FROM_NUMBER}` with the DID (Dial `callerId`). That string is the `Texml` field. The same DID is also the request `To` (`sip:{PHONEZERO_FROM_NUMBER}@sip.voice.x.ai;transport=tls`). Do not send `Url` — the request schema is oneOf: `Url` XOR `Texml` XOR neither. Do not set `answerOnBridge`. Do not put `` in the Texml. + +**Schema lag:** `get_api_endpoint_schema` for `calls_accounts_texml_calls` does **not** list `Texml`. `invoke_api_endpoint` still passes `Texml` through. Omit `Url` and send `Texml`. + +- `To` is the agent SIP URI. The agent answers first. +- `From` is exactly `PHONEZERO_FROM_NUMBER` (E.164). +- `{callee.phone}` inside `` is E.164 only (destination country already confirmed on the Telnyx profile whitelist). +- Recording is call-level (`Record` true, `RecordingChannels` `dual`). Do not put `record` on ``. +- Do **not** send `MachineDetection`, `AsyncAmd`, or `SendDigits`. +- `Timeout` 30 is the ring timeout waiting for the agent `To`. Do not raise it. +- `TimeLimit` 600s is the per-call duration cap. Do not raise it. + +On success, store `sid` / `CallSid` as `call_sid`. On MCP/HTTP error: outcome `failed`. Do not retry in the same turn; tell the user what Telnyx returned (no secrets). + +## 6. Poll for completion + +Poll: `invoke_api_endpoint` with `endpoint_name` `retrieve_calls_accounts_texml_calls` and args `account_sid`, `call_sid`. The MCP schema for that name is a **list** (no `call_sid` field) and the list call can time out — still send `call_sid`; if the list hangs, treat a completed recording on `retrieve_recordings_json_calls_accounts_texml_recordings_json` as the terminal signal. Do not use `answered_by`. + +| Field | Action | +|---|---| +| `status` `ringing`, `in-progress` | Keep polling. | +| `status` `completed` | Go to recordings. | +| `status` `no-answer`, `busy` | Apply §4 retry; report `no_answer` only when `attempts >= 2`. | +| `status` `failed`, `canceled` | Outcome `failed` always. Never `succeeded` / `booked`. | + +Poll every 10s while live. **Call timeout:** 12 minutes from dial. If still live at 12 minutes, stop, try recordings once, and if nothing usable → `unknown` (never `succeeded`). + +## 7. Recording + transcription + +Telnyx does **not** transcribe Dial-verb recordings. xAI STT is the default outcome path. + +1. `invoke_api_endpoint` `retrieve_recordings_json_calls_accounts_texml_recordings_json` (not the write-named twin). Poll every 15s for up to 3 minutes after call end until a completed recording with `media_url` exists. If none: `unknown`. Never `succeeded`. +2. Download the dual-channel `media_url` promptly to `/tmp` (expires ~10 minutes). Do not paste the URL in chat. +3. PhoneZero xAI MCP `transcribe` (`file_path` = the temp download). If `XAI_API_KEY` is not set on the MCP: **stop**. Re-save Configure and start a new conversation. Never ask for the key in chat. Do not classify `succeeded`. + +Channel model: + +- Identify the **agent** channel by the opener ONLY ("calling on a recorded line"). The rest of the opener is skill-specific (`I'd like to make a reservation` / hours ask / ad-hoc `opener`). +- Live-person confirmation = a later turn on the non-agent channel, after the opener, that satisfies the skill’s `success`. +- A mailbox greeting / beep / "leave a message" is never confirmation. +- If `channels` is missing or the opener is not unique: outcome `unknown`, never `succeeded`. + +If STT fails or returns empty text: outcome `unknown`. Never `succeeded`. + +## 8. Extract the outcome (runtime shell) + +There is no spoken English recap. Classify from the conversation. + +Shared states (exactly one): `succeeded` | `unavailable` | `no_answer` | `needs_user` | `unknown` | `failed`. + +`booked` is an alias of `succeeded` for `book-restaurant` chat copy and personas. + +**`succeeded` (or `booked`) only if all of these are true:** + +1. Recording exists and xAI STT returned a transcript. +2. The agent channel was identified in §7. +3. A **live person** turn on the non-agent channel confirmed the read-back required by this brief’s `success` (not voicemail). +4. The confirmed facts stay inside this brief’s `constraints`. +5. `put_task` succeeded with a complete envelope. If that upload failed, never `succeeded`. + +Never invent a confirmation number or a fact that is not in the transcript. + +Map everything else: + +| Situation | Outcome | +|---|---| +| Live person cannot meet `success` inside `constraints` | `unavailable` | +| No human (ring/no-answer/busy) or voicemail only, after retries exhausted | `no_answer` | +| Voicemail on attempt 1 of 2 | not terminal — retry per §4 | +| Offer **outside** `constraints` | `needs_user` | +| Objected to recording or to an AI caller | `needs_user` | +| Wrong number, asked a human to call back | `needs_user` | +| Task JSON upload failed or was incomplete | `unknown` | +| Missing recording/transcript, no clear confirmation or refusal | `unknown` | +| Call abandoned mid-hold | `unknown` | +| Call `status` `failed` or `canceled` | `failed` always | +| MCP/dial/API failure | `failed` | + +The bound skill may add chat wording (`booked`, hours string) on top of this shell. It may not loosen the five `succeeded` gates. + +## 9. Artifacts + +After successful STT (and the outcome is classified / you are ready to report it): + +1. **Keep** the Telnyx recording. Do not `DELETE` it. Download the presigned `media_url` to `/tmp` only. Never copy recordings into the repo. +2. **Delete** the live xAI brief (`delete_booking` with the `put_task` ids). Do **not** delete `phonezero-template-*` files. +3. Discard the `/tmp` download after STT. + +Do not copy raw audio or full transcripts into chat. Quote only the live-person confirmation phrase you relied on. + +## 10. Report + +Report in chat, one state, concrete facts. The bound skill fills the details (`book-restaurant` may say “booked” and offer a ~90 minute calendar event). Runtime defaults: + +- `succeeded` — what `success` required, plus host notes. Offer calendar only if the skill says to. +- `unavailable` — what they said. +- `no_answer` — attempts used; offer a later retry as a new plan. +- `needs_user` — the exact offer or objection; ask what to do. +- `unknown` — why. Do not claim success. +- `failed` — what broke. Do not claim success. + +There is no mid-call relay. Out-of-constraint offers → `needs_user`, then a new plan if they accept. + +## Ad-hoc interview (Grok Bot — no matching skill) + +Do **not** ask them to paste a `SKILL.md`, clone a gist, or write a skill folder. + +1. Recognize an ad-hoc voice task (e.g. “call the clinic and ask if they take new patients”). +2. Interview, fail closed. One clarifying turn if vague; stop if they still have no callee or no success condition. +3. Fill a `phonezero-task` in chat (`skill`: `custom`, or a slug they later save). `playbook` is a short ordered list. + +| Ask | Lands in | +|---|---| +| Who are we calling? (name + confirm E.164) | `callee` | +| Callback if they miss us | `callback` | +| What should the voice agent try to get done? | `goal` | +| First sentence after hello (meaning, not a script to recite in every language) | `opener` | +| What may it accept without calling you back? | `constraints` | +| What must a **live person** say before we call this a win? | `success` | +| What to leave on voicemail | `voicemail` | +| Any extra facts | `facts` | +| Spoken name override | `spoken_name` | + +4. Same safety as shipped skills. Show the JSON in the call plan. Dial only on yes. Classify with §8 + this brief’s `success` / `constraints`. +5. Do not open the Builder. The JSON is the shape of the call. + +### Save as a template (only if they ask) + +Save the **shape** (interview questions + `goal` / `opener` / `constraints` / `success` / `voicemail` / `facts` keys), not this call’s callee/date unless they say freeze those too. Pick a store and tell them where it went. No new Configure field. + +1. **This chat only** if they did not ask to save. +2. **Grok persistent memory** if the host has it. Named card: slug, when to use, interview list, brief defaults. +3. **xAI collection** if memory is missing or they want a list/get next session: `put_template` → `phonezero-template-{slug}.json` in **PhoneZero bookings**. Never write over `phonezero-task.json`. Never `delete_booking` a template. Later: `list_templates` / `get_template`. +4. **Show the JSON in chat** if 2 and 3 are unavailable. Do not invent Drive/gist/home-dir writes on Grok. + +Next session: search memory then templates, then interview only instance fields. Still show a plan and wait for yes. + +Cursor may save a template as a local skill folder when they ask. That is not the Grok path. + +## Hard rules + +- No call without §1 call-time variables, a complete collect (skill or ad-hoc), an hours check, and an explicit yes to the current plan. +- No `succeeded` / `booked` unless all five gates in §8 hold. +- No secrets, no keys in chat, no non-fixture numbers written into skills or examples. +- One callee, one task, max two attempts, 20 minutes apart. +- Never edit the Builder prompt after setup. Brief each call with `phonezero-task.json`, not TeXML `` and not the Builder console. +- Never ask a Grok user to write `~/.cursor/skills` or paste a `SKILL.md` as the way to add a scenario. diff --git a/plugins/phonezero/skills/phonezero/SKILL.md b/plugins/phonezero/skills/phonezero/SKILL.md index ff17bc4..ee262f1 100644 --- a/plugins/phonezero/skills/phonezero/SKILL.md +++ b/plugins/phonezero/skills/phonezero/SKILL.md @@ -1,340 +1,13 @@ --- name: phonezero -description: Book a restaurant table by phone when no online reservation exists. Invoke for restaurant reservations, calling a business to book a table, or delegating a dining phone call (OpenTable/Resy unavailable, restaurant takes reservations by phone only). Do not invoke for SMS, email booking, or bulk/multi-restaurant calling. +description: Deprecated alias. Use phonezero-runtime for setup and dial, book-restaurant for table reservations. Invoke only when an old chat still names this skill. --- -# PhoneZero +# PhoneZero (moved) -Grok Bot skill. You collect a reservation task, try online booking first, confirm a call plan in chat, then (only on explicit yes) place one outbound call through the Telnyx hosted MCP. An xAI voice agent talks to the restaurant. You poll Telnyx for the dual-channel recording, transcribe it with xAI STT, extract the outcome, delete the booking JSON file, and report in chat. Keep the Telnyx recording. +This skill was split. -Destinations must be in the Telnyx outbound voice profile **PhoneZero US-only** `whitelisted_destinations` (ISO 3166-1 alpha-2). Read that list via Telnyx MCP — it is **not** a PhoneZero plugin variable. Never place a call to a country not on that list. Never place bulk or multi-destination calls. Never auto-dial. +- Setup, dial, STT, outcomes, Grok ad-hoc interview → [`../phonezero-runtime/SKILL.md`](../phonezero-runtime/SKILL.md) +- Restaurant collect / online first / booked gates → [`../book-restaurant/SKILL.md`](../book-restaurant/SKILL.md) -**Defaults (not compliance advice).** Destinations default `US` on a new Telnyx profile. `PHONEZERO_DISCLOSE_AI` defaults on. Every call is recorded (dual-channel); the opener says so. Spoken name defaults to PhoneZero. Booking JSON is English; after pickup the voice agent speaks the answerer’s language. The person using this sample is solely responsible for complying with all applicable laws and vendor terms — see repo [DISCLAIMER.md](../../../../DISCLAIMER.md). Do not give legal guidance in chat. - -**Bootstrap from repo.** `AGENTS.md` at the repo root is the hook. **Grok Bot** (pointed at `https://github.com/function1st/PhoneZero` or “set this up”): execute **Grok Bot — set this up** in `AGENTS.md` first — do not + Add the Cursor plugin. **Cursor IDE:** this file’s Setup, after the Cursor package is installed. After install or update, start a **new** conversation. - -## 1. Preconditions - -Before collecting a task or touching Telnyx, verify these variables are present. Read them; do not echo secrets. - -| Variable | Role | -|---|---| -| `TELNYX_API_KEY` | Cursor Configure → stdio `npx @telnyx/mcp`. Grok canary (`plugins/phonezero-grok`) → `Authorization: Bearer` on `https://api.telnyx.com/v2/mcp`. Never in chat. **The check is a successful `tools/call`** (e.g. `list_api_endpoints`) — NOT the tool list. A `401` / 10009 means the key is not wired. | -| `PHONEZERO_FROM_NUMBER` | Plugin Configure card → PhoneZero xAI MCP `get_call_config` (and the Telnyx `From`). Cursor does **not** put this in the agent shell. E.164 DID. Call-create `To`: `sip:{PHONEZERO_FROM_NUMBER}@sip.voice.x.ai;transport=tls`. If `get_call_config` has no From, take the DID attached to the PhoneZero TeXML app from Telnyx `list_phone_numbers`. | -| `XAI_API_KEY` | Cursor Configure → PhoneZero xAI MCP. Grok Bot: add/use that same xAI MCP (`put_booking` / `transcribe`); REST Bearer on `https://api.x.ai/v1` and `/v2` is fallback only. Never in chat. Never `source ~/.phonezero/env`. Must be from a team with **ZDR off**. Check: `get_call_config.xai_key_wired` then `ensure_collection`. | -| `TELNYX_ACCOUNT_SID` | **Not on the Configure card.** Telnyx MCP has **no** `whoami` tool. `invoke_api_endpoint` `list_billing_groups` → first `data[].organization_id`. Developer curl `GET /v2/whoami` is the same value. TeXML paths: `/v2/texml/Accounts/{TELNYX_ACCOUNT_SID}/…`. | -| `PHONEZERO_TEXML_APP_ID` | **Not on the Configure card.** Resolve with Telnyx MCP: list TeXML apps, use the one named `PhoneZero`. | -| `PHONEZERO_AGENT_NAME` | Default `PhoneZero`. Spoken name in `phonezero-booking.json`. Not baked into the Builder prompt. Override per call if the user wants a different name. | -| `PHONEZERO_DISCLOSE_AI` | Boolean, default `true`. Substituted once into the Builder prompt at agent creation (`{disclosure_clause}`). Also set as `disclose_ai` in each booking JSON. Toggling later does **not** change the already-created agent. | -| `PHONEZERO_XAI_COLLECTION_ID` | **Not on the Configure card.** Find-or-create collection name `PhoneZero bookings`. Attach it to the Builder agent (knowledge / file search). | - -Call-time required: working Telnyx MCP, working PhoneZero xAI MCP, a From number from `get_call_config` or Telnyx list. Resolve `TELNYX_ACCOUNT_SID` and `PHONEZERO_TEXML_APP_ID` **before** the call plan — not after `put_booking`. SID: `invoke_api_endpoint` `list_billing_groups` `{ "jq_filter": "[.data[].organization_id] | unique" }`. TeXML id: `invoke_api_endpoint` `list_texml_applications` `{ "filter": { "friendly_name": "PhoneZero" }, "jq_filter": ".data[] | {id, friendly_name}" }`. Destinations: `invoke_api_endpoint` `list_outbound_voice_profiles` `{ "filter": { "name": { "contains": "PhoneZero" } }, "jq_filter": ".data[] | select(.name==\"PhoneZero US-only\") | {id, name, whitelisted_destinations}" }`. Spoken name / disclose come from `get_call_config` (defaults PhoneZero / true). Collection via xAI MCP `ensure_collection`. - -If Telnyx MCP or xAI MCP is missing / 401s, or From cannot be resolved: **stop. Do not dial.** Tell the user to re-save Plugins → Configure and start a **new** conversation, then `/setup-phone-calling`. Never paste keys in chat. Never `source ~/.phonezero/env`. An old chat not seeing new MCP tools is not a failure. - -Do not invent `{TELNYX_ACCOUNT_SID}`. - -## Setup (when the user says *Set up phone calling* or runs `/setup-phone-calling`) - -Human/developer mirrors: `docs/SETUP.md`, and `scripts/provision.sh` — developer-only, run on a personal machine that may hold keys, never on this computer. Never `source ~/.phonezero/env`. Telnyx account + KYC + buying the DID stay manual. Telnyx API steps go through the Telnyx MCP. xAI Files / collections / STT / phone-numbers go through the PhoneZero xAI MCP on **both** hosts. **Grok Bot:** add that stdio `xai` server if `put_booking` is missing (`AGENTS.md` / README). REST on `api.x.ai` is fallback only. Do not take the key from chat. **Grok must ask** spoken name and AI disclaimer ON/OFF — do not silently keep PhoneZero / true. Destination countries are the Telnyx profile whitelist; read and show them, do not ask as a PhoneZero field. - -**First message, before any API call:** - -``` -Before we continue, you need: -- Telnyx account + KYC done -- One US number bought (that is From) -- Telnyx API key -- xAI team with Zero Data Retention (ZDR) OFF -- xAI API key from that same team -- Access to Voice Agent Builder at console.x.ai - -Missing any of these? Stop and get them. Links: telnyx.com · console.x.ai · docs.x.ai/developers/faq/security -Then we fill the rest. -``` - -If any of those are missing, **stop**. - -**Connect the Telnyx MCP.** It is not OAuth — do not click Authenticate. - -- Cursor IDE: this package’s `telnyx` is stdio `npx @telnyx/mcp` with Configure `TELNYX_API_KEY`. Do not add hosted-HTTP Telnyx here (SSE GET 404 tombstone). -- Grok Bot: stop and run `AGENTS.md` **Grok Bot — set this up**. Custom HTTP MCP (`https://api.telnyx.com/v2/mcp` + Bearer in the form). Not Cursor + Add. Not Authenticate. -- Fallback (no plugin, Cursor IDE): `"telnyx": {"command":"npx","args":["-y","@telnyx/mcp"],"env":{"TELNYX_API_KEY":"${env:TELNYX_API_KEY}"}}`. Never the literal key in the file or chat. -- **Verify with a `tools/call`, never the tool count.** Call `list_api_endpoints`. If `get_call_config.from_wired` is false, take From from Telnyx `list_phone_numbers` (PhoneZero TeXML DID). - -**Keys first.** Required on the Configure card: `TELNYX_API_KEY`, `PHONEZERO_FROM_NUMBER`, `XAI_API_KEY`. **Cursor:** name / disclose already default on the card (PhoneZero / true) — the human can change them there. Destinations are **not** on the card. **Grok Bot:** name and disclose are **not** silent. Show the `AGENTS.md` call-settings card and wait. Then write those answers into Edit Values / xAI env. Read destinations from Telnyx (`list_outbound_voice_profiles` → `PhoneZero US-only` → `whitelisted_destinations`) and show them. PATCH that profile only if they ask to add/remove countries (Mission Control → Voice → Outbound voice profiles is the same setting). Account SID, TeXML app id, and collection id are **not** on the card. If Telnyx MCP or the PhoneZero xAI MCP is unwired, send the user to Plugins → Configure and a **new** conversation. Then, field-for-field: - -1. `TELNYX_ACCOUNT_SID` via Telnyx MCP `invoke_api_endpoint` `list_billing_groups` (`data[].organization_id`). Do not search the catalog for `whoami`. -2. Find-or-create outbound voice profile name `PhoneZero US-only`: `traffic_type=conversational`, `service_plan=global`, `usage_payment_method=rate-deck`, `whitelisted_destinations` default `["US"]` **on create only**, `daily_spend_limit="5.00"`, `daily_spend_limit_enabled=true` (`POST /v2/outbound_voice_profiles`; any other combo → Telnyx error 10015). **This Telnyx profile is the destination enforcement** — Telnyx rejects calls outside `whitelisted_destinations`. If the profile already exists, keep its current whitelist. `PATCH` only when the user asks to add or remove countries (e.g. add `JP`). Change the same list in Telnyx Mission Control → Voice → Outbound voice profiles. -3. Find-or-create TeXML app name `PhoneZero`: `voice_url` = the public raw URL of `texml/inbound.xml` (default `https://raw.githubusercontent.com/function1st/PhoneZero/main/texml/inbound.xml`; verify it returns HTTP 200 before writing it), `voice_method=get`, `outbound.outbound_voice_profile_id` = that profile. -4. `PATCH /v2/phone_numbers/{phone_number_id}` `{"connection_id":""}`. -5. Register the DID with xAI (idempotent: `GET https://api.x.ai/v2/phone-numbers` first): `POST https://api.x.ai/v2/phone-numbers` `{"name":"PhoneZero","phoneNumber":"{PHONEZERO_FROM_NUMBER}","origin":"byo_trunk"}`. -6. PhoneZero xAI MCP `ensure_collection` (name `PhoneZero bookings`). A 403 mentioning Zero Data Retention means **stop** — the key's team has ZDR on. There is no sub-team override; disable ZDR or create a sibling team and use that team's key. Keep the collection id in session. Then `register_byo_number` for the From DID if `list_phone_numbers` does not already show it as `byo_trunk`. -7. Voice Agent Builder **has no create API** (`/v1/agents` is not enabled). In this Bot's browser (or walk the human) at [https://console.x.ai](https://console.x.ai), on the **same ZDR-off team** as the key: - - Create one agent. Paste `prompts/voice-agent.md` fully substituted once (`{disclosure_clause}` = `, an automated assistant,` if `PHONEZERO_DISCLOSE_AI` is true, else empty; do not substitute a spoken name). Save. Never add a per-call TASK BRIEF. - - **Welcome: on**, text exactly `PhoneZero is ready!` **Caller can interrupt: on.** That line is the session-start cue so the agent runs `collections_search` during the TeXML pause, before the restaurant greets. Empty welcome delays the search until the restaurant speaks. Do not put booking facts in the welcome. - - **Knowledge / file search:** attach `PhoneZero bookings`. Without this the agent invents the reservation. - - **`end_call` tool: on.** Name exactly `end_call`. Description exactly: `ONLY use this tool after successfully booking the reservation or confirming no available time slot can be accommodated. Be sure to verbally exchange goodbyes so you don't abruptly end the call.` The pasted prompt already uses this tool after a spoken goodbye. Do not leave hang-up off. - - **Max duration:** at least 10 minutes if the console exposes it. - - Guardrails if shown: in-window only, verbatim read-back, no invented confirmation. - - The wizard mints a **free xAI number — ignore it.** PhoneZero always bridges to `PHONEZERO_FROM_NUMBER`. Copy the `agentId`. -8. Attach the agent to the **registered Telnyx DID**, never the wizard's number. xAI MCP `list_phone_numbers` → find YOUR DID (`origin` `byo_trunk`) → `attach_agent` with that `phone_number_id` and the Builder `agentId`. The `agentId` is visible on the wizard's number row — copy it from there, then attach it onto the DID. -9. Keep `TELNYX_ACCOUNT_SID`, the TeXML app id, and the collection id in this session. Confirm last-4 of the From number in chat. **Do not** ask the user to paste those ids into Plugins → Configure. Do not invent SIDs. - -Approve each credentialed step. Never echo keys. - -## 2. Collect before any call - -Do not dial until every required field is known. Ask for missing pieces. Fail closed: if the task stays vague after one clarifying turn (no restaurant, no day, "sometime," "a place downtown"), **do not call**. - -Required: - -| Field | Rules | -|---|---| -| Restaurant name | As the host will recognize it. | -| Restaurant phone | E.164. If you only have a name, look the number up, show it, and get confirmation. Reject numbers whose country is not in the Telnyx **PhoneZero US-only** `whitelisted_destinations`. `+1` covers Canada and Caribbean NANP too — confirm the actual country, ask if unsure, refuse on no. | -| Date | Concrete calendar date. | -| Preferred time | The first ask. | -| Window start–end | Inclusive acceptable range on that date. Concatenate into the spoken brief `{window}` as a single string (e.g. `6:30 PM to 8:00 PM`). | -| Ranked alternates | Ordered fallback times the agent may accept without asking you. | -| Party size | Integer ≥ 1. | -| Booking name | Name on the reservation. | -| Callback phone | E.164 the agent leaves on voicemail and gives if the host asks. Default to the user's phone; confirm it. | - -Optional: special requests (high-top, allergies, stroller). Pass through; do not invent. Optional spoken name (what the agent calls itself on this call). Default `PHONEZERO_AGENT_NAME`; override if the user wants a different name for this restaurant or call. - -**Window and alternates.** Collect start and end, then concatenate into `{window}` for the call plan and spoken brief (e.g. start `6:30 PM` + end `8:00 PM` → `6:30 PM to 8:00 PM`). If the user said "around 7" and did not give a window, propose a default (preferred ± 30–60 minutes, e.g. `6:30 PM to 8:00 PM`) and the ranked in-window slots (e.g. 6:45, 7:15, 7:30). Confirm that proposal in the call plan — do not silently widen it. - -**Calendar.** If this Bot can read the user's calendar, compute alternates as times inside the window that do not conflict (travel buffer ~30 minutes before/after existing events). Rank: preferred time first, then nearest free in-window slots. If a backup day is free and the user allowed it, list it as a lower-rank alternate and say so in the plan. If there is no calendar access, use only the user's stated flexibility. - -Hold this task in conversation memory: restaurant, E.164, date, preferred time, `{window}`, ranked alternates, party, booking name, callback, special requests, spoken name (default `PHONEZERO_AGENT_NAME`), `attempts` (completed dial attempts, starts at 0), prior `call_sid`s, last outcome. - -## 3. Try online booking first - -Before any call plan, try to book in the Bot's own browser: OpenTable, Resy, the restaurant's site, Google Reserve. Same date, time, party, name. - -- If an online path exists and succeeds: report the confirmation in chat. Offer to add it to the calendar. **Do not call.** -- If an online path exists but needs the user (login, payment, captcha you cannot complete): hand it off in chat. **Do not call** unless they explicitly want the phone path instead. -- Call only when there is no working online path. - -## 4. Plan-first confirmation - -Present the plan in chat. Do not dial in the same turn as the plan. - -``` -Call plan -- Who: {restaurant_name} -- Number: {restaurant_phone} -- Ask: party of {n} on {date} at {time}, under {booking_name} -- Window: {window} -- Alternates the agent may accept (in order): {alternates} -- Special requests: {special_requests or "none"} -- Spoken as: {agent_name} -- From: {PHONEZERO_FROM_NUMBER} -- Callback if they miss us: {callback_phone} -- Attempt: {attempts + 1} of 2 -``` - -Dial **only** on an explicit yes to this plan ("yes", "go ahead", "call them"). Not implied consent, not "sounds good I guess," not a new unrelated message. If they edit the plan, re-show it and wait again. - -Never auto-dial. Never dial because a previous task was approved. Vague task → no call. - -## 5. Calling-hours guard and attempt cap - -`attempts` = completed dial attempts for this task. Starts at 0. The plan shows `Attempt {attempts + 1} of 2`. Block when `attempts >= 2`. - -Place a call only when **all** of these hold: - -1. **Destination country is in the Telnyx profile `whitelisted_destinations`** (already required; default `US` on a new profile). -2. **Hours — one policy, no gaps:** - - Hard cap: **09:00–21:00 user-local**. Never dial outside it, override or not. - - Known restaurant hours: only while the restaurant is open, and still inside the hard cap. - - Unknown restaurant hours: **10:30–20:30 restaurant-local** (if restaurant TZ is unknown, use the user's timezone). - - Never call a time you know the restaurant is closed. -3. **`attempts` < 2** (do not dial when `attempts >= 2`). Maximum two completed dial attempts. Attempts are 20 minutes apart (wall clock). A confirmation callback after an out-of-window hold is a **new** plan (still needs a fresh yes) and does not count against the original task's two attempts unless it is a retry of the same unanswered ask. -4. The user has explicitly approved the **current** plan. - -If it is outside calling hours: state the next legal window. Do **not** wait-and-dial. When that time comes, **re-show the plan** and dial only on a fresh explicit yes. - -If they ask to call outside restaurant hours but still inside 09:00–21:00 user-local: that override is a **new plan** and needs a fresh yes. The 09:00–21:00 hard cap cannot be overridden for restaurant destinations. - -**Owner setup-test exception.** When the user has explicitly asked for a setup or plugin test call to **their own confirmed number** (`` is that E.164; `phonezero-booking.json` says this is a test and the callee is the owner): skip both the restaurant-hours window and the 09:00–21:00 hard cap. Still require the destination country to be on the Telnyx profile whitelist, a current plan, and an explicit yes. Still max two attempts. This is only for proving the dial path during development or first-time setup — never for calling a restaurant. - -Voicemail / no-answer: leave the message (the agent does this), increment `attempts`, wait 20 minutes, re-check hours, **re-show the plan** (`Attempt {attempts + 1} of 2`, same ask), and dial only on a fresh yes. After two completed attempts still no human (`attempts >= 2`): outcome `no_answer`. Stop. - -## 6. Upload the booking JSON, then dial - -The Builder prompt (`prompts/voice-agent.md`) is **fully static**. `{disclosure_clause}` was substituted once at agent creation. The spoken name is **not** in the prompt — it is in `phonezero-booking.json`. **Never edit the Builder prompt per call. Never open the Builder console at call time.** - -Do **not** speak a Telnyx TTS brief. Write the facts to a JSON file, upload it to xAI Files, attach it to the PhoneZero bookings collection (replace any existing `phonezero-booking.json`), wait until `DOCUMENT_STATUS_PROCESSED`, then dial. The agent loads that file via the collection. - -Build this object (no extra keys): - -```json -{ - "kind": "phonezero-booking", - "spoken_name": "{agent_name}", - "disclose_ai": true, - "restaurant": "{restaurant_name}", - "party": 2, - "date": "{date}", - "preferred_time": "{time}", - "window": "{window}", - "alternates": ["{alt1}", "{alt2}"], - "booking_name": "{booking_name}", - "callback": "{callback_phone}", - "special_requests": "none" -} -``` - -`disclose_ai` is true when `get_call_config.disclose_ai` is true (default). `{agent_name}` is this call's spoken name (default `get_call_config.agent_name`). - -**Both hosts:** PhoneZero xAI MCP `put_booking`. If those tools are already live, use them. **Grok Bot:** if `put_booking` is missing, add stdio `xai` from `plugins/phonezero/mcp.json` (secure-field env, not `${…}`) — do not say upload is impossible. Fallback: `https://api.x.ai/v1` Bearer; sequence is `putBooking` in `scripts/xai-mcp.mjs`. Never echo the key. If the user said xAI is already set up, do not open the Builder. - -SID and TeXML id must already be in session from Setup / `AGENTS.md` step 5. After yes: `put_booking` (wait processed) then dial immediately. Do not look up `whoami` between those two. - -### Dial — Telnyx hosted MCP - -Use the Telnyx hosted MCP (auth is the plugin bearer; you never pass the key). The server (`https://api.telnyx.com/v2/mcp`, serverInfo `telnyx_api` v3.0.0) exposes **three generic tools**, not per-operation names: `list_api_endpoints` → `get_api_endpoint_schema` → `invoke_api_endpoint`. Use these exact `endpoint_name` values. - -Place call: `invoke_api_endpoint` with `endpoint_name` `calls_accounts_texml_calls` (REST equivalent: `POST /v2/texml/Accounts/{TELNYX_ACCOUNT_SID}/Calls`). - -TeXML body fields are PascalCase (`To`, `From`, `Texml`, …). `account_sid` is the MCP path param, not a TeXML body key. - -JSON `args`: - -```json -{ - "account_sid": "{TELNYX_ACCOUNT_SID}", - "ApplicationSid": "{PHONEZERO_TEXML_APP_ID}", - "To": "sip:{PHONEZERO_FROM_NUMBER}@sip.voice.x.ai;transport=tls", - "From": "{PHONEZERO_FROM_NUMBER}", - "Texml": "{restaurant_phone}", - "Record": true, - "RecordingChannels": "dual", - "Timeout": 30, - "TimeLimit": 600 -} -``` - -Load `texml/bridge.xml`, strip comments and newlines, replace `{RESTAURANT_E164}` with `{restaurant_phone}` and `{PHONEZERO_FROM_NUMBER}` with the DID (Dial `callerId`). That string is the `Texml` field. The same DID is also the request `To` (`sip:{PHONEZERO_FROM_NUMBER}@sip.voice.x.ai;transport=tls`). Do not send `Url` — the request schema is oneOf: `Url` XOR `Texml` XOR neither. Do not set `answerOnBridge` — the SIP To is already answered so the agent can load the booking file; that flag is for unanswered inbound legs and can skip the PSTN Dial. Do not put `` in the Texml. - -**Schema lag:** `get_api_endpoint_schema` for `calls_accounts_texml_calls` does **not** list `Texml` (the MCP's OpenAPI snapshot lags the API). `invoke_api_endpoint` still passes `Texml` through and it works. Do not "correct" yourself off the schema — omit `Url` and send `Texml`. - -- `To` is the agent SIP URI (`sip:{PHONEZERO_FROM_NUMBER}@sip.voice.x.ai;transport=tls`). The agent answers first. -- `From` is exactly `PHONEZERO_FROM_NUMBER` (E.164). -- `{restaurant_phone}` inside `` is E.164 only (destination country already confirmed on the Telnyx profile whitelist). -- Recording is call-level (`Record` true, `RecordingChannels` `dual`). Do not put `record` on ``. -- Do **not** send `MachineDetection` or `AsyncAmd`. In this shape they would classify the xAI agent (the `To` leg), which is useless. Voicemail is handled conversationally by the agent and classified from the transcript (§7 / §9). -- Do **not** send `SendDigits`. It would apply to the agent `To` leg, not the restaurant. Restaurant IVR is handled conversationally or reported `needs_user`. Mid-call DTMF is not available. -- `Timeout` 30 is the ring timeout in seconds waiting for the agent `To` to answer. Do not raise it. -- `TimeLimit` 600s is the per-call duration cap. Do not raise it. - -On success, store `sid` / `CallSid` as `call_sid`. On MCP/HTTP error: outcome `failed`. Do not retry in the same turn; tell the user what Telnyx returned (no secrets). - -## 7. Poll for completion - -Poll the same MCP: `invoke_api_endpoint` with `endpoint_name` `retrieve_calls_accounts_texml_calls` and args `account_sid`, `call_sid` (REST equivalent: `GET /v2/texml/Accounts/{TELNYX_ACCOUNT_SID}/Calls/{call_sid}`). The MCP schema for that name is a **list** (no `call_sid` field) and the list call can time out — still send `call_sid`; if the list hangs, treat a completed recording on `retrieve_recordings_json_calls_accounts_texml_recordings_json` as the terminal signal. Returns `status` and `duration`. Do not use `answered_by` — AMD is off; voicemail is classified from the transcript (agent reports leaving a message / a voicemail greeting or beep is present and no human turn → voicemail path per §5). - -This endpoint is eventually consistent. - -| Field | Action | -|---|---| -| `status` `ringing`, `in-progress` | Keep polling. | -| `status` `completed` | Go to recordings. | -| `status` `no-answer`, `busy` | Not a booking; apply §5 retry; report `no_answer` only when `attempts >= 2`. | -| `status` `failed`, `canceled` | Outcome `failed` always. Never `booked`. Do not override from a transcript. If a transcript exists, quote it under `failed` only. | - -Poll every 10s while live. **Call timeout:** 12 minutes from dial. If still `ringing` / `in-progress` at 12 minutes, stop polling the live call, try recordings once, and if nothing usable → `unknown` (never `booked`). - -## 8. Recording + transcription - -Telnyx does **not** transcribe Dial-verb recordings. TeXML transcription exists only for `` and the webhook-dependent `` verb. There is no post-hoc "create transcription" API. xAI STT is the default outcome path. The xAI Builder console is review-only — not on the critical path. - -After a terminal call status: - -1. Fetch recordings via the Telnyx MCP: `invoke_api_endpoint` with `endpoint_name` `retrieve_recordings_json_calls_accounts_texml_recordings_json` (REST equivalent: `GET /v2/texml/Accounts/{TELNYX_ACCOUNT_SID}/Calls/{call_sid}/Recordings.json`). Do **not** use `recordings_json_calls_accounts_texml_recordings_json` — that name is a write (start recording). Response: `recordings[].media_url` (S3 presigned, expires ~600s / ~10 min) and `recordings[].sid`. Poll every 15s for up to 3 minutes after call end until a completed recording with a `media_url` exists. If none: outcome `unknown`. Never `booked`. -2. Download the dual-channel `media_url` **promptly** to a temp file on this computer (the presigned URL expires in ~10 minutes; do not commit it; do not paste the URL in chat). -3. Transcribe with the PhoneZero xAI MCP `transcribe` (`file_path` = the temp download). Do not curl STT from the agent shell. If the xAI MCP errors that `XAI_API_KEY` is not set: **stop**. Re-save Configure and start a new conversation. Never ask for the key in chat. Do not classify `booked`. - -5. The multichannel response includes a `channels` array (one transcript per speaker). Apply this channel model (do not over-specify channel numbers): - - - Identify the **agent** channel by the opener ONLY ("calling on a recorded line" / "I'd like to make a reservation"). - - Host confirmation = a later turn on the non-agent channel, after the opener, that accepts the time, has the party down, or answers yes to the read-back. - - A mailbox greeting / beep / "leave a message" is never a host confirmation. - - If `channels` is missing or the opener is not unique (neither channel, or both channels, contain the opener): outcome `unknown`, never `booked`. - -If STT fails or returns empty text: outcome `unknown`. Never `booked`. You may retry the call later under §5 (absence is not a booking). - -## 9. Extract the outcome - -There is no spoken English recap. Classify from the conversation. - -If `channels` is missing or the agent channel was not identified in §8, outcome is `unknown` — do not search merged text for a booking. - -**`booked` only if all of these are true:** - -1. The agent read back party, date, time, and booking name, and a **host** turn (the non-agent channel, identified in §8) confirmed — e.g. they accept the time, say they have the party down, or answer yes to that read-back. -2. The confirmed `{time}` is inside the approved window or the pre-briefed alternates. -3. Recording exists and xAI STT returned a transcript. -4. The confirming turn is not a voicemail greeting/beep/"leave a message" — a live human turn is required. -5. The uploaded booking JSON had restaurant, party, date, preferred time, window, and booking name. If that upload failed, never `booked`. - -Never invent a confirmation number or a time that is not in the transcript. - -Map everything else: - -| Situation | Outcome | -|---|---| -| Host said they are full / no times in window or alternates | `unavailable` | -| No human (ring/no-answer/busy) or voicemail only, after retries exhausted | `no_answer` | -| Voicemail on attempt 1 of 2 | not terminal — retry per §5; do not report a final outcome yet | -| Host offered a time **outside** window/alternates | `needs_user` | -| Host objected to recording or to an AI caller | `needs_user` | -| Wrong number, not a restaurant, host asked a human to call back | `needs_user` | -| Booking JSON upload failed or was incomplete | `unknown` | -| Missing recording/transcript, no clear host confirmation or refusal | `unknown` | -| Call abandoned mid-hold, host never refused | `unknown` | -| Call `status` `failed` or `canceled` | `failed` always. Never `booked`. Quote a transcript under `failed` only. | -| MCP/dial/API failure | `failed` | - -Valid outcome states (exactly one): `booked` | `unavailable` | `no_answer` | `needs_user` | `unknown` | `failed`. - -## 10. Artifacts - -After successful STT (and the outcome is classified / you are ready to report it): - -1. **Keep** the Telnyx recording. Do not `DELETE` it. Download the presigned `media_url` to `/tmp` only (expires in ~10 minutes). Never copy recordings into the repo or the workspace. The Telnyx recording object remains. -2. **Delete** the xAI booking file (xAI MCP `delete_booking`) so the next call cannot pick up a stale JSON. -3. Discard the `/tmp` download after STT. - -There is no Telnyx transcription to delete. - -Do not copy raw audio or full transcripts into chat. Quote only the host confirmation phrase you relied on. - -## 11. Counter-offer tiers - -There is no mid-call relay to you. Three tiers: - -1. **In-window / pre-briefed alternates (default).** Already in the booking JSON. The agent accepts on the spot. You report `booked` at that time (host confirmation still required). -2. **Out-of-window offer.** Agent must not accept. It asks the host to hold if possible and says goodbye. You report `needs_user`, show the offer in chat, and wait. If the user accepts, place a **confirmation callback** with a new booking JSON (narrow ask: lock the held time) and run §4–§10 again. If they decline, stop (`unavailable`) or collect a new window. -3. **Live calendar tool mid-call** — not available. Do not pretend it is. - -## 12. Report and calendar - -Report in chat, one state, concrete facts: - -- `booked` — restaurant, date, time, party, name, any host notes. Offer to create the calendar event yourself (title, start, duration ~90 minutes unless they say otherwise, location, phone, party). Only write the calendar if they want it. -- `unavailable` — what the host said; do not call again for the same slot unless they change the window. -- `no_answer` — attempts used; offer a later retry as a new plan. -- `needs_user` — the exact offer or objection; ask what to do. -- `unknown` — why (no transcript, no clear host confirmation). Do not claim a table. -- `failed` — what broke (Telnyx status / MCP / STT error). Do not claim a table. - -## Hard rules - -- No call without §1 call-time variables, a complete collect, an online-booking attempt, an hours check, and an explicit yes to the current plan. -- No `booked` unless all five gates in §9 hold (recording + transcript, live host confirmation of the read-back that is not voicemail, in-window time, complete booking JSON). -- No secrets, no keys in chat, no non-fixture numbers written into skills or examples. -- One restaurant, one task, max two attempts, 20 minutes apart. -- Never edit the Builder prompt after setup. It is fully static. Brief each call with `phonezero-booking.json` in the xAI collection, not TeXML `` and not the Builder console. +Read those files in full and follow them. Do not use this file as the source of truth. diff --git a/plugins/phonezero/texml/bridge.xml b/plugins/phonezero/texml/bridge.xml index 78fca29..ebd7acd 100644 --- a/plugins/phonezero/texml/bridge.xml +++ b/plugins/phonezero/texml/bridge.xml @@ -7,12 +7,12 @@ The Builder agent answers that To immediately. At call time the caller substitutes: - {RESTAURANT_E164} — restaurant PSTN E.164 inside + {RESTAURANT_E164} — callee PSTN E.164 inside (must be on the Telnyx PhoneZero US-only voice-profile whitelisted_destinations; enforcement is Telnyx, not this XML) - Booking facts are NOT spoken here. Upload phonezero-booking.json to the + Task facts are NOT spoken here. Upload phonezero-task.json to the xAI collection (scripts/put-booking-file.sh) before this dial. The agent reads that file. Do not add . @@ -22,13 +22,13 @@ when Texml is set). Flow: Pause 3s (welcome "PhoneZero is ready!" + collections_search - on the SIP leg) → the restaurant with an E.164 + on the SIP leg) → the callee with an E.164 callerId. The welcome is spoken on the agent's ear during this - pause; the restaurant must not be bridged yet or a fast pickup + pause; the callee must not be bridged yet or a fast pickup hears it. After Dial, the agent hears ringback and stays mute until the callee greets. Do not set answerOnBridge: the SIP To is already answered so the - agent can retrieve the booking file; answerOnBridge only applies to + agent can retrieve the task file; answerOnBridge only applies to an unanswered inbound leg and has been seen to skip the PSTN Dial after a SIP first hop. diff --git a/scripts/place-call.sh b/scripts/place-call.sh index 51620b9..997ce03 100755 --- a/scripts/place-call.sh +++ b/scripts/place-call.sh @@ -15,7 +15,7 @@ # https://developers.telnyx.com/api-reference/texml-rest-commands/initiate-an-outbound-call # # Reversed flow (verified Aug 2026): To is the xAI agent SIP URI; Texml -# Pause+Dial(restaurant). Booking facts go in the xAI collection JSON +# Pause+Dial(callee). Task facts go in the xAI collection JSON # (put-booking-file.sh), not Telnyx TTS. MachineDetection/AsyncAmd are # omitted (they would classify the agent To-leg). # @@ -35,18 +35,18 @@ set -euo pipefail usage() { cat <<'EOF' -Usage: place-call.sh [--dry-run] [--booking-json PATH] E.164_RESTAURANT +Usage: place-call.sh [--dry-run] [--booking-json PATH] E.164_CALLEE Place a TeXML call: To = the xAI agent SIP URI; Texml pauses then -dials the restaurant E.164. Booking facts must already be in the -xAI collection as phonezero-booking.json (or pass --booking-json to -upload first). Prints the Call SID. +dials the callee E.164. Task facts must already be in the +xAI collection as phonezero-task.json (or pass --booking-json to +upload first). Prints the Call SID. E.164_RESTAURANT is accepted as an alias. Numbers must be E.164 (^\+[1-9][0-9]{6,14}$). PHONEZERO_FROM_NUMBER -must also be US (+1 and 10 digits). Restaurant dest is E.164 only — +must also be US (+1 and 10 digits). Dest is E.164 only — Telnyx voice-profile `whitelisted_destinations` is country enforcement. - --booking-json PATH Upload this phonezero-booking JSON before dialing + --booking-json PATH Upload this phonezero-task or phonezero-booking JSON before dialing --dry-run Print the request (API key redacted) without sending it Required environment: @@ -63,7 +63,7 @@ Optional / auto-resolved: xAI-provisioned number instead. Example (fixture number only): - place-call.sh --booking-json /tmp/phonezero-booking.json +15555550100 + place-call.sh --booking-json /tmp/phonezero-task.json +15555550100 place-call.sh --dry-run +15555550100 EOF } @@ -146,9 +146,9 @@ print(org.strip()) echo "resolved TELNYX_ACCOUNT_SID via /v2/whoami (${TELNYX_ACCOUNT_SID:0:8}…)" } -# Build inline Texml from texml/bridge.xml: substitute restaurant E.164, -# strip comments and whitespace. No — booking facts are the -# collection JSON. Fallback: proven shape. +# Build inline Texml from plugins/phonezero/texml/bridge.xml: substitute +# callee E.164, strip comments and whitespace. No — task facts are +# the collection JSON. Fallback: proven shape. build_texml() { python3 -c ' import os, re @@ -176,8 +176,8 @@ xml = "".join(line.strip() for line in xml.splitlines()) xml = re.sub(r">\s+<", "><", xml) if "{RESTAURANT_E164}" in xml or "{PHONEZERO_FROM_NUMBER}" in xml: raise SystemExit("error: unsubstituted placeholder in Texml") -if "" in xml: - raise SystemExit("error: Texml must not include Say (booking JSON is the brief)") + if "" in xml: + raise SystemExit("error: Texml must not include Say (task JSON is the brief)") if "&2 + echo "error: --brief is removed; facts are phonezero-task.json in the xAI collection (use --booking-json)" >&2 exit 2 ;; --) @@ -291,7 +291,7 @@ if [ -z "$RESTAURANT" ]; then exit 2 fi -require_number "restaurant" "$RESTAURANT" +require_number "callee" "$RESTAURANT" require_env TELNYX_API_KEY require_env PHONEZERO_FROM_NUMBER @@ -317,7 +317,7 @@ if [ -n "$BOOKING_JSON" ]; then "${SCRIPT_DIR}/put-booking-file.sh" "$BOOKING_JSON" fi fi -export PHONEZERO_BRIDGE_XML="${SCRIPT_DIR}/../texml/bridge.xml" +export PHONEZERO_BRIDGE_XML="${SCRIPT_DIR}/../plugins/phonezero/texml/bridge.xml" export PHONEZERO_RESTAURANT="$RESTAURANT" # SIP To is the xAI number the agent answers. Default: the Telnyx DID # (byo_trunk). Override with PHONEZERO_XAI_SIP_NUMBER when the agent is diff --git a/scripts/privacy-check.py b/scripts/privacy-check.py index 5bf26eb..8b0408a 100755 --- a/scripts/privacy-check.py +++ b/scripts/privacy-check.py @@ -30,6 +30,7 @@ ALLOWED_AUTHOR_EMAIL_RES = ( re.compile(r"^[^@\s]+@users\.noreply\.github\.com$", re.IGNORECASE), re.compile(r"^[^@\s]+@noreply\.github\.com$", re.IGNORECASE), + re.compile(r"^noreply@github\.com$", re.IGNORECASE), re.compile(r"^cursoragent@cursor\.com$", re.IGNORECASE), ) @@ -145,20 +146,37 @@ def collect_commit_emails(repo: Path, extra_args: list[str] | None = None) -> li return found -def _author_log_args(repo: Path) -> list[str] | None: - """On a PR, check only commits that are not already on the base branch.""" - base = os.environ.get("GITHUB_BASE_REF", "").strip() - if not base: - return None - ref = f"origin/{base}" +def _ref_exists(repo: Path, ref: str) -> bool: probe = subprocess.run( ["git", "-C", str(repo), "rev-parse", "--verify", ref], capture_output=True, text=True, ) - if probe.returncode != 0: - return None - return ["HEAD", "--not", ref] + return probe.returncode == 0 + + +def _author_log_args(repo: Path) -> list[str] | None: + """Check only commits that are not already on the base branch.""" + candidates: list[str] = [] + base = os.environ.get("GITHUB_BASE_REF", "").strip() + if base: + candidates.extend([f"origin/{base}", base]) + before = os.environ.get("GITHUB_EVENT_BEFORE", "").strip() + if before and set(before) != {"0"}: + candidates.append(before) + candidates.append("origin/main") + for ref in candidates: + if _ref_exists(repo, ref): + return ["HEAD", "--not", ref] + parents = subprocess.run( + ["git", "-C", str(repo), "rev-list", "--parents", "-n", "1", "HEAD"], + capture_output=True, + text=True, + ) + parts = (parents.stdout or "").split() + if parents.returncode == 0 and len(parts) >= 3: + return ["HEAD", "--not", parts[1]] + return None def scan_authors(repo: Path) -> list[str]: @@ -225,6 +243,19 @@ def self_test() -> None: if leftover: raise SystemExit(f"self-test: noreply author was flagged: {leftover}") + merge_env = { + "GIT_AUTHOR_NAME": "GitHub", + "GIT_AUTHOR_EMAIL": "noreply@github.com", + "GIT_COMMITTER_NAME": "GitHub", + "GIT_COMMITTER_EMAIL": "noreply@github.com", + } + (git_repo / "README").write_text("z\n", encoding="utf-8") + _git(git_repo, "add", "README") + _git(git_repo, "commit", "-m", "merge", extra_env=merge_env) + leftover = [row for row in scan_authors(git_repo) if "noreply@github.com" in row] + if leftover: + raise SystemExit(f"self-test: GitHub merge author was flagged: {leftover}") + (root / "mail-ok.txt").write_text( "noreply function1st@users.noreply.github.com fixture reporter@example.com\n", encoding="utf-8", diff --git a/scripts/provision.sh b/scripts/provision.sh index e020dcd..d0bf978 100755 --- a/scripts/provision.sh +++ b/scripts/provision.sh @@ -760,7 +760,7 @@ if [ -n "${XAI_API_KEY:-}" ]; then echo "xai_agent_id=${XAI_AGENT_ATTACHED:-}" fi echo -echo "Set PHONEZERO_AGENT_NAME and PHONEZERO_DISCLOSE_AI in Plugins → Configure." +echo "Spoken name and disclose are per-task (chat), not Plugins → Configure." echo "Enter TELNYX_API_KEY as a plugin variable (backend-held)." echo "Enter XAI_API_KEY via Grok Bot's secure secret request flow." echo "Destination countries are the Telnyx outbound voice profile PhoneZero US-only (Mission Control → Voice → Outbound voice profiles), not a plugin field." diff --git a/scripts/put-booking-file.sh b/scripts/put-booking-file.sh index e58118c..ba2c66e 100755 --- a/scripts/put-booking-file.sh +++ b/scripts/put-booking-file.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Developer tool for a personal machine that may hold keys. Never echo keys. # -# Upload phonezero-booking.json to xAI Files, attach it to the PhoneZero +# Upload phonezero-task.json to xAI Files, attach it to the PhoneZero # bookings collection, wait until DOCUMENT_STATUS_PROCESSED, print ids. # # Required env: @@ -22,8 +22,8 @@ usage() { cat <<'EOF' Usage: put-booking-file.sh PATH.json -Replace the collection document named phonezero-booking.json with PATH.json -(must be an object with kind "phonezero-booking"). Waits until the document +Replace the collection document named phonezero-task.json with PATH.json +(kind phonezero-task, or legacy phonezero-booking). Waits until the document is processed. Prints COLLECTION_ID and FILE_ID. Never prints API keys. Required environment: @@ -64,23 +64,77 @@ if ! command -v curl >/dev/null 2>&1; then exit 2 fi +WRAP_TMP="$(mktemp)" +trap 'rm -f "$WRAP_TMP"' EXIT python3 -c ' import json, sys from pathlib import Path -p = Path(sys.argv[1]) -data = json.loads(p.read_text(encoding="utf-8")) -if not isinstance(data, dict) or data.get("kind") != "phonezero-booking": - sys.stderr.write("error: JSON must be an object with kind phonezero-booking\n") - sys.exit(2) -required = ("spoken_name", "restaurant", "party", "date", "preferred_time", "window", "alternates", "booking_name", "callback") -missing = [k for k in required if k not in data or data[k] in (None, "")] -if missing: - sys.stderr.write("error: booking JSON missing: " + ", ".join(missing) + "\n") + +src = Path(sys.argv[1]) +dest = Path(sys.argv[2]) +data = json.loads(src.read_text(encoding="utf-8")) +if not isinstance(data, dict) or data.get("kind") not in ("phonezero-task", "phonezero-booking"): + sys.stderr.write("error: JSON must be an object with kind phonezero-task or phonezero-booking\n") sys.exit(2) -' "$JSON_PATH" + +def wrap_booking(booking): + required = ("spoken_name", "restaurant", "party", "date", "preferred_time", "window", "alternates", "booking_name", "callback") + missing = [k for k in required if k not in booking or booking[k] in (None, "")] + if missing: + sys.stderr.write("error: booking JSON missing: " + ", ".join(missing) + "\n") + sys.exit(2) + party = booking["party"] + date = booking["date"] + preferred = booking["preferred_time"] + name = booking["booking_name"] + window = booking["window"] + alts = booking["alternates"] if isinstance(booking.get("alternates"), list) else [] + callee = booking.get("callee") if isinstance(booking.get("callee"), dict) else {} + phone = callee.get("phone") or booking.get("phone") or booking.get("restaurant_phone") or "" + spoken = booking["spoken_name"] + callback = booking["callback"] + return { + "kind": "phonezero-task", + "skill": "book-restaurant", + "spoken_name": spoken, + "disclose_ai": booking.get("disclose_ai") is not False, + "callee": {"name": booking["restaurant"], "phone": phone}, + "callback": callback, + "goal": "Book the table within the window.", + "opener": f"I'\''d like to make a reservation for a party of {party} on {date} at {preferred}. Do you have availability?", + "constraints": [ + "Accept only the preferred time, ranked alternates, or a host offer inside the window.", + "Never invent a time.", + ], + "success": "Live host confirms read-back of party, date, agreed time, and booking name.", + "voicemail": f"This is {spoken} calling for {name} about a reservation for {party} on {date} at {preferred}. Please call {callback}. Thank you.", + "playbook": "Ask preferred first; then alternates in order; then in-window host offers. Mention special requests only after a time is under discussion.", + "facts": { + "party": party, + "date": date, + "preferred_time": preferred, + "window": window, + "alternates": alts, + "booking_name": name, + "special_requests": booking.get("special_requests") or "none", + }, + } + +if data.get("kind") == "phonezero-booking": + data = wrap_booking(data) +else: + required = ("skill", "spoken_name", "callee", "callback", "goal", "opener", "constraints", "success", "voicemail", "playbook", "facts") + missing = [k for k in required if k not in data or data[k] in (None, "")] + if missing: + sys.stderr.write("error: task JSON missing: " + ", ".join(missing) + "\n") + sys.exit(2) +dest.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") +' "$JSON_PATH" "$WRAP_TMP" +JSON_PATH="$WRAP_TMP" COLLECTION_NAME="PhoneZero bookings" -BOOKING_NAME="phonezero-booking.json" +BOOKING_NAME="phonezero-task.json" +LEGACY_BOOKING_NAME="phonezero-booking.json" API="https://api.x.ai/v1" auth_api=(-H "Authorization: Bearer ${XAI_API_KEY}") @@ -138,7 +192,7 @@ for c in items: -H "Accept: application/json" \ --data-binary "$(python3 -c 'import json,os; print(json.dumps({ "collection_name": os.environ["COLLECTION_NAME"], - "collection_description": "Current PhoneZero reservation facts", + "collection_description": "Current PhoneZero call briefs and optional templates", "field_definitions": [{ "key": "kind", "required": False, @@ -236,7 +290,7 @@ attach_and_wait() { http_json POST "${API}/collections/${cid}/documents/${fid}" "${auth_api[@]}" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ - --data-binary "$(CID="$cid" FID="$fid" python3 -c 'import json,os; print(json.dumps({"collection_id": os.environ["CID"], "file_id": os.environ["FID"], "fields": {"kind": "phonezero-booking"}}))')" + --data-binary "$(CID="$cid" FID="$fid" python3 -c 'import json,os; print(json.dumps({"collection_id": os.environ["CID"], "file_id": os.environ["FID"], "fields": {"kind": "phonezero-task"}}))')" if [ "$PHONEZERO_HTTP_CODE" != "200" ] && [ "$PHONEZERO_HTTP_CODE" != "201" ]; then echo "error: add document HTTP ${PHONEZERO_HTTP_CODE}" >&2 exit 1 @@ -276,6 +330,8 @@ print(d.get("status") if d.get("status") is not None else "") export COLLECTION_NAME COLLECTION_ID="$(resolve_collection_id)" remove_existing_booking "$COLLECTION_ID" +BOOKING_NAME="$LEGACY_BOOKING_NAME" remove_existing_booking "$COLLECTION_ID" +BOOKING_NAME="phonezero-task.json" FILE_ID="$(upload_file)" attach_and_wait "$COLLECTION_ID" "$FILE_ID" printf 'COLLECTION_ID=%s\n' "$COLLECTION_ID"