Voice interfaces

Nine States for One Phone Call

A Greek appointment-booking prototype for Narusec. Soniox listens, Luna writes, and Gemini TTS speaks. I replaced the turn-taking loop with nine XState states to make transcript acceptance, interruption, and playback explicit.

  • TypeScript
  • Bun
  • XState
  • Soniox
  • OpenAI
  • Gemini TTS
  • WebSockets
Line-art mascot: a smiling rotary telephone lifts its handset in a tube arm while three sound marks ring out
Contents · 11 sections
  1. Call the Agent
  2. Context
  3. Choosing Parts With a Stopwatch
  4. The Live Route
  5. Architecture
  6. The State Machine
  7. Getting Interrupted
  8. The Booking Guard
  9. Ending a Call
  10. Putting the Call Together
  11. What a Call Costs

Call the Agent

The quickest explanation is the working loop itself. Speak Greek into your browser mic; replies come back as PCM audio over one media WebSocket. This is the session code the article describes, with a mocked calendar and bookings. No SMS is sent. It runs on my keys, so sign in for two calls of up to two minutes each. One live call at a time.

Voice agent

Η Ρούλα

Athens barbershop · Greek

Time left02:00
OutputLine idle
InputMic off
API cost estimate

Estimate appears after hangup

Loading sign in…

Context

Narusec builds a Greek voice agent for call centers. The first job we gave it was to answer a call and book an appointment. We assembled the pipeline from parts: Soniox stt-rt-v5 for transcription, GPT-5.6 Luna on OpenAI's Fast tier for replies, and Gemini 3.1 Flash TTS for speech. That's the configuration I selected below. The browser demo uses a telephony-style media envelope; connecting a phone provider still needs a transport adapter.

The test business is a fictional Athens barbershop. The agent greets you as ο ψηφιακός βοηθός της Ρούλας, checks a fixed set of slots, and returns a mock booking confirmation. Keeping availability fixed makes it easier to compare conversations across runs that each cost real API money.

Choosing Parts With a Stopwatch

Before settling on the model, I built a harness around the real APIs. Each saved run used the same prompt, tools, date, and Greek script for every candidate: transcription repair, ordinary conversation, prompt injection, a fake booking claim, calendar-and-booking flow, and repeated abuse. The script made up to ten LLM requests; failed tool gates skipped later steps. Providers and tiers were pinned, with routing fallback disabled. Each result kept transcripts, tool calls, usage, timings, and hashes of the prompt, tools, and model config.

Fast replies, good replies: all eight finalists

Dialogue qualityHigher is better · 0–100

First textLower is better · 0–6,000 ms

  • GPT-OSS / Groq7/14 checks passed
    Dialogue quality31 / 100
    First text189 ms
  • GPT-OSS / Cerebras4/10 checks passed
    Dialogue quality39 / 100
    First text431 ms
  • Luna / noneSweet spot13/15 checks passed
    Dialogue quality88 / 100
    First text667 ms
  • Luna / low13/15 checks passed
    Dialogue quality89 / 100
    First text829 ms
  • Terra / none6/10 checks passed
    Dialogue quality70 / 100
    First text866 ms
  • Qwen3.814/15 checks passed
    Dialogue quality77 / 100
    First text953 ms
  • Gemini 3.714/15 checks passed
    Dialogue quality81 / 100
    First text1,355 ms
  • Grok 4.612/15 checks passed
    Dialogue quality55 / 100
    First text5,635 ms
Dialogue quality comes from one blind agent review of transcripts; no audio was rated. First text is the median time to visible text. Checks passed are separate deterministic checks. Failed tool gates shortened some runs, changing check counts and latency samples; silent replies have no first-text sample. One exploratory run, August 29, 2026.

Latency came first but never got the final vote. GPT-OSS on Groq reached visible text in a median 189 ms and cost $0.00174 across nine LLM requests, yet passed only 7 of 14 checks. Grok took a median 5.6 seconds and had the most expensive run. I put the saved conversations into a blind packet with model names, providers, latency, and cost removed. An agent reviewed the transcripts for naturalness, coherence, repair, tact, suitability for speech, and tool transitions. Groq scored 31/100, Cerebras 39, and Grok 55. Speed alone hadn't bought a usable conversation.

ConfigurationDialogue qualityFirst textLLM run costDecision
GPT-5.6 Luna / Priority / none88 / 100667 ms median, 831 ms P90$0.00260Picked for the LLM route; abuse escalation still failed.
GPT-5.6 Luna / Priority / low89 / 100829 ms median, 1,015 ms P90$0.00313Similar dialogue, slower reasoning path.
Gemini 3.7 Flash / Priority / low81 / 1001,355 ms median$0.02573Borderline: one truncation and awkward Greek.
Qwen3.8 2.4T / Modal / low77 / 100953 ms median$0.01332Borderline: lively, weak repeated-abuse boundary.
GPT-5.6 Terra / Priority / none70 / 100866 ms median$0.01736Borderline: incomplete booking flow and weak escalation.
Grok 4.6 / Priority / low55 / 1005,635 ms median$0.07392Rejected: rigid persona and incoherent booking transition.
GPT-OSS 120B / Cerebras / low39 / 100431 ms median$0.00320Rejected: poor repair, booking flow, and boundaries.
GPT-OSS 120B / Groq / low31 / 100189 ms median$0.00174Rejected: unnatural Greek and silent tool transitions.
One blind agent review of transcripts, August 29. Dialogue scores assess suitability for speech; no audio was rated. Cost covers executed LLM requests (7–10 per run). First text covers replies with text. These exploratory results aren't a general model ranking.

An earlier August 28 round had picked Gemini 3.1 Flash-Lite: 452 ms median first text, 555 ms to complete the LLM response, and 12 of 15 hard checks. The broader August 29 field put both Luna configurations at the top of the transcript review, though both still failed to end the call after repeated harassment. Their one-point dialogue gap was too small to decide on in one review. Reasoning none reached text 162 ms sooner at the median and 184 ms sooner at P90, with lower cost. That was enough to break the tie for this prototype.

The Live Route

Luna transport and service-tier check (ms)

Median across ten scripted LLM requests, one run per configuration, August 29, 2026. Excludes STT, TTS, tool execution, and playback. A route check for this workload, with no cold-start guarantee.

This A/B followed the model decision and compared transport and service tier. Fast reported the expected priority tier and reached median first text in 492 ms. It passed 12 of 15 hard checks where the other routes passed 13. One run couldn't establish a quality difference between tiers, so I chose Fast for the lower observed latency. Abuse escalation remained unresolved in all three: the live session still relies on the model to request end_call. A deterministic escalation policy hasn't been implemented.

Architecture

From caller audio to spoken reply

The sentence buffer lets the agent start talking before a slower answer is complete. Once the first meaningful sentence is ready, I allow 150 ms for the rest of the reply to arrive. If it finishes in that window, the whole reply becomes one TTS request. Otherwise the lead goes first and the remaining text becomes one second request when generation finishes. Short acknowledgements wait for more text while the reply is still arriving. The two-request limit applies to each generation; a tool result can start another one.

The State Machine

The first version was an imperative loop with a 400 ms setTimeout debounce, booleans, and a generation counter for stale work. Timing fixes kept adding flags. I moved the control flow into nine XState states, with explicit transitions for accepting caller text, interrupting a reply, and ending the session. The old loop remains as call-session.old.ts in the original Narusec repo. The new machine still needs a generation counter and a playback clock; the states make their transitions easier to follow.

  • greeting queues prerecorded audio, then passes through speaking to idle when that audio is estimated to have finished.
  • debouncing collects caller text, but only accepted final text starts generation; a 300 ms pause with partial text returns to idle to keep waiting.
  • generating runs the LLM and streams its speech through TTS, so the bot can already be audible here.
  • speaking waits for the estimated playback tail after synthesis finishes, then returns to idle or starts shutdown.
  • tooling runs local tools after the model's preamble has been synthesized, with an 800 ms mock delay per batch; the preamble may still be playing.

A normal reply

  • Happy path

The playback clock is an estimate. Each outgoing PCM chunk adds its duration to playback.until, starting from the later of now or the previous queue end. The browser schedules audio on its own clock and sends no playback acknowledgement. I use the server estimate to wait for queued speech and distinguish a caller continuing before audio starts from someone interrupting it. Transcript guards and confirmation protection still decide whether the interruption is accepted.

Getting Interrupted

Not every sound is an interruption. During greeting or speaking, a Soniox partial with three speech characters is enough, so ναι and όχι can cut playback before a final transcript arrives. During generating, the current partial threshold is four characters, even if audio is already flowing. Finals need at least three speech characters and confidence above 0.7. These checks count letters and digits and reject filler sounds such as μμμ and εεε.

After the caller interrupts

  • Happy path
  • Barge-in

When an interruption lands, I clear the browser's audio queue, invalidate the old generation, and annotate the assistant history with the text prepared for synthesis so far. That list can include words the caller never heard: it has no sentence timings or playback acknowledgements. The note gives the next response interruption context, with an approximate account of the interrupted reply.

Ο χρήστης σε διέκοψε εδώ
— the interruption marker added to assistant history

The Booking Guard

Before text reaches TTS, a lexical guard checks known Greek booking phrases such as κλείστηκε and επιβεβαιώθηκε, with a check for negation. Without a successful book_appointment result, a detected claim is suppressed and the machine gives the model one retry. The guard covers those phrases; it can miss a different wording and doesn't compare the spoken date, time, or name against the tool result.

After a successful mock booking, the confirmation turn blocks speech-triggered barge-in while audio is estimated to be playing. The booking result already exists by then; this gives its readback room to finish. There's a tradeoff: spoken corrections during that protected audio are ignored. The caller can still hang up at any point.

Ending a Call

An agent-requested ending waits for queued speech. The end_call tool sends the session back to speaking with an end request; once estimated playback finishes, ending waits 200 ms before cleanup. A caller stop or disconnect reaches cleanup directly from whichever state is active.

Finish speaking, or stop now

  • Ending

Putting the Call Together

One caller turn can take several passes through Respond: Luna may request a tool, read its result, and continue the reply. Gemini streams speech as text becomes ready. After playback, the call returns to Listen; an accepted interruption gets there sooner, with the caller's new text retained.

How the call flows

  • Happy path
  • Barge-in
  • Tools
  • Ending

What a Call Costs

At hangup, I store a per-call API cost estimate in SQLite. Soniox supplies processed audio time. For the LLM, I use reported cost where available or price reported tokens at configured rates; missing usage falls back to a marked estimate. Cloud TTS uses a local count of submitted text, including its style prompt, and an estimated character rate. The browser fetches the STT, LLM, and TTS breakdown after disconnect.

The summary is a snapshot taken when cleanup starts, so a cut-off call can miss usage that arrives afterward. Telephony and hosting charges are outside it. I use the breakdown to watch API spend while tuning the conversation, with those limits visible.