Engineering · Field notes from building KUNI
The Railroad Pattern: How to Make an AI Receptionist Sound Human
A field report from building KUNI — the AI receptionist that answers calls, texts, and WhatsApp for small businesses.
You've heard it. You call a business, an AI picks up, and it says:
“Got it.”
…and then, eight seconds later, it finally says the actual thing you needed to hear.
Or it talks over you. Or you finish your sentence and it just… goes quiet. Or you ask a follow-up question and it plows right past it into the next scripted step. Every one of those moments is the instant a caller stops trusting the machine — and most people assume it's because “the AI isn't smart enough yet.”
It isn't. After building a production voice engine that has handled real calls across doctors' offices, restaurants, salons, and home-services businesses, we can tell you the uncomfortable truth: the model is usually smart enough. What's broken is the order of operations. The AI answered before it had the facts, or spoke twice, or got interrupted mid-sentence by its own plumbing.
The thing that separates an AI receptionist that sounds like a person from one that sounds like a chatbot isn't a bigger prompt or a smarter model. It's an engine that refuses to jump the rails. We call the pattern the railroad, and this is how it works.
Why "AI receptionists" mostly sound robotic
Under the hood, most AI phone agents are a loop: transcribe what the caller said (STT) → ask a large language model what to say next → speak it (TTS) → repeat. Bolt on a few “tools” (book an appointment, look up hours) and you have a demo that nails the happy path.
The demo always works. The hard cases are where it falls apart, and they cluster into six recognizable failure modes:
- Cut-off mid-utterance — the agent gets interrupted by its own pipeline (an end-of-call signal, a re-render) and clips a sentence in half.
- Back-to-back utterances — the agent says two things in a row for one turn, because two different code paths both decided to generate a reply.
- Cold-context answers — it responds before the result of the thing it just did (a booking, a lookup) has actually landed, so it answers against stale information.
- Dead air — it finishes a thought, but no code tells it to take the next step, so the line just goes silent for 15–20 seconds.
- Ignored questions — the caller asks something mid-flow and the agent barrels past it to the next scripted node.
- Robotic pauses — timing gaps that don't match how humans actually take turns.
Here's the pattern in that list: not one of them is a “the model said something dumb” problem. They're all timing and ordering problems. The words were fine. The sequence was wrong.
That reframing is the whole game. If the failures are about order, the fix is an architecture that guarantees the order — regardless of which LLM is driving.
One caveat up front: this post is about the mechanics of turn-taking — the timing and ordering that decide whether a call feels human. Voice quality, prosody, accent, and persona are a real and important layer too — just a separate one we're not covering here. When we say “sound human,” we mean the receptionist takes turns like a focused person: it doesn't clip you, talk over you, or leave you sitting in silence.
The metaphor: a conversation is a train on rails
Picture the live call as a train running on rails. Every time the agent moves — it picks up the call, it answers a turn, it crosses from one step of the workflow to the next — the train pulls through the same fixed, ordered sequence of stations:
set up the context → settle the prior work → decide the opening → speak exactly once → hand back to the caller.
- The train is the live audio pipeline.
- A station is one ordered step the engine performs when the agent acts.
- The rails are the invariants that must hold at every station: tight causality, one generation per turn, speak once, never tear down the pipeline mid-station.
- A guard is a small, deterministic check that enforces a rail at a specific station.
A train that stays on its rails is a focused receptionist. A train that jumps the rails is a chatbot: it answers before it has the facts (cold context), it talks over itself (double-utterance), it stalls (dead air), it blows past the passenger's question. The railroad exists so those can't happen by construction — not because we prompt-engineered the model to be careful, but because the engine physically won't let the train leave a station out of order.
The core move: stop fusing "set up the step" with "speak the step"
The foundational insight came from one nasty production bug (the offending call was the “Got it… [8 seconds] …here's your answer” clip).
The old code did something that looks completely reasonable: when the agent moved to a new step in the conversation, the same function that set up that step also immediately spoke it. Set up and speak, fused into one action.
That fusion is the bug. Setting up a step and speaking it are two different concerns:
Set up the step
Compose the step's instructions and tools; apply its voice settings. Generates nothing.
Open the step
Decide and fire the opening line — a fixed spoken greeting if one is configured, otherwise one model generation against the fully-committed context.
When they're fused, the opening fires against a cold context — before the result of the last action (the booking, the lookup) has committed. And worse: the underlying framework, seeing the result land a moment later, fires its own second generation against the now-correct context. Two replies per turn, spoken back-to-back — one wrong, one right. That is exactly the “Got it… then the real answer” artifact, and the double-utterance, and the cold answer, all from a single architectural mistake.
The fix is to decouple them. Setting up a step queues no speech. A separate, explicit station decides the opening and fires it once, at the one correct moment: after the prior result is committed to context. The rule, in one line:
One model generation per step, fired explicitly by the engine, against the fully-committed context — and no boundary may tear the pipeline down while a station is still executing.
Everything human-feeling about the call falls out of holding that single rule.
Every production bug became a rail
Here's the part that turned a one-off fix into a philosophy. Once we had the railroad, each new failure we hit in production didn't get a band-aid — it became a new rail, an invariant the engine enforces so that whole class of bug can never come back:
- Speak-once guard — if the previous generation already spoke this turn, the opening is suppressed. No double-utterance, ever.
- Must-hear guard — a read-back step (spelling an email back, confirming an address) will not advance until it has actually finished being heard. So the agent can't email an unconfirmed address because a stray “yes” fired the transition early.
- Don't-re-ask — if a value is already known (the caller gave it, or we remember them), the engine skips the question instead of asking again.
- Don't-stall — if a step ends with speech but nothing tells the train to move, a deterministic force-advance kicks in instead of dead air.
- Don't-cancel-mid-station — a barrier that refuses to tear the pipeline down while an opening is still being composed or spoken. (This one is our sharpest; it's what kills the cut-off-mid-sentence artifact.)
- Settle-before-persist — on text channels, background work (extracting the address the caller just typed) is drained before the turn is saved, so nothing gets silently dropped.
- Answer-then-advance — if the caller asks something mid-step, the engine answers the question and re-offers the current step before moving on, instead of barreling past it to the next node. That's the ignored-questions failure mode, closed.
- Natural pacing — turn-taking timers (endpointing) are tuned per step so the agent waits for a caller who's still mid-sentence — or mid-spelling an email — and doesn't leave an unnatural gap before it replies. That's the robotic-pauses failure mode, closed.
That's all six failure modes from the top of the post — each one now has a rail that makes it impossible, not a prompt that asks the model to avoid it.
A leaf patch fixes the one call in the bug report. A rail fixes every future call that would have hit the same edge. That distinction — find the structural cause and fix it at the layer where the ordering is correct, not at the symptom — is the difference between an AI product that slowly rots and one that gets more reliable every week.
Case study: the day one call black-holed an entire customer
The best proof of this philosophy isn't in the conversation engine at all. It's in a concurrency bug that, for about twenty minutes one afternoon, silently took an entire customer offline — and the way we fixed it is a near-perfect illustration of “structural, not leaf.”
Here's the setup. To meter how many calls a business is handling at once, each live call acquires a slot in a Redis counter (concurrent_calls:{org}) and releases it when the call ends. Simple, standard. The original limit was small, and — crucially — the slot was released only when the call ended cleanly and fired its normal end-of-call callback.
You can already see the bug if you've done any concurrency work. Not every call ends cleanly. A caller's connection drops abnormally (a WebSocketDisconnect). A routine API deploy sends SIGTERM to a pod that's mid-call. Neither of those fires the clean end-of-call callback — so the slot is never released. It leaks, and it sits there for the full 20-minute stale-timeout window like a ghost holding a seat.
One afternoon, two slots leaked that way. The org's bucket was pinned at capacity — 2 of 2 taken by calls that had already ended. And because the counter was per-tenant, every agent in that account — the receptionist, the directions line, all of them — started telling every new caller the equivalent of “we're experiencing high call volume.” One agent's leaked slot had black-holed the entire customer. Real callers, real business, a busy signal from a company that was, in reality, completely idle.
The leaf patch (necessary, not sufficient)
The fast fix is obvious: raise the limit, add a per-agent bucket so one agent can't starve the others, shorten the stale sweep. We shipped that. It stops that outage.
But it's a leaf patch. A limit is still a limit — with several agents each allowed a handful of concurrent calls, a busy afternoon can still pile up against the tenant ceiling and reject a caller. We'd moved the black-hole, not removed it. And the root defect — a slot that only releases on the happy path — is the exact same class of bug as the engine's “cut-off mid-utterance”: something that must happen on every path only happened on the clean one.
The structural fix (the two rails)
So we laid two rails, straight out of the railroad playbook:
- Release on all paths. Slot release became a deterministic, idempotent guarantee wired into every teardown path — normal hangup, abnormal disconnect, error, and pod shutdown — not just the clean callback. A leaked slot is now structurally impossible; the stale sweep is demoted to a last-resort backstop, not the primary mechanism.
- Reframe the gate as a meter — the deeper fix. The outage forced a question we should have asked on day one: should a plan's concurrency limit ever reject a caller at all? The answer is no. A busy signal is the single worst thing an “always answering” receptionist can do. So the concurrency limit stopped being a gate and became a billing meter: a call over the plan is still answered, and the overage simply becomes a billable line item. The limits now fail open — if Redis itself errors, we admit the call and count zero, because a metering counter must never stand between a caller and the business.
That reframe is the “with ease and clarity” part. Once you decide the meter can never reject a caller, an entire category of outage — any per-tenant black-hole — stops being possible by construction. You don't need clever leak detection or generous headroom; you've removed the failure mode instead of defending against it. And it's a better product at the same time: the customer literally cannot be given a busy signal, and legitimate over-plan usage becomes revenue instead of a rejected call. Quality and economics, aligned.
The whole arc — leak → outage → leaf patch → “release on all paths” → “a gate should have been a meter” — is one clean run up the failure ladder. A bug in production didn't just get patched; it taught us the correct design, and the correct design is simpler and safer than the thing it replaced. That's what good engineering feels like: the fix makes the system less complicated, not more.
The same rails, on WhatsApp too
Here's where the “engine, not a script” claim earns its keep. When we added WhatsApp and SMS, the naive approach would have been to write a second, text-shaped code path next to the voice one. Two paths means two sets of bugs — and, sure enough, the first time text ran on its own logic, it jumped a rail: an inbound WhatsApp caller's very first message — their opener — got ignored, and the greeting came out in the wrong order (the deterministic hello firing after the reply instead of before it). The exact same ordering failure as the voice engine's double-utterance, just wearing a different channel.
There was a subtler one, too. A text conversation runs as a short-lived task per message (that's what makes SMS/WhatsApp cheap to run). But a caller who typed “6027 Delafield Ave, Bronx NY” had their address silently dropped — because that short-lived task captured and saved its state and tore itself down before the background job that extracts the address had finished. The map never got sent. It's the concurrency leak's twin: work that must complete before teardown was allowed to be interrupted by teardown.
The fix in both cases was the railroad move, not a text-specific patch: make text run the identical ordered stations as voice (set up → settle → open → speak once), and add the settle-before-persist rail — the short-lived task now drains its pending work before it saves and shuts down. One engine, one set of rails, every channel. A caller now gets the same coherent, in-order, nothing-dropped experience whether they phone in or open WhatsApp — and when we fix a rail, we fix it everywhere at once, because there's only one set of rails to fix.
Correctness under concurrency: the setting that saved but didn't apply
A subtle one, and a favorite, because it's a bug almost every multi-process service ships at least once.
A customer opened their agent's settings, changed a value, hit Save, saw the success toast — and the change didn't take effect on their calls. No error. The save genuinely worked. So what happened?
The API runs as several worker processes behind a load balancer for throughput. Each one keeps a hot in-memory copy of an agent's config so it doesn't hit the database on every turn. The save request landed on worker #3 — which dutifully updated its own memory. But the customer's next call was served by worker #1, still holding the old value. Classic cache-coherence: N processes, N private copies, one of them freshly correct and the rest silently stale. The kind of race that's invisible in development (one process) and inevitable in production (many).
You can't fix this with a lock or a bigger cache — the whole point of the per-worker copy is to avoid shared state on the hot path. The correct move is to make the change converge across all workers deterministically: on a config write, we now broadcast an invalidation over Redis pub/sub, and every worker — #1 through #N — drops its stale copy and reloads on the next use. Save on any worker, correct on all of them, within a heartbeat. It's the same principle as the whole railroad: a guarantee is only real if it holds across every path — and here the “paths” are parallel processes instead of stations in a pipeline. Same discipline, different axis.
Correctness in ordering: the confirmation that can't be skipped
This is the sharpest pipeline-ordering story we have, and it's the one that convinced us the ordering guarantees had to live in deterministic engine code, not in a prompt.
The scenario: the agent has captured an email by having the caller spell it out, and it's about to read it back — “let me confirm, that's j-o-h-n at…” — and only then, on the caller's confirmation, send the directions. Standard, careful, human. But on one production call the caller said, in a single breath, “Yes. I'm finished. Give me the directions.” Speech-to-text split that into pieces that arrived at slightly different moments — and one of those pieces looked, to the transition logic, like a confirmation that fired before the read-back had actually been spoken. The agent skipped its own read-back, emailed an address it had never confirmed (and, because the parse was imperfect, the wrong address), and the leftover read-back audio frame spilled onto the goodbye node. A correctness failure and a privacy failure, from pure frame-ordering timing.
The wrong fix is prompt-level (“tell the model to be more careful about confirmations”). Timing races don't respect instructions. The right fix is an ordering invariant enforced in the engine: a read-back station cannot be exited until its read-back has physically started and finished being spoken. We arm that guard synchronously the instant the node is entered — before any stray, early-arriving “yes” can be processed — so a transition out of a confirmation step is structurally impossible until the caller has genuinely heard what they're confirming. And because it's a deterministic property of the node type — not a per-workflow prompt — it protects every confirmation step across every template automatically, and it behaves identically no matter which LLM is driving. In one line: correctness isn't something you ask the model for. It's an ordering the engine enforces, at the station, on every path, deterministically.
The principle that makes it durable: deterministic over LLM
Notice what's not doing the work here: the prompt.
The human-quality mechanics — speak once, wait your turn, don't clip your own sentence, confirm before you send — live in deterministic engine code, not in the model's instructions. That's a deliberate architectural bet, and it pays off in a way you can't fake with prompting:
The quality survives an LLM swap. Because the rails are enforced in our code, not coaxed out of a specific model, you can change the underlying AI — a new GPT, a Claude, a cheaper open model — and the receptionist still speaks once, still waits its turn, still never talks over the caller. Only the free-form phrasing changes; the deterministic spine is unchanged, and it's proven by a test suite that runs with no LLM at all.
That's the honest version of “human-like AI”: you don't get there by turning the temperature down and writing a longer system prompt. You get there by putting the mechanics of good conversation into infrastructure, and fencing the model's freedom inside guards it can't violate.
And none of the underlying ideas are new. The railroad is a synthesis of well-worn engineering principles applied to a live phone call: a finite-state machine for the conversation graph, idempotent operations so a slot releases — and an opening speaks — exactly once on every path, and backpressure so a station drains its work before the next one starts. What's specific to voice is the constraint: the pipeline is a real-time audio stream with a person on the other end, so an out-of-order step isn't a dropped message you can retry — it's a caller hearing the wrong thing, or nothing at all. That real-time, no-do-overs constraint is what turns these familiar patterns into hard rails.
Why this matters if you're picking an AI receptionist
If you run a small business and you're evaluating AI receptionists — whether that's for a dental office, a restaurant, or a home-services business — here's the practical translation of all this engineering:
- It won't talk over your customers, and it won't leave them in dead air — the two things that make callers hang up on a machine.
- It confirms before it acts. It reads an email or an appointment back and only sends once you've agreed — so a misheard letter doesn't email a stranger.
- It sounds the same on every channel. The same engine — the same rails — drives voice, SMS, and WhatsApp, so a caller gets one coherent experience whether they phone in or text.
- It doesn't rot. The reliability is enforced by tests, not by a prompt someone might accidentally break.
The demo is easy. The edges are the product. Any AI can nail a scripted happy path; what earns a caller's trust is what happens when they interrupt, correct themselves, ask something off-script, or pause to think. Those are precisely the moments the railroad is built for.
That's the whole thesis: human quality isn't a bigger prompt. It's an engine that refuses to jump the rails.
See it for yourself.
KUNI is the AI receptionist built on this engine — it answers your calls, texts, and WhatsApp 24/7, books appointments, and sounds like a focused member of your team.
Building voice AI yourself? Start by decoupling “set up the step” from “speak the step.” Everything else is downstream of that one move.