July 21, 2026

Voice interfaces

Nine States for One Phone Call

A Greek voice agent for Narusec's call-center product. Soniox listens, Gemini thinks, Google's TTS answers, and an XState machine decides who's allowed to talk. The first turn-taking loop was a pile of timers and flags; the rewrite gave every moment of a phone call its own named state.

  • TypeScript
  • Bun
  • XState
  • Soniox
  • Gemini
  • WebSockets
Line-art mascot: Foni, a smiling retro rotary telephone lifting her handset
Contents · 9 sections
  1. Context
  2. Choosing Parts With a Stopwatch
  3. Architecture
  4. The State Machine
  5. Getting Interrupted
  6. When the Model Lies
  7. What a Call Costs
  8. Transcripts and Intent
  9. Where It Stands

Context

Narusec builds a Greek voice agent for call centers, and the first job we gave it is the least glamorous one in telephony: answer the phone and book an appointment. Managed voice platforms treat Greek as an afterthought, so we assembled the pipeline from parts. Soniox for the ears (the best Greek word error rate we measured: 7.4%), Gemini 3.1 Flash-Lite for the brain, Google's Gemini TTS for the voice, and a WebSocket in front that speaks the same media-stream shape VoIP providers use, so a browser mic and a phone line look identical to the server.

The test business is a fictional Athens barbershop. The agent greets you as ο ψηφιακός βοηθός της Ρούλας, checks a calendar, offers slots, books, and hangs up. The calendar behind it is deterministic and fake on purpose: every run costs real API money, so the only part allowed to be unpredictable is the part we're actually testing.

Choosing Parts With a Stopwatch

Before the agent existed we built the thing that would pick its parts: a live eval harness where every candidate model answers the same scripted Greek booking scenarios behind the same mocked tools. Every run is a paid measurement against the real APIs, so the shortlist stayed deliberately narrow. Latency came first.

The grounded round: latency with live tool calls (ms)

Two runs per model against the same mocked calendar, April 2026. GPT-5.4 mini's bars are missing something: it never called the tool.

Raw speed nearly picked the wrong winner, and raw speed alone hides the shape of an answer. MiniMax reached its first token in a reasonable second, then kept talking for six: its answers averaged 467 characters where everyone else stayed near 100 or 200, and a phone call has no scrollbar. The grounded round, same scenarios but with the calendar tools mocked live, is where the real sorting happened: GPT-5.4 mini stayed fastest and made exactly zero tool calls across every run, answering availability questions without ever checking availability. DeepSeek hit its first token in 0.8s but needed 4.3s to finish a tool loop. Gemini 3.1 Flash-Lite took 1.2s to first token, called the calendar every single time, passed all four booking scenarios, and its share of a full scenario came to about a tenth of a cent.

ModelFirst token / full answerGreekVerdict
Gemini 3.1 Flash-Lite1238 / 1345 ms (grounded)Concise, naturalThe pick. Calls the calendar every time.
GPT-5.4 mini531 / 943 msPolishedFastest, refused the calendar tool. Dropped.
DeepSeek V4-Flash693 / 1425 msCorrect but wordyBackup. Right tools, slow full loop, cheapest by far.
Gemini 3 Flash2458 / 2682 ms (grounded)Occasionally awkwardRight tools, twice as slow as Flash-Lite. Dropped.
MiniMax M2.71091 / 6258 msFluent, endless467-char answers on a phone line. Dropped.
Claude 4.5 Haiku1026 / 2557 msFineCut before the grounded round.
GPT-4o mini1192 / 2236 msFineCut before the grounded round.
Gemini 2.5 Flash1689 / 1718 msFineCut before the grounded round.
The full roster. Plain times are the LLM-only bench (four runs each); rows marked grounded were measured with live mocked tool calls. Latency and tool behavior are measured; the Greek column is what we wrote down while reading the transcripts (the harness itself only asserts the reply IS Greek).

Architecture

One call, one socket, one machine

The sentence buffer is why the agent starts talking before the model has finished thinking: the first complete sentence goes to TTS while the rest of the answer is still streaming in. That one decision buys most of the perceived speed.

The State Machine

Our first version was an imperative loop: a 400ms setTimeout debounce, a pile of booleans, and a generation counter for throwing away stale work. Every new bug was a timing bug, and every fix was another flag. We threw it out and rebuilt the session as an XState machine with nine named states: idle, greeting, debouncing, generating, speaking, interrupting, tooling, ending, cleanup. The old loop is still in the tree, unused. The machine version has survived contact with real conversations; the loop didn't.

  • debouncing sits 300ms on every transcript fragment, merging Soniox partials into one utterance before waking the model.
  • speaking knows when the bot goes quiet by arithmetic, not by callback: each outgoing chunk advances a wall-clock estimate, and the state re-checks itself until the clock passes playback.until.
  • tooling plays a pre-generated Greek filler clip (Μια στιγμή, ψάχνω τις διαθέσιμες ώρες) so a calendar lookup, mocked at a deliberate 800ms, never sounds like a dropped call.
  • interrupting is the reason the machine exists. It gets its own section.

The playback estimate deserves a second look, because it's the load-bearing hack: the client never reports that audio finished playing. The server just adds up chunk durations into playback.until, and every question that matters (is the bot audible, is the turn over, is this caller barging in) becomes one comparison against the clock.

Getting Interrupted

Not every sound is an interruption. A barge-in has to be longer than three characters, above 0.7 STT confidence, and it has to get past a regex whose entire job is to refuse μμμ and εεε as speech, because Greek callers backchannel constantly and the bot shouldn't stop mid-sentence for an agreeable hum. One moment is deliberately rude in the other direction: while the agent is reading back a booking confirmation, barge-in is disabled, so the one sentence that commits a slot is heard whole.

src/call-session/constants.ts (trimmed)ts
/** Greek filler/backchannel sounds that should never
    trigger barge-in or generation. */
export const FILLER_SOUND_RE = /^[μαεωχ]+$/i;

/** The model claiming a booking it never made. */
export const BOOKING_HALLUCINATION_RE =
  /(?:έκλεισε|σε έβαλα|σε βάζω|επιβεβαιώθηκε|κλείστηκε|τα λέμε.*τότε)/i;

When a real interruption lands, the machine doesn't just cut the audio. It goes back into the chat history and rewrites its own last turn down to the sentences that actually made it out of the speaker, then appends a note telling the model where it was cut off. The next answer starts from what the caller heard, not from what the model said.

Ο χρήστης σε διέκοψε εδώ — πιθανότατα άκουσε μέχρι αυτό το σημείο
— the note injected into the model's own last turn on barge-in

When the Model Lies

Sometimes Gemini announces a booking it never made. It says τα λέμε τότε with the confidence of someone who did the work, and no book_appointment call exists anywhere in the turn. That second regex above watches every reply for exactly this family of phrases, and when it fires without a matching tool call, the machine sends the model back to actually make the booking before anything reaches the caller's ear.

A flag caps the correction at one retry per turn, so a stubborn hallucination can't loop. In the eval suite this guard is scored alongside task success: a model that books without saying so and a model that says so without booking are both failures, just differently embarrassing ones.

What a Call Costs

Every call prices itself. The cost tracker reads real usage metadata from each provider (audio seconds for STT, per-turn tokens for the LLM, characters for TTS), applies published pricing, and writes the summary to SQLite when the call ends. The browser client fetches it after hangup and shows you what the conversation you just had cost, in dollars, split by organ.

TTS transport, one full answer (ms)

Measured on MiniMax speech-2.8-turbo with sentence-buffered input. The live build now speaks through Google's Gemini TTS.

Transport experiments like this one are where the eval harness paid for itself twice: the same numbers that picked the LLM also settled how to feed the TTS, and both decisions are written down next to the data instead of living in somebody's memory.

Transcripts and Intent

Everything above is the flashy half. A call-center product earns its keep after the call: the agent holds the complete transcript of every conversation, both sides, the same history the model reads, and per-call cost summaries already land in SQLite the moment a call ends. Transcript storage and intent tagging sit on those same rails.

That's the part a call center actually buys. Not that a bot can talk, but that every call comes back as a record of who wanted what, with a price tag attached.

Where It Stands

Working prototype. Try Foni in the browser: the full loop runs today in Greek with real STT, model, and voice APIs, barge-in included, plus the per-call cost rail at the end. The calendar behind it is still mocked. The demo currently runs on our own keys; rate limiting or bring-your-own-key sessions are the next cost-control step.

  • Swap the mocked calendar tools for a real booking backend.
  • Add rate limiting or use the wire protocol's existing customParameters slot for bring-your-own-key sessions.
  • Put a proper viewer on the transcript and intent side of the store.
  • Pick up a real phone line: the media protocol is already the shape VoIP providers speak.