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

Contents · 9 sections
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)
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.
| Model | First token / full answer | Greek | Verdict |
|---|---|---|---|
| Gemini 3.1 Flash-Lite | 1238 / 1345 ms (grounded) | Concise, natural | The pick. Calls the calendar every time. |
| GPT-5.4 mini | 531 / 943 ms | Polished | Fastest, refused the calendar tool. Dropped. |
| DeepSeek V4-Flash | 693 / 1425 ms | Correct but wordy | Backup. Right tools, slow full loop, cheapest by far. |
| Gemini 3 Flash | 2458 / 2682 ms (grounded) | Occasionally awkward | Right tools, twice as slow as Flash-Lite. Dropped. |
| MiniMax M2.7 | 1091 / 6258 ms | Fluent, endless | 467-char answers on a phone line. Dropped. |
| Claude 4.5 Haiku | 1026 / 2557 ms | Fine | Cut before the grounded round. |
| GPT-4o mini | 1192 / 2236 ms | Fine | Cut before the grounded round. |
| Gemini 2.5 Flash | 1689 / 1718 ms | Fine | Cut before the grounded round. |
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.
debouncingsits 300ms on every transcript fragment, merging Soniox partials into one utterance before waking the model.speakingknows 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 passesplayback.until.toolingplays a pre-generated Greek filler clip (Μια στιγμή, ψάχνω τις διαθέσιμες ώρες) so a calendar lookup, mocked at a deliberate 800ms, never sounds like a dropped call.interruptingis 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.
/** 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.
Ο χρήστης σε διέκοψε εδώ — πιθανότατα άκουσε μέχρι αυτό το σημείο
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)
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
customParametersslot 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.