Case study · Real-time voice AI

Hinglish Clinic Voice Agent.
A voice receptionist that's fast, but never allowed to guess.

2026·Personal project · solo build, 8-week planIn development · week 3 of 8

Small Indian clinics take bookings by phone, usually through one busy receptionist. Callers mix Hindi and English in one sentence ("kal shaam Sharma ji ke saath slot hai?"). This agent picks up, answers clinic questions, finds free slots, and books, moves or cancels appointments. It checks who the caller is before touching their data, and it never writes anything the caller hasn't clearly confirmed.

Status, 14 Oct 2026: Weeks 1–2 are built and voice-tested. Week 3 (turn-taking, latency, the V1 demo video) is underway. Audio runs in English while the engineering is built; Hinglish audio returns in week 4. The date, time and number parsing is already bilingual. All clinic and patient data is synthetic.

217
unit and DB tests
13/13
sandboxed eval tasks · 0 wrong actions
61→99%
doctor names right on phone audio
1.2s→200ms
slot lookup after one rewrite
The V1 demo video lands in week 3, in Hinglish if possible.Watch the repo →
Why this is hard

A phone call forgives nothing. Not slowness, not a guess.

Fast

A reply should start in about 1–1.5 seconds, or the call feels broken. So every stage is timed on every turn.

Safe

No double bookings, no booking the caller didn't agree to, no personal data before verification, no medical advice.

Honest

It never invents a fact and never claims an action that didn't happen. Facts come from tools; actions come from code.

The real-time loop

Every turn, timed to the millisecond.

Audio streams through LiveKit into turn detection, speech-to-text, the LLM and text-to-speech. When the model wants data, it goes through code guards to a tool and Postgres. Nothing in a turn waits on a queue or a workflow engine. Each stage has a fallback provider. Press play: the bar runs at real speed with the measured p50s.

played at real speed · click a stage
audio back to the callertool callCallerbrowser · WebRTCLiveKit CloudIndia SouthTurn detectionSilero VAD · v1-miniSTTDeepgram nova-3LLMGemini FlashTTSCartesia sonic-3Telemetryp50 / p95Code guardsstate.check()Tools9 toolsPostgresSupabase · Mumbai
Caller stops speaking0.00 s
0target 1.5 s4.2 s
End of turn 578 msLLM first token 1313 msFirst sentence → TTS 155 msTTS first audio 205 ms

10 Oct baseline, no tools: voice-to-voice p50 2.25 s. The LLM's first token is the biggest stage (about 58%).

Measured, not guessed

Every change gets a row. Some get reverted.

English audio over a browser connection, cold first call excluded, p50 and p95 in milliseconds. Pick a run to see how each change moved each stage.

End of turn
2,500 / 2,501 ms
LLM first token
1,410 / 1,942 ms
TTS first audio
200 / 229 ms
Voice-to-voice
3,829 / 6,358 ms
Barge-in stop
379 / 1,100 ms
9 calls / 53 turns · solid = p50, faint = p95 · | 1.5 s targetAll scripted writes correct, 0 wrong actions
  • ▹Tool turns cost two LLM calls. In the 11 Oct run, turns that used a tool took about 3.9 s p50 against about 2.2 s without one.
  • ▹27 of 52 turns in the week 2 run waited the full 2.5 s end-of-turn delay. The turn detector scores phone numbers, dates and short answers ("Five thirty is fine.") as unfinished. That is now the biggest cost, and phase-aware endpointing is the week 3 fix.
  • ▹LLM first token (about 1.3–1.4 s) is the biggest stage on turns that don't wait. A model comparison (E1) is planned.
One agent, many modes

The model can only reach what this moment needs.

A single agent switches mode in place. Each mode exposes only its own tools, so a prompt injection ("cancel all bookings", "read me today's list") has nothing dangerous to call.

Click a mode
sticky · callback onlyGreetingFAQBookingReschedulingCancellingHandoff
Booking mode can call5 / 9 tools
clinic_info
find_slots
hold_slotwrites
confirm_bookingwrites
verify_caller
my_bookings
reschedule_booking
cancel_booking
request_callback

The only mode that can hold and confirm a new slot.

How a booking is written safely

The caller says yes to the row that gets stored.

The LLM proposes, code decides. Dates are resolved in code, only an offered slot can be held, the read-back is built from the database row and spoken word for word, and a second confirm call writes only after a clear yes to that exact sentence. Try the three answers.

The caller answers the read-back with:

    A write that times out returns "outcome unknown", never "failed", and it is safe to repeat because the idempotency key is the same.

    What it does today
    Live voice calls in the browser
    LiveKit Cloud (WebRTC), streaming STT → LLM → TTS
    Knowing when the caller has finished
    Silero VAD + LiveKit turn-detector model
    Barge-in
    Adaptive interruption detection; the agent stops in about 0.35–0.4 s
    Ignoring "okay", "haan", "hmm" while she talks
    Backchannel filter on the transcript words (one resume bug open)
    Clinic questions
    Hours, fees, address, doctors: only from the database
    Free-slot search
    From each doctor's weekly hours, days off and existing bookings
    "kal shaam", "parson subah", "next Monday"
    Resolved by code (agent/dates.py), never by the LLM
    Book, reschedule, cancel
    Slot holds, read-back built from the stored row, idempotent writes
    Caller verification
    Phone + name or date of birth, parsed in code, exact match
    Handoff
    Medical questions, upset callers and failed verification go to a staff callback
    Engineering decisions

    Built the way production voice agents are.

    01

    One agent with modes, not a chain of agents

    Problem ·
    A hand-off between agents on a live call costs an extra LLM round trip and loses context.
    Choice ·
    One agent switches mode in place; a mode only changes instructions and the visible tools.
    Payoff ·
    No extra latency per switch, and an injected instruction can only reach the current mode's tools.
    02

    Guards live in code

    Problem ·
    "Only book after a clear yes" in a prompt is a promise, not a guarantee.
    Choice ·
    Every tool calls state.check() against typed call state (mode, verification, pending read-back) before acting.
    Payoff ·
    The LLM proposes; code decides. A refused call is logged, not silently obeyed.
    03

    The LLM never computes dates or times

    Problem ·
    "kal shaam" or "parson subah" is easy for a model to get almost right, which is worse than wrong.
    Choice ·
    Code resolves them into a date window and passes the result back as a fact.
    Payoff ·
    Bilingual date handling covered by unit tests instead of hoped for.
    04

    The read-back is written by code, from the DB row

    Problem ·
    If the LLM paraphrases the booking, the caller's yes may not match what gets written.
    Choice ·
    The sentence the caller agrees to is built from the stored row and spoken word for word.
    Payoff ·
    A yes always covers exactly what gets written.
    05

    Double booking is the database's job

    Problem ·
    Two callers can race for the same slot; application checks have gaps.
    Choice ·
    A partial unique index on (doctor, start) for held and confirmed rows.
    Payoff ·
    Impossible by construction, whatever the app or the model does.
    06

    Idempotent writes, no blind retries

    Problem ·
    A timed-out write may or may not have happened.
    Choice ·
    Full-length idempotency keys, timeouts on every external call, and "outcome unknown" instead of guessing.
    Payoff ·
    A repeated confirm makes exactly one booking (tested).
    07

    No personal data before verification

    Problem ·
    A voice line is an easy way to fish for someone's bookings.
    Choice ·
    Phone plus name or birth date, parsed in code, exact match against one patient. A failure doesn't say which detail was wrong; two failures hand off.
    Payoff ·
    0 verification violations in evals and the voice test.
    08

    Measure before optimizing

    Problem ·
    Latency "fixes" that feel faster often aren't.
    Choice ·
    Every change gets a row in LATENCY_LOG with p50/p95 per stage and task success next to it.
    Payoff ·
    E7 looked promising and was reverted on the numbers.
    Experiments

    One measured change at a time.

    E7reverted
    Cloud turn detector (v1) vs local v1-mini

    Wrong "unfinished" waits 5 vs 6, on different sentences; voice-to-voice p95 worse.

    → Reverted to v1-mini

    E9kept
    Doctor names as STT keyterms

    Doctor names right: browser 75% → 99%, phone 61% → 99%. Other names got worse (phone 70% → 39%) and were sometimes turned into a doctor's name.

    → Kept, with scoping planned (it caused "Kabir" → "Kavita")

    find_slotskept
    4 sequential queries + new connections → 1 query, warm pool, 60 s doctor cache

    680–1,170 ms → about 200 ms.

    → Kept

    Thinkingkept
    Gemini thinking level minimal on 3.x

    0 reasoning tokens on every reply (a 2.5-style budget is silently ignored).

    → Kept

    Evals and the voice test

    Tested in a sandbox before any real call.

    SplitScenariosTask successWrong actions
    Dev1313 / 130
    Hold-out8kept unseen for final reporting

    The first run was 10 of 12. The agent was right and the fixture was wrong: the test patient had 5 bookings, not 1, and the agent correctly asked "which one?". The fixture was fixed rather than the agent, and that case became its own scenario.

    • ▹217 unit and DB tests: date resolution (English and Hinglish), the slot engine, phone and birth-date parsing, mode allowlists, guards, read-back confirmation, the backchannel filter, and DB write safety (a double hold is refused, a repeated confirm makes one booking, an expired hold frees the slot).
    • ▹21 multi-turn scenarios run the real agent and LLM in text mode against a local Postgres 17 sandbox with a frozen clock: happy paths, "yes, but change it", someone else's booking, prompt injection, medical questions, upset callers and Hinglish.
    • ▹Safety switch: EVAL_MODE=1 can only connect to the sandbox database, checked on every connection. Evals never touch real systems.
    • ▹Threat model: 12 threats mapped to the OWASP LLM Top 10, each with its code mitigation and test, plus an honest list of known gaps.
    Week 2 voice test · 14 Oct0 wrong actions
    • ✓ W1
      Book as a new patient
      Booked; the read-back matched the stored row. Name capture was hard.
    • ✓ W2
      "Yes" spoken over the read-back
      Safe: the write was refused and the read-back repeated in full, then booked. UX cost: 3 read-backs.
    • ✓ W4
      Reschedule after verification
      Nothing said before verification; the old booking is linked to the new one.
    • ✓ W5
      Cancel one of two bookings
      Asked "which one?", cancelled only that one.
    • ✓ W6
      Wrong birth date twice
      Nothing revealed; handed off to a callback.
    • ✓ W7
      Callback request
      No time promised.
    • ✓ 5 · 8
      Barge-in · doctor name
      Both passed in the week 2 voice test.
    • ✗ 10
      Soft "okay" while she talks
      The "okay" was filtered, but the paused speech didn't resume. Root cause found; fix in week 3.

    Every row in the events table matches what the caller asked for.

    Problems found and fixed

    Every real problem gets a LEARNING entry: what happened, the mechanism, the options, the choice, how it was verified, and how production systems handle it. 27 entries so far.

    #1Gemini thinking delayed the first word; the 2.5-style setting was silently ignored on 3.x→ Thinking level minimal; verified 0 reasoning tokens
    #5The agent invented clinic facts and pretended to take actions→ Facts only from tools; an honest "can't do that yet"
    #10 · 27The turn detector made the agent wait 2.5 s after complete sentences→ Measured (E7, week 2 voice); phase-aware endpointing next
    #11 · 15Soft "okay" / "haan" interrupted the agent→ Backchannel filter on the transcript words
    #13find_slots took up to 1.2 s→ 1 query + warm pool: about 200 ms
    #16"Sharma" misheard as "Harmer"→ Per-clinic STT keyterms (E9)
    #17A 5 s fallback timeout that Gemini rejects as a deadline would have broken every call→ Caught by a chaos test; timeout set to 10 s
    #19 · 22"Wait for a clear yes" was only an LLM promise→ Code checks the yes, and code writes the read-back
    #20An expired hold still blocked the slot→ Cleared inside the hold transaction
    #21 · 23Evals need isolation; a stale cache after re-seeding→ Docker sandbox, a frozen clock, a hard eval-mode switch
    #24Keyterms silently off after a refactor; spoken birth dates rejected→ Import fixed + regression test; spoken-number parsing in code
    #25"Kabir" heard as "Kavita"→ Phone + birth date find the patient, name on file read back (week 3 step 1, 14/14 in text evals)
    #26Paused speech didn't resume after a filtered "okay"; interruption quota ran out→ Root cause found; fix planned
    Stack, and why each
    Voice framework
    LiveKit Agents 1.8 (Python) on LiveKit Cloud

    Production WebRTC, built-in turn detection and barge-in, India region

    Speech-to-text
    Deepgram nova-3 (en-IN) · Sarvam saaras:v4 fallback

    Low latency, keyterm boosting for doctor names; Sarvam for Hinglish

    LLM
    Gemini 3.6 Flash (thinking minimal) · 2.5 Flash fallback

    Fast time to first token; tool calling

    Text-to-speech
    Cartesia sonic-3 · Sarvam bulbul fallback

    About 200 ms to first audio

    Turn-taking
    Silero VAD + turn detector v1-mini (pinned)

    Runs locally; pinned so latency runs are comparable

    Database
    Supabase Postgres (Mumbai) · asyncpg, no ORM

    The unique index is the final double-booking guard

    Validation
    pydantic

    Typed tool inputs, call state, telemetry records

    Tests & evals
    pytest · pytest-asyncio · Docker Postgres 17 sandbox

    Evals never touch real systems

    Tooling
    Python 3.12 · uv · ruff

    Fast, reproducible installs

    Later
    Temporal · Next.js staff view · OpenTelemetry

    Durable work off the call path; observability

    Roadmap

    Eight weeks, built in the open.

    Week 3 · in progressTurn-taking, latency, V1
    • Name and number capture (step 1 done: 14/14 in text evals)
    • Backchannel resume fix · phase-aware endpointing
    • Fewer read-back repeats · filler during tool calls · LLM comparison (E1)
    • V1 demo video (Hinglish if possible)
    Known gaps, honestly
    • ▹Voice-to-voice is 3.8 s p50 right now, well above the 1–1.5 s target. Most of it is the 2.5 s end-of-turn wait on numbers and short answers; that is week 3's main job.
    • ▹A "yes" spoken over the end of a read-back counts as an interruption and causes a re-read. Safe, but slow.
    • ▹Verification on a browser call relies only on what the caller says. On the phone line, caller ID can add a factor, but it can be spoofed and isn't proof on its own.
    • ▹No rate limit or budget cap yet on a public demo, and the agent's database role isn't least-privilege yet.
    • ▹Audio is English for now. The Hinglish parsing is built and tested in code; Hinglish speech recognition is measured in week 4.