Voice receptionist — Path A is live, Path B is proven, the phone leg is yours
voice-agent-inbound · built 2026-09-11 22:55 PT → 2026-09-13 05:30 PT · M2 + Nano · session session_01LtWQvLowfqVqMdrne2q1oT · Sean asked 2026-09-11 22:56: “a voice agent answering calls on our GHL number, 20–30 min, monthly cost OK.”≈ $0.07–0.10 / min · 0 real calls in the log so far
≈ $0.01 / min carrier + $0 inference
number +1 (760) 965-5049, rented and idle
Path B is entirely off to one side
What you do next — 3 steps, about 10 minutes
- LiveKit Cloud dashboard: Telephony → Phone numbers → +1 760 965 5049 → Assign dispatch rule →
SDR_p6seEBH2cQ4q.Why you and not the system: the API call that does this is broken on LiveKit’s side — every field-name and flag variant returns
invalid_argument, and the one workaround that did write cleanly turned out not to actually bind. The dashboard is the only lever left. - Call (760) 965-5049 from your cell and talk to it.
Why it matters: this is the only thing never yet tested. Listen for six beats — greeting, it asks your name, it reads your number back digit by digit, it asks your reason, it asks permission to pass it on, then one confirmation and it hangs up. If any beat is missing or it loops, stop and tell me which one.
- Only when you are satisfied: GHL → Settings → Phone Numbers → (760) 642-1892 → forwarding → +1 760 965 5049.
Why last: this is the cutover. Until you do it, Path A keeps answering exactly as it does now. Leave the Voice AI backup box checked — it still fires on ring-timeout no matter where the forward points, so you keep a net under the net. Revert is the same screen, old value ends 1622.
SCREENING_TEST_NONSIP=1 from run-worker.sh, and replace the "*" in allowed_trunk_ids with the real trunk id, which is only visible on the first genuine call. Step 2 needs the test flag gone too — say the word after the dashboard step and I will clear it.How it was built
Path A — 19 minutes on 09-11, get something answering
- Pulled the existing Nano research before building anything.22:57 A Nano session had already written the cost research, a call-screening audit and a staged LiveKit worker with 35 passing tests. Read those instead of redoing them.
- Proved the local speech stack.23:00 Whisper on M2 healthy, Kokoro healthy, Ollama 12 models, the OpenAI key on file invalid — which is what made the whole thing local-only by default.
- Wired the screener to LiveKit.23:01 Inbound trunk locked to carrier ranges (LiveKit refuses an open trunk), dispatch rule to agent
business-screener, worker installed as a LaunchAgent, explicit dispatch delivered a job to it. - Found which GHL sub-account owns the 760 by API, not by asking you.23:03 Three sub-account tokens exist; two are dead or 401. EROS answered 200 and its number list contains 1892, friendly name “My Legacy Management”, forwarding to the cell ending 1622.
- Discovered GHL’s Voice AI API was reachable on that sub-account.23:04 Which meant a native agent could be attached to the 760 with no new number, no carrier account and no forwarding change.
- Schema discovery, with one honest miss.23:05 A minimal probe POST intended to surface validation errors instead created an agent. Recorded as a block so it is not repeated.
- Reconfigured that agent in place.23:06 Name, business, voice, welcome line, CMS-safe message-taking prompt (no advice, no quotes, not a “specialist”, no government affiliation), notifications, backup mode, number attached.
- Verified by re-reading the record, not by the 200.23:06 One agent on the location, correct number, backup mode confirmed.
- Recorded state everywhere it has to live.23:07–23:12 Lane on both boxes, packet, control script, A0 card, session log, memory, blocks, identifiers into the operator env on M2 and Nano.
Path B — 09-12 into 09-13, own the whole stack
- Built our own speech-to-text on Nano. A streaming zipformer running int8 under LaunchAgent
com.aios.stt, websocket on:8992with a health endpoint. Real-time factor 0.06, first partial about 60 ms. - Benched it instead of trusting it — and it failed. 40 receptionist utterances in clean and telephone-band audio: 23% word error, and only half to six-in-ten phone numbers transcribed digit-perfect. A receptionist that reads your callback number back wrong is worse than no receptionist.
- Tried the cheap fix first: insurance hotwords. Medicare, Medigap, annuity, My Legacy Management and the rest. The library needed a vocabulary file nobody documents, which cost a block to find. Result: 23% → 19% word error, digits unchanged. Honest verdict: not enough.
- So the design changed rather than the tuning. Two-pass: the streaming model still does partials and decides when you stopped talking, but the final transcript is produced by Whisper large-v3-turbo running in-process on Nano’s GPU, with a 3-second timeout that falls back to the streaming text. 0.8% and 0.4% word error, 100% of phone numbers digit-exact, and the final arrives in 0.30 s — faster than the reference Whisper server at 1.10 s.
- Picked the language model by measurement, not by size. Given a realistic transcript and asked to pull out name, digits and consent: the 3B model got the digits wrong both times (fast, useless), the 27B model was right but took 14.6 s (correct, unusable on a live call), and a mid-size model was right both times in 4.1 s warm. That one won.
- Pinned it warm. A cold model is a 20-second silence on a live call. A LaunchAgent keeps it resident and writes a health line every 4 minutes across all five services.
- Abandoned the softphone route on evidence, not on frustration. Every SIP attempt from a softphone into LiveKit Cloud answered “404 No trunk found” — with allowlists, with auth, with numbers, with every trunk variant. LiveKit Cloud expects carrier-style origination. That path is dead and, as it turns out, not needed.
- Proved the whole call without a phone line. Dispatched the agent into a room and published a recorded caller into it, with an env-gated test mode in the worker. Room
call-test-223536saved a correct record: name, number, consent, category and a full reason string, with both sides of the transcript and per-turn timings in the log. - Fixed the one real bug that test exposed. The model tried to save the record before it had the reason, inferring your consent from “yes that’s right.” Now the save is refused unless name and reason are present, a deterministic normalizer owns the digits rather than the model, and the call ends after exactly one confirmation.
- Rented the number and hit the last wall. +1 (760) 965-5049, the first number free on every plan. The dispatch rule exists and points at our agent. Binding the two together fails server-side on every API variant — which is why step 1 above is yours.
Architecture — the two flows
business-screener on Nano→own STT + local LLM + Kokoro→SQLite outbox → CRM intakeEverything inside Path B after the carrier hop runs on hardware you own, at zero per-minute inference cost. The only rented pieces are the number and LiveKit’s media edge. Path A stays attached as the backstop either way — the GHL agent fires on ring-timeout regardless of where the forward points.
Proof — claim, method, evidence
| Claim | How it was checked | Evidence |
|---|---|---|
| Path A still answers, untouched | GHL API read-back | agent 6aa4eb4a…, inboundNumber +17606421892, backup mode on |
| Path A has taken a real call | GHL Voice AI call log | NO — 0 calls logged since 09-11 |
| Our STT beats the reference Whisper | 40 utterances × 2 channels, shared normalizer | stt-bench/BENCH-run3-v2.md: 0.8% / 0.4% WER, 100% digit-exact, 0.30 s vs 1.10 s |
| The screener completes a call and saves a correct record | agent dispatched into a room, recorded caller published in, test mode on | room call-test-223536: John Smith, +1 760 555 0100, consent true, existing_customer, reason “Medicare Supplement plan change notification — preferred time tomorrow afternoon” |
| Transcripts and timings are captured per call | worker log | caller and agent turns plus per-turn latency, Nano worker log |
| The LLM was chosen on accuracy, not on feel | 3-model tool-call bench on one transcript | 3B wrong digits 2/2 · mid-size correct 2/2 at 4.1 s · 27B correct 2/2 at 14.6 s |
| Models stay warm between calls | LaunchAgent com.aios.voice-keepwarm | JSONL health line every 4 min across 5 services |
| Number rented, rule exists and points at our agent | lk number list / lk sip dispatch list | PN_PPN_5jVvDTMHNCyH · SDR_p6seEBH2cQ4q → business-screener |
| Number is bound to the rule | every API field and flag variant, plus a rule-side workaround | NO — server-side failure; the workaround wrote cleanly but did not bind. Dashboard step. |
| A real phone call reaches the agent | — | NOT YET — your call to (760) 965-5049 is the only proof that counts |
Speech-to-text bench — why the design changed twice
Deepgram-API compatible (added 06:30 PT 09-13). aios-stt answers Deepgram's own wire protocol on /v1/listen (Results, UtteranceEnd, Metadata; KeepAlive, Finalize, CloseStream). Proof: LiveKit's official Deepgram plugin pointed at base_url=http://127.0.0.1:8992/v1/listen returned 15 interim and 1 correct final transcript with END_OF_SPEECH. So "own Deepgram" is literal: a drop-in, self-hosted replacement, $0 per minute.
| Engine | WER clean | WER phone | Whole sentences right | Phone numbers exact | First partial | Final after speech ends |
|---|---|---|---|---|---|---|
| v1 — streaming zipformer, plain | 23.2% | 23.8% | 38% / 35% | 50% / 60% | 0.82 s | ~0 s |
| v1 + insurance hotwords | 19.7% | 18.9% | 38% / 38% | 50% / 60% | 0.82 s | ~0 s |
| v2 — two-pass, now shipping | 0.8% | 0.4% | 95% / 98% | 100% | 0.82 s | 0.30 s |
| Reference: Whisper server on M2 | 0.0% | 0.4% | 100% / 98% | 100% | — | 1.10 s |
40 scripted receptionist utterances — names, phone numbers, reasons, confirmations, times — each rendered clean and through a telephone-band filter. Paired cells are clean / phone. Word error is measured after a shared normalizer, so no engine is rewarded for punctuation. v1’s final is near zero because the streaming decoder has already emitted its text; v2 spends 0.30 s on the second pass and buys a 22-point error drop and every digit. First partial is measured from the first audio frame and is bound by the harness’s chunk size, which is why it is identical across v1 and v2 — the model’s own first partial is about 60 ms at a real-time factor of 0.06. Raw runs: stt-bench/BENCH-run1-greedy.md, BENCH-run2-hotwords.md, BENCH-run3-v2.md, results-run*.json.
Latency chain — measured on the end-to-end test
| Stage | Measured | Note |
|---|---|---|
| You stop talking → final transcript | 1.55–1.66 s | includes the endpointing wait before the second pass starts |
| Transcript → first model token | 0.65–5.9 s median ~2.4 s | the widest and least predictable link; the 27B model would have made this 14 s |
| Model → first audio out | 0.16–0.50 s | Kokoro, local |
So a typical turn lands around 2.5–4.5 s and a bad one near 8 s. That is the honest number and it is the thing your test call should be judged on as much as the wording. If the pauses feel long, the lever is the model tier, and that is a measured trade against digit accuracy — the fast model got the phone number wrong every time.
Controls
Path A — run from M2
| Command | Effect |
|---|---|
ghl-voice-agent.sh status | re-read the live agent record |
ghl-voice-agent.sh logs | Voice AI call log for the location |
ghl-voice-agent.sh direct | AI answers immediately, your cell never rings |
ghl-voice-agent.sh backup | current: AI only after the forward rings out |
ghl-voice-agent.sh hours-business | Mon–Fri 9–5 PT only |
ghl-voice-agent.sh detach | kill switch — number removed from the agent |
Path B — on Nano
| Thing | Where |
|---|---|
| Screener worker | LaunchAgent com.aios.business-screener |
| Own speech-to-text | LaunchAgent com.aios.stt · ws://127.0.0.1:8992/v1/listen · /health |
| Keep-warm and health | LaunchAgent com.aios.voice-keepwarm, JSONL every 4 min |
| Kill switch | unassign the dispatch rule in the dashboard, or revert the GHL forward — Path A resumes instantly |
Open decisions — 4
- Provenance
- Agent-proposed. Follows from your 09-11 brief (“monthly cost OK” set the ceiling, not the floor); nothing here was requested outright.
- Identifier
- lane
voice-agent-inbound/ Path B cutover · M2 + Nano · sessionsession_01LtWQvLowfqVqMdrne2q1oT - Last touched
- 2026-09-13 ~05:30 PT — end-to-end test passed, number rented, binding blocked
- Summary
- Path B answers with our models at about a seventh the per-minute cost and total control of prompt, voice and data.
It has completed a real call in a test room but never over a phone line.
The cutover is one field in the GHL UI and is reversible in the same field. - What approving does
- Routes live callers who do not reach you to our own agent instead of GHL’s. Reversible in one UI edit; the GHL agent remains as ring-timeout backup either way.
- Ownership
- SeanAssign the dispatch rule in the LiveKit dashboard
- SeanCall (760) 965-5049 and judge it
- SeanEdit the GHL forwarding field — only if satisfied
- SystemClear the test flag and pin the real trunk id
- SystemWatch the first real calls and report transcripts and timings
- SystemRoll back on your word
- Before / after
- Now: GHL Voice AI answers after ring-out, $0.07–0.10/min, prompt and data inside GHL, 0 calls handled.
After: our agent answers, $0.01/min carrier and $0 inference, transcript and record on our disk, GHL still backstops. - Impact
- Cost per minute drops roughly 7–10×, which is small in dollars at pilot volume and large if this ever fronts a campaign. The real gain is that the prompt, the voice, the Spanish path and the CRM write all become things we can change in minutes. Risk is a worse-sounding call, which step 2 is designed to catch before anyone but you hears it.
My read: do the dashboard step and the test call now, and hold the cutover until you have heard it. The cost argument is not what should decide this; the fact that Path A has answered zero calls in two days means you have no evidence either stack is good, and Path B is the one we can actually fix.
- Provenance
- Agent-proposed default, carried unresolved from the 09-11 packet. Your brief said “answering calls” without saying whether you want to be rung first.
- Identifier
- lane
voice-agent-inbound/ answer mode · first staged 2026-09-11 23:06 PT - Last touched
- 2026-09-11 23:06 PT — set to backup and verified. Untouched since.
- Summary
- Today every call rings your cell first and the agent only picks up if you do not.
Direct mode means the agent is the receptionist and you never get a live ring.
This applies whichever path is behind the number. - What approving does
- One API call to flip the mode. Reversible by the same call. Nothing else changes.
- Ownership
- SeanHear one call, then say backup or direct
- SystemFlip it and verify by re-reading the record
- Before / after
- Now: caller waits through your ring-out, roughly 20–25 s, before anyone speaks.
After: caller is greeted in about a second and you never get the live ring. - Impact
- Direct mode is a much better caller experience and costs you the chance to take the call yourself. Not gauged in dollars — it depends on how often you would actually have answered.
My read: stay on backup. You are the better first contact and the agent is worth more as a net than as a gate. Revisit only if you find yourself missing calls you wanted.
- Provenance
- Agent-proposed, from research in
EN-ES-PLAN.md. Path A currently promises a Spanish-speaking callback rather than handling the call. - Identifier
- lane
voice-agent-inbound/ EN-ES ·EN-ES-PLAN.md, research 2026-09-12 - Last touched
- 2026-09-12 21:40 PT — research written, nothing built, no models downloaded
- Summary
- No streaming Spanish model exists in the family we are already running, so v1 Spanish means a second, older engine on a second port — workable, noticeably weaker.
The model that does English and Spanish in one stream with auto-detection exists but its conversion path is unproven and unbenched.
Text-to-speech in Spanish works today with a quality caveat on proper nouns. - What approving does
- Authorises a bench run on Nano, roughly an hour of GPU time, before any code is written. Nothing goes live.
- Ownership
- SeanSay whether Spanish callers are frequent enough to matter now
- SystemBench the bilingual model on Nano and report
- SystemBuild whichever path the bench supports
- Before / after
- Now: a Spanish caller hears an English greeting and is promised a callback.
After: a Spanish caller is screened in Spanish, or we know for certain it is not worth it yet. - Impact
- Not gauged. It depends entirely on your Spanish-speaking call volume, which we have no data on because no calls have come in.
My read: bench it, do not build it. An hour on Nano tells us whether the good path is open, and that answer does not go stale. Building the weaker fallback now is work we would throw away if the bench comes back clean.
- Provenance
- Agent-proposed, from
SELF-HOST-EDGE-PLAN.md. It is the one piece of Path B we do not own, so it was worth pricing rather than assuming. - Identifier
- lane
voice-agent-inbound/ edge ownership · plan written 2026-09-12 - Last touched
- 2026-09-12 21:39 PT — decision table complete, no action taken
- Summary
- Everything except the carrier hop and the media relay already runs on our hardware.
Self-hosting that last leg saves about $40–50 a month and costs 3–15 hours a month of upkeep.
It also gives up the turn-taking, interruption and transfer handling we currently get free. - What approving does
- Nothing today. This is a written trigger condition, not an action — it says we revisit at roughly 3–4 thousand minutes a month.
- Ownership
- SeanAgree the trigger, or override it if owning the edge matters for its own sake
- SystemTrack monthly minutes and raise it when the trigger is hit
- Before / after
- Now: the media edge is rented and the decision is open-ended.
After: the decision is closed with a number attached and stops consuming attention. - Impact
- About $40–50 a month of savings deferred, against 3–15 hours a month of operations work not taken on. At pilot volume that trade is not close.
My read: stay on Cloud and accept the trigger. The instinct to own the stack was right for speech and reasoning, where owning it bought accuracy and zero marginal cost. The SIP edge is plumbing — owning it buys latency risk and a pager.
Also done, not asked for
- A reusable speech benchmark kit — 40 receptionist utterances, clean and telephone-band, shared normalizer, digit-exactness scoring. Any future speech engine gets judged against the same ruler instead of an impression.
stt-bench/ - Six blocks recorded so none of these walls costs an hour twice: the GHL probe that creates, the LiveKit command wrappers, the softphone dead end, the undocumented vocabulary file, the broken number binding, and a private-directory requirement in the record store.
- Two research documents that close questions rather than open them: the English/Spanish options table, and the self-hosting decision table with a numeric trigger.
- Exact click-paths written down for both UI steps, including the revert value, because neither has an API.
Files: comms/outputs/2026-09-11-voice-agent-lane/ — this page, README.md, agent-config.json, ghl-voice-agent.sh, GHL-FORWARDING.md, NUMBER-BINDING.md, EN-ES-PLAN.md, SELF-HOST-EDGE-PLAN.md, stt-bench/. Worker, models and LaunchAgents live on Nano. Path B artifacts named in the proof table were produced and read on Nano during the 09-12/09-13 session; this page was assembled on M2 and does not re-verify them.