Voice receptionist — Path A is live, Path B is proven, the phone leg is yours

OUTPUT packet · lane 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.”
Path A · what answers todayLIVE GHL Voice AI “MLM Receptionist” on (760) 642-1892, backup mode. Unchanged since 09-11.
≈ $0.07–0.10 / min · 0 real calls in the log so far
Path B · our own stackPROVEN, NOT ON THE PHONE Full call completed in a LiveKit test room and saved a correct record. Every model runs on Nano.
≈ $0.01 / min carrier + $0 inference
What is missingPSTN One dashboard click binds the rented number to the agent. Then a real phone call is the proof.
number +1 (760) 965-5049, rented and idle
Risk while you decideNone. The 760 still rings your cell first and the GHL agent still backstops it. Nothing was changed on the live number.
Path B is entirely off to one side

What you do next — 3 steps, about 10 minutes

  1. 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.

  2. 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.

  3. 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.

Before real traffic, two one-line changes on Nano (mine, not yours — but they must happen before step 3, not before step 2): drop 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. Verified by re-reading the record, not by the 200.23:06 One agent on the location, correct number, backup mode confirmed.
  9. 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

  1. Built our own speech-to-text on Nano. A streaming zipformer running int8 under LaunchAgent com.aios.stt, websocket on :8992 with a health endpoint. Real-time factor 0.06, first partial about 60 ms.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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-223536 saved 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.
  9. 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.
  10. 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

PATH A · liveCaller(760) 642-1892your cell 1622→ ring-out →GHL Voice AIcontact note + email
PATH B · provenCaller(760) 642-1892→ forward →(760) 965-5049 · LiveKitdispatch rulebusiness-screener on Nanoown STT + local LLM + KokoroSQLite outbox → CRM intake

Everything 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

ClaimHow it was checkedEvidence
Path A still answers, untouchedGHL API read-backagent 6aa4eb4a…, inboundNumber +17606421892, backup mode on
Path A has taken a real callGHL Voice AI call logNO — 0 calls logged since 09-11
Our STT beats the reference Whisper40 utterances × 2 channels, shared normalizerstt-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 recordagent dispatched into a room, recorded caller published in, test mode onroom 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 callworker logcaller and agent turns plus per-turn latency, Nano worker log
The LLM was chosen on accuracy, not on feel3-model tool-call bench on one transcript3B 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 callsLaunchAgent com.aios.voice-keepwarmJSONL health line every 4 min across 5 services
Number rented, rule exists and points at our agentlk number list / lk sip dispatch listPN_PPN_5jVvDTMHNCyH · SDR_p6seEBH2cQ4qbusiness-screener
Number is bound to the ruleevery API field and flag variant, plus a rule-side workaroundNO — server-side failure; the workaround wrote cleanly but did not bind. Dashboard step.
A real phone call reaches the agentNOT 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.

EngineWER cleanWER phoneWhole sentences rightPhone numbers exactFirst partialFinal after speech ends
v1 — streaming zipformer, plain23.2%23.8%38% / 35%50% / 60%0.82 s~0 s
v1 + insurance hotwords19.7%18.9%38% / 38%50% / 60%0.82 s~0 s
v2 — two-pass, now shipping0.8%0.4%95% / 98%100%0.82 s0.30 s
Reference: Whisper server on M20.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

StageMeasuredNote
You stop talking → final transcript1.55–1.66 sincludes the endpointing wait before the second pass starts
Transcript → first model token0.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 out0.16–0.50 sKokoro, 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

CommandEffect
ghl-voice-agent.sh statusre-read the live agent record
ghl-voice-agent.sh logsVoice AI call log for the location
ghl-voice-agent.sh directAI answers immediately, your cell never rings
ghl-voice-agent.sh backupcurrent: AI only after the forward rings out
ghl-voice-agent.sh hours-businessMon–Fri 9–5 PT only
ghl-voice-agent.sh detachkill switch — number removed from the agent

Path B — on Nano

ThingWhere
Screener workerLaunchAgent com.aios.business-screener
Own speech-to-textLaunchAgent com.aios.stt · ws://127.0.0.1:8992/v1/listen · /health
Keep-warm and healthLaunchAgent com.aios.voice-keepwarm, JSONL every 4 min
Kill switchunassign the dispatch rule in the dashboard, or revert the GHL forward — Path A resumes instantly

Open decisions — 4

1 · Cut the 760 over to Path B, or stay on Path A
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 · session session_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.

2 · Backup mode or direct answering on the 760
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.

3 · Spanish — build the fallback lane now, or wait for the model that does both
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.

4 · Self-host the phone edge, or stay on LiveKit Cloud
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

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.