A phone agent that calls a business on your behalf, asks one quick question, and writes down the answer. You paste an E.164 number, pick hours, stock, or aisle, and give your first name. The agent dials from a shared Telnyx FROM (TELNYX_FROM_NUMBER / TELNYX_PHONE_NUMBER), opens with "I'm calling on behalf of Nathan", gets the answer, hangs up, and leaves a result, a transcript, and the call audio on disk. Closed-alpha testers text one shared Treena iMessage bot (LINQ_FROM_E164); each account stores the user's own phone.
The stack is Python 3.12, Pydantic v2, Typer, FastAPI (two routes), Telnyx Call Control with media streaming, an app-owned GPT-Live bridge that follows Telnyx's outbound GPT-Live recipe, and a DID ledger that defaults to SQLite. Set NEON_DATABASE_URL to use Neon. Set the four R2_* vars to put finished artifacts on R2. The design and its rationale are in docs/design.md. How V1 prompts GPT-Live, and the OpenAI page that is the source of truth for it, are in docs/gpt-live-prompting.md. What this tree implements today is listed in docs/FEATURE_MAP.md. The standing verification map is .cursor/skills/verify-call-agent/features/. Both maps update when a lever ships.
Day 1 ships quick-ask and restaurant book. Closed alpha includes iMessage inbound through the Linq Worker hook and the MessagingPort process path. Live send is gated on LINQ_API_TOKEN. That is not a public landing claim. Hold queues, packaging, and SMS are out of scope. Telnyx vs Linq is in docs/linq.md. This repository has not dialed live book.
Closed alpha L1 testers text Treena through Linq. This Worker URL is on that inbound path. Builder can register it now. No Nathan click. SMS stays off.
https://call-agent-webhooks.usetreena.workers.dev/linq/webhook
GET /linq/webhook returns {"ok":true,"provider":"linq"}. POST forwards the raw body and Standard Webhooks headers to APP_BASE_URL/linq/webhook. Unset APP_BASE_URL is 501 missing_app_base_url. Alias https://call-agent-webhooks.usetreena.workers.dev/webhooks/linq. Telnyx stays on https://call-agent-webhooks.usetreena.workers.dev/webhooks/telnyx. FastAPI POST /linq/webhook verifies HMAC and replies on iMessage message.received via MessagingPort. Non-kickoff chat echoes Received: {text}. A natural book ask without the restaurant number gets a phone-number collect on the same chat. After the number, Treena asks to confirm. Confirm / approve / yes returns 202 and this process texts Okay, I'm going to call them now., places the call, then texts the result. The first message never dials. The Worker does not send SMS. Prod FastAPI is the always-on Fly app hidden-beacon-080. Set Worker APP_BASE_URL to https://hidden-beacon-080.fly.dev (no path). Details are in workers/webhooks/README.md.
Fly Machines run always-on uvicorn. fly.toml pins app = "hidden-beacon-080" and sets auto_stop_machines = "off" and min_machines_running = 1. The live origin is https://hidden-beacon-080.fly.dev (no path). The laptop is not the prod FastAPI host. Worker APP_BASE_URL is that origin. Do not fly launch a second app. This VM has no Fly token.
fly secrets set LINQ_WEBHOOK_SIGNING_SECRET=...
fly secrets set ACCOUNT_SYNC_SECRET=...
fly deployLINQ_WEBHOOK_SIGNING_SECRET is required for inbound HMAC. ACCOUNT_SYNC_SECRET is required for POST /accounts/sync (call-agent-web checkout/settings). Live inbound reply also needs LINQ_API_TOKEN and LINQ_FROM_E164 on this process. LINQ_FROM_E164 is the one shared Treena iMessage bot (Cap/ops sandbox +12138653024 until ~Sep 18). Live dial also needs TELNYX_FROM_NUMBER and TELNYX_PHONE_NUMBER (+12135690899). HMAC verify without a token still ACKs ignored events; message.received send uses StubLinq when the token is unset. Seed L1 testers with call-agent account upsert --phone +1... --name Nathan --minutes 30 until the website PR POSTs sync. --phone is the user's own number. Kickoff does not need users.from_did. The sync contract is docs/accounts.md. This tree does not fly deploy.
Then point the Worker at the Fly origin (no path) and deploy the Worker:
cd workers/webhooks
npx wrangler secret put APP_BASE_URLThe value is https://hidden-beacon-080.fly.dev. Then:
npx wrangler deployStub mode fakes the three network boundaries (Telnyx HTTP, Telnyx media WebSocket, OpenAI GPT-Live WebSocket) with raw JSON fixtures that go through the same parsers live mode uses. It needs no keys and opens no sockets. CI runs it on every push.
python3 -m venv .venv
. .venv/bin/activate
pip install -e ".[dev]"
call-agent verify-quick-ask --to +15555550123 --ask hours --name Nathan --mode stub
call-agent verify-book --to +15555550123 --name Nathan --party 2 --time "Friday 7pm" --mode stubverify-quick-ask should print eight [PASS] lines and 8/8 passed, and exit 0. The eight checks are:
did_in_ledger. The user has exactly one number in the ledger, and provisioning twice returns the same number.dial_payload. The Call Control body hasfromset to that number,toset to the destination, awss://stream_url,stream_bidirectional_mode: rtp, andstream_bidirectional_target_legs: self.disclosure_first. The GPT-Livesession.startinstructions name the caller ("on behalf of Nathan") before the question, and the agent's first transcript turn does too.clean_hangup. The verdict comes fromrecord_task_result, the agent callsend_call, this process farewells then drains, sends exactly one Telnyx mark namedhanguponce GPT-Live confirms the close, Telnyx echoes it,HANGUP_TRAILING_SILENCE_S(default 0.4 s) elapses, the hangup follows, and Telnyx confirms withcall.hangup.hangup.pathistool. A drain that runs intoHANGUP_DRAIN_TIMEOUT_S(default 10 s), or a call that sends more than one mark, fails this check.result_artifact.result.jsonis a validQuickAskResult, itstranscript_refandaudio_refpoint at files that exist, andcall_report.jsonis a validCallReportwhose refs exist.session_delegation.session.starthasdelegation.type=responses, toolsrecord_task_resultandend_call,reasoning.efforton the Responses object (default OpenAIlowfromOPENAI_RESPONSES_EFFORT=light), audio 16 kHz, and no resample.tool_loop. A Responses delegation, tool calls,tool_resultthencontinue_response, andtask.jsonwhose outcome maps to the result status.app_owned_hangup. Hangup pathtool, one mark, trail beforecall.hangup, no silence-failsafe steer.
verify-book should print ten [PASS] lines and 10/10 passed. The first five checks match verify-quick-ask, except clean_hangup expects booked or declined. The sixth check is multi_turn_booking. The transcript must have at least three agent turns and two business turns, and it must mention party, time, and constraints. A one-shot stub that skips that conversation fails this check and exits 1. The seventh check is keeps_talking: the stub emits Responses tools after the time turn, this process returns the tool result and continues, and the agent speaks again before hanging up via=tool. Checks 8–10 are the same session_delegation, tool_loop, and app_owned_hangup rows.
To run the test suite:
pytest -qStub messaging (no Linq token, no public URL):
call-agent verify-messageYou should see six [PASS] lines and 6/6 passed. The checks are stub send shape, inbound message.received mapping, inbound reply + artifact text, Standard Webhooks signature accept/reject, live constructor without LINQ_API_TOKEN, and SMS ignored. See docs/linq.md.
call-agent verify-linq-call proves iMessage natural collect + confirm-before-dial → stub run_call → result reply on the same chat, plus artifact cross-links. Fourteen [PASS] lines and 14/14 passed, including collect_time, shared_from_did and no_number_order. It fails if dial happens before confirm. It does not dial Telnyx or hit Linq live. Cap recorded live-prove: PASS (#34). qa owns live-prove sign-off (TRE-19).
call-agent verify-persistence checks the Neon and R2 ports. With those vars unset it uses SQLite and a local directory, prints four [PASS] lines, and exits 0. If some R2_* vars are set and others are not, or if NEON_DATABASE_URL is not a postgresql:// URL, it exits 2 and names the problem. CI runs it with the vars unset. It never opens Neon or R2.
call-agent verify-accounts proves the users row, omit-preserve, in-process HMAC POST /accounts/sync, signed GET /accounts/dashboard, and no users.name column. Five [PASS] lines and 5/5 passed. It never POSTs Fly.
call-agent verify-minutes proves the minute cap: remaining 0 refuses kickoff, included remaining still collects, a 5-minute prepaid pack unlocks, max 12 packs / 60 minutes, pack price is $1.00 (Cap locked), and Stripe stays TEST. Six [PASS] lines and 6/6 passed. It never dials and never charges Stripe live.
call-agent did provision --user default
call-agent did list
call-agent account upsert --phone +14155552671 --name Nathan --from-did +15555550177 --minutes 30
call-agent quick-ask --to +14155552671 --ask hours --name Nathan
call-agent quick-ask --to +14155552671 --ask stock --item "oat milk" --name Nathan
call-agent book --to +14155552671 --name Nathan --party 2 --time "Friday 7pm" --notes "outdoor if possible"quick-ask prints one JSON object with status (answered, declined, no_answer, or failed), answer_text, call_control_id, transcript_ref, and audio_ref. book prints status (booked, declined, no_answer, or failed) and confirmation_text on a booked result. The refs are relative to CALL_AGENT_ARTIFACTS_DIR. During the call, each run writes one directory there with dial.json, session_start.json, events.jsonl, transcript.json, audio/inbound.wav, audio/outbound.wav, hangup.json, result.json, call_report.json, and call_report.redacted.json. After hangup, those files are deleted unless RETAIN_CALL_ARTIFACTS is set or the request sets retain_call_artifacts=True. Crash leftovers are accepted. verify-quick-ask and verify-book retain so they can prove hangup and transcript shape.
Laptop and stub mode keep the ledger in CALL_AGENT_DB_PATH (SQLite). When NEON_DATABASE_URL is set, did provision and did list use Neon with the same dids table. When retention is on and R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_BUCKET are all set, run_call also puts transcript.json, result.json, call_report.json, and both audio files to that bucket. Default wipe skips that publish. events.jsonl, dial.json, and call_report.redacted.json stay on the local disk when retained. A partial R2 set fails before the call starts. The package depends on psycopg[binary], so pip install . (and the Fly image) includes the Neon driver. Live Fly hidden-beacon-080 sha bb91810 (PR #39): health 200, Neon/psycopg baked. Do not hot-install on the machine.
Start with call_report.json. It names the call (call_control_id, run_id, to, from_did, mode), the outcome (status, answer_text, reason), and how the call ended under hangup: path is trail when the final mark's echo and the trailing silence triggered the hangup, drain_timeout when the echo never came, failure when an error ended the call, and far_party when the callee hung up first. timing_ms gives milliseconds from the run start to each step (answered, live_active, verdict, live_closed, final_mark_sent, final_mark_echoed, hangup_sent, call_ended). counts includes marks_sent, mark_echoes, stale_mark_echoes, and output_audio_chunks_after_verdict. refs points at every other artifact.
events.jsonl is the full sequence. Every row has seq and t_ms. Rows with kind are events, and a transitions object on an event row lists the tracks whose state it changed. Rows with effect are what the runtime did in response (play_audio, mark, schedule, hangup, close_live, finish). Audio payloads appear as byte counts.
call_report.redacted.json is the shareable view. call_report.json, transcript.json, and the audio files stay local. Never send transcript.json, audio, or call_report.json to product analytics. Landing analytics stay separate. This repo has no landing code.
Live mode places a real call. It is gated so that no test or CI run can reach the network by accident.
- Copy
.env.exampleto.envand fill inTELNYX_API_KEY,TELNYX_CONNECTION_ID, andOPENAI_API_KEY. Keep.envout of git. - Start a public tunnel to
CALL_AGENT_PORT(default 8000), for examplengrok http 8000orcloudflared tunnel --url http://localhost:8000, and put that tunnel'shttps://URL in bothPUBLIC_BASE_URLandPUBLIC_WEBHOOK_URL. CLI--livehosts CallHandle in this process. Telnyx must open/media-streamand post the per-callwebhook_urlon that same host. Process env overrides.env, so a leftoverPUBLIC_WEBHOOK_URLcan send webhooks to a stale host while media followsPUBLIC_BASE_URL(Fly media + old tunnel). The verb fails before dial if webhook host != stream host after wss/https normalize, or if{PUBLIC_BASE_URL}/healthis not HTTP 200 with this process'sserve_token. Flyhttps://hidden-beacon-080.fly.devis a different process with no BOOK entry; it is not valid asPUBLIC_BASE_URLfor Mac CLI live. The verb waits until local/healthaccepts, then probes{PUBLIC_BASE_URL}/healthuntil HTTP 200 and this process'sserve_tokenmatch (and/media-streamaccepts) before it dials. A printed trycloudflare URL is not enough: DNS can lag after the URL is advertised. The Call Control applicationwebhook_event_urlis independentlyhttps://call-agent-webhooks.usetreena.workers.dev/webhooks/telnyx(always-on, laptop-off). That Worker does not terminate media:/media-streamreturns 501. Optional delivery proof through a working tunnel:POST /webhooks/telnyxwith an unknowncall_control_idreturns 404unknown callfrom this process (the Worker unsigned POST is 401). Linq registershttps://call-agent-webhooks.usetreena.workers.dev/linq/webhook(alias/webhooks/linq). See workers/webhooks/README.md. - Set
QUICK_ASK_LIVE=1. - Assign the outbound caller ID. Live CLI takes
--from-didor readsTELNYX_FROM_NUMBER/TELNYX_PHONE_NUMBER(Cap+12135690899). Kickoff ignoresusers.from_did. Confirm/dial never callsledger.provisionand neverPOST /v2/number_orders.did provision --liveis an ops verb that can order a number into thedidstable; it is not a live-call prerequisite. - Run
call-agent quick-ask --to +1... --ask hours --name Nathan --live --from-did +1.... The command hosts the two FastAPI routes in the same process, dials, bridges audio between Telnyx and GPT-Live, and prints the result when the call ends.call-agent book --to +1... --name Nathan --party 2 --time "Friday 7pm" --live --from-did +1...is the same path with aBookRequest. The later live target for book is+17272701288. Do not dial it until{PUBLIC_BASE_URL}/healthreturns HTTP 200 with this process'sserve_token. Cap GO on this fail-loud climb. HOLD REDIAL for the live book. This tree does not place that call.
call-agent verify-quick-ask --mode live runs the same eight checks against a real call. call-agent verify-book --mode live is gated the same way and still runs the stub adapters unless you change the code. Only call numbers you control until you have reviewed consent, calling-hour, and AI-disclosure rules for your destination.
Prove the Worker inbox without deploying:
call-agent verify-webhooksYou should see five [PASS] lines and 5/5 passed. Against a deployed Worker:
call-agent verify-webhooks --url https://call-agent-webhooks.usetreena.workers.devUnsigned Telnyx POSTs must be 401. If they are 200, signature verification is unwired and the lever fails.
The live path is written to the recipe. CLI --live now fails loud if PUBLIC_BASE_URL does not reach this process. A successful live book against Telnyx still needs keys, GPT-Live access, and a public media wss:// tunnel on the laptop. The always-on webhook Worker is already at https://call-agent-webhooks.usetreena.workers.dev.
.cursor/rules/pstack-models.mdc maps pstack roles to the model slugs available in Cursor cloud agents. Re-run /setup-pstack to change a role.