KUNI

Engineering · Field notes from building KUNI

You Don’t Have One Railroad — You Have Fifty

Composing voice-AI flows without drowning in nodes.

By John Montoya, Co-founder & CTO of KUNI17 min read

A follow-up to The Railroad Pattern. That post argued that an AI receptionist sounds human when a deterministic engine — not the LLM — controls the order of operations on a single conversation. The best objection we got was: real businesses don't have one conversation. This is the answer.


When we published the railroad pattern — the idea that an AI receptionist sounds human because a deterministic engine, not the LLM, controls when it speaks — the sharpest pushback was also the fairest:

“There isn't one railroad. A real business has dozens. An HVAC company alone has emergency repair, maintenance, an installation quote, warranty, billing, parts, a job applicant, a vendor, and a wall of spam. Each is its own branching conversation. Manage that and you're not keeping a train on the rails — you're running a rail network. That's a workflow-orchestration problem, and the original post doesn't touch it.”

That's correct. Keeping one conversation in order is the hard real-time problem. Keeping fifty conversations coherent — each with the same hard-won turn-taking guarantees, across every customer, without a team of people hand-maintaining hundreds of graph nodes — is the hard engineering-at-scale problem. They're different, and the second one is where most voice-AI products quietly rot.

Here's how we handle it.

The naive version, and exactly how it rots

The obvious way to ship a second vertical is to copy the first. You built a great dental-office agent; a spa signs up, so you clone the dental workflow, find-and-replace the words, and tweak the booking step. It works. A restaurant signs up; you clone again.

Six months later you have nine near-identical workflows and a bug report: the email read-back confirmation is broken on the restaurant agent. You fix it in the restaurant graph. Two weeks later the same bug surfaces on the salon agent — because that clone predated the fix. Then the medical clone. Each clone is a frozen snapshot of whatever your best flow looked like the day it was copied, and every one of them drifts independently from that day forward.

This is the real cost, and it's not the node count — it's that every clone re-opens every bug you already closed. The railroad pattern's whole promise was “find the structural cause, fix it once, and the whole class of bug is gone.” Copy-paste cloning breaks that promise at the workflow layer: you fixed the rail in one graph, and the other eight still jump it. You've turned a solved problem back into fifty unsolved ones.

Copy-paste clones each freeze a snapshot, so a fix in one leaves the others broken; a single reusable component with a drift pin makes one fix land in every vertical at once.
Figure 1 — Clone and it rots: one fix leaves N snapshots drifting. Compose from one drift-pinned component and a single fix lands everywhere, provably.

So the scaling question isn't “how do I draw fifty graphs?” It's “how do I make one fix land in all fifty at once?”

The move: components are generators, not graphs

The answer is to stop treating a conversation flow as a drawing and start treating it as a function.

A booking flow isn't a set of nodes someone laid out in an editor. It's a generator — code that emits the nodes, edges, prompts, and guards for a hardened booking conversation, parameterized by the handful of things that actually differ between businesses:

build_calendar_component(
    vocabulary = "reservation",      # "appointment" | "reservation" | "visit"
    booking_sources = ["triage"],    # which conversational node routes in
    target_after_booking = "wrap",
    include_manage = True,           # also emit reschedule + cancel?
)
→ (nodes, edges)   # a complete, hardened book + change + cancel sub-flow

Everything hard about booking over the phone — spelling an email back in NATO (“M as in Mary”), the must-hear guard that won't send a confirmation until the read-back has actually been heard, the full-restate before it commits, reading real calendar openings instead of hallucinating times, the deterministic outcome emails — is baked into the generator, once. A spa, a clinic, and a restaurant don't get copies of that flow. They get the same flow, emitted from the same function, with the vocabulary swapped.

We have a handful of these components — booking (find/book/reschedule/cancel), take-a-message, driving-directions, order-taking — and every scheduling or answering-service template is assembled from them, not authored from scratch. A new vertical is a composition, not a redraw:

nodes, edges = greeting() + triage()
nodes += build_calendar_component(vocabulary="appointment", …)
nodes += build_directions_subflow(channel="email")
nodes += take_a_message()

The new template inherits every rail by construction. Nobody has to remember to add the read-back guard, because it's not something you add — it's something the booking component is.

One drift-pinned generator emits the same hardened flow into every vertical; a CI drift pin proves the live deployed flow matches the generator, so a fix lands everywhere at once.
Figure 2 — One generator emits the same hardened flow into every vertical with a one-line vocabulary swap; a CI drift pin proves the live flow matches it, so a fix lands everywhere.

Why this composes: the drift pin

A generator alone isn't enough. The moment someone “improves” the live spa flow by hand — a tweak in the production database — the generator and the real world diverge, and you're back to snapshots.

So the load-bearing piece isn't the generator. It's the drift pin: a test that asserts the live, in-production flow is byte-for-byte what the generator emits.

def test_live_spa_booking_is_the_generic_component():
    live    = load_from_production("Spa & Clinic")   # the real, deployed nodes
    emitted = build_calendar_component(vocabulary="appointment")
    assert live.booking_nodes == emitted             # or the build fails

This one test changes the whole economics. It means:

  • There is exactly one source of truth for what a booking conversation is — the generator — and CI proves the deployed flow matches it. No silent hand-edit can survive.
  • A fix lands everywhere by definition. Improve the read-back guard in the generator, and either every template that uses it now matches the new shape, or the drift pin goes red and tells you precisely what fell behind. “Fixed once, fixed everywhere” stops being a hope and becomes a CI invariant.
  • A new vertical inherits the hardening, not just the structure. When we ported the calendar component into a new template, we didn't re-test whether the must-hear guard works — the drift pin already guarantees the new template is the hardened flow. The bugs we closed in month one cannot reappear in the vertical we add in month twelve.

That's the actual answer to “you'll drown in hundreds of nodes.” You don't manage hundreds of nodes. You manage a handful of generators and let CI keep the hundreds of emitted nodes honest. The node count grows; the number of things a human maintains does not.

Where the LLM still lives (this isn't a scripting engine)

Worth saying plainly, because “generated deterministic flows” sounds like the 2005 IVR phone tree everyone hates: the components generate the rails, not the script. Inside every station the flow emits, the LLM runs with the full conversation and every tool it needs. The generator decides when the agent may speak and what guards must hold; the model decides what to say and which tool to call. A “collect the caller's email” node is a hardened, drift-pinned station — but what happens inside it is a real language model handling a caller who spells their address, then changes it, then asks an unrelated question. The rails are generated; the intelligence is live.

That's the same split the railroad pattern draws at the single-conversation level — deterministic plumbing, LLM water — just lifted up to the fleet level: deterministic composition, LLM inside every composed station.

How this compares to LangGraph and Temporal

The natural question from anyone who's built orchestration: isn't this just LangGraph, or Temporal, or a state-machine library? Honest answer: those solve a different part of the problem, and you could even run this on top of them.

  • LangGraph gives you a graph runtime for LLM apps — nodes, edges, shared state, cycles. It's a great way to express a conversation graph. But it's a runtime, not a position on reuse: it doesn't tell you how to keep fifty business-specific graphs from drifting into fifty snapshots, and it doesn't own the hard real-time audio constraint (a node that speaks twice, or tears down the pipeline mid-sentence, is a LangGraph node that compiled fine and still sounds broken on the phone).
  • Temporal is a durable-workflow engine — brilliant for long-running, retryable, multi-step backend processes (a booking that has to survive a process restart, a payment saga). Some of our post-call work is exactly that shape. But a Temporal workflow is measured in seconds-to-days with durable checkpoints; a turn on a live call is measured in the tens of milliseconds between a caller finishing a word and the agent deciding whether to speak. Different clock, different failure mode. Temporal makes sure your booking eventually completes; it has nothing to say about whether the agent clipped the caller's sentence while doing it.

The part these tools don't give you is the two things this post is actually about: (1) the real-time turn-taking rails that live inside a station (from the railroad post), and (2) the drift-pinned generator discipline that keeps a fleet of business-specific flows collapsed to one source of truth. Those are orthogonal to your orchestration runtime. Put differently: LangGraph and Temporal answer “how do I run a workflow?” Our components answer “how do I have fifty of them without maintaining fifty of them, each still sounding human?” You can absolutely host the first on the second — it's just not where the interesting problem is.

The other two objections, same answer

The scaling critique came with two smaller ones, and the component model quietly answers both.

“Human receptionists make judgment calls — this is too rigid.” The rails govern when the agent speaks, never what it says, so judgment lives inside a generated station. Our emergency component detects an urgent situation and reads back a deterministic urgent-line instead of taking a message; a returning caller is recognized across voice and text and greeted “welcome back” by name; a spelling step slows its own pacing for an anxious caller. Policy-aware behavior isn't fighting the structure — it's the water flowing through it.

“Where's the live data — CRM, calendar, knowledge base?” Inside the stations, with its own rail. The components reach a live calendar for real openings, a knowledge base for FAQs, point-of-sale for order status, and a cross-channel memory of the caller — and a retrieve-then-answer guard forces the lookup to resolve before the agent replies, so it never answers against data that's still loading. Retrieval isn't bolted onto the side of the flow; it's a station in it, ordered correctly.

Both fall out of the same idea: put the guarantees in reusable infrastructure, and let the model spend its intelligence on the caller instead of on remembering the rules.

Where this still gets hard (two honest edges)

The best readers pushed past the pattern and asked the two questions we ask ourselves. Neither is fully solved — they're the problems you only reach after the foundational ones — but here's how we think about them.

1. Parameters explode — unless behavior variation lives somewhere other than flags

build_calendar_component() with four clean parameters is a tidy example, and real customization is not tidy. One customer wants the email read back twice; another wants to skip the confirmation for returning callers; a third has a cancellation policy that needs a different guard. Add a boolean for each and the generator slowly becomes the tangle of flags it was supposed to replace.

The way out is to keep most variation off the generator entirely, on two layers that are already drift-pinned and engine-interpreted:

  • Per-node configuration, not generator flags, for fine behavior. “Read the email back twice,” “slow the pacing on this step,” “allow three re-asks here” are properties of a node the generator emits and the engine reads deterministically — not new function arguments. The generator's parameter space stays small because the knobs live on the nodes.
  • Existing engine rails absorb a surprising amount of it. “Skip the confirmation for a returning caller” isn't a new flag at all — it's the don't-re-ask rail from the railroad post, which already skips a question when the value is known (and we already know a returning caller). The behavior the customer wants is a rail we shipped for a different reason.

That leaves the genuinely structural requests — a materially different cancellation guard, a flow shape no parameter expresses. Our honest answer is that those become a new drift-pinned component variant, not a twelfth boolean. And there's a real line we hold on purpose: if a request can't be expressed as a parameter, a node property, or a pinned variant, it does not go into the shared generator — we'd rather tell one customer “not yet” than rot the component for the other forty-nine. The moment a vertical-specific hack lands in the shared flow, you're back to clone rot with extra steps.

We do add real knobs when they earn their place — but the bar is “one flag, drift-pinned, off by default so every existing template is byte-for-byte unchanged.” The most recent one let the caller choose whether their confirmation arrives by text, WhatsApp, or email: a single parameter threaded through the component, pinned to the generator, inert for every template that doesn't opt in. That's the shape a good knob takes; a bag of thirty ad-hoc booleans is the shape we refuse.

But a principle is only as good as its enforcement, and this one runs straight into commercial pressure — the moment a big customer leans on you for a one-off, the twelfth boolean is tempting. What holds the line isn't willpower; it's that the drift pin makes the cheap hack expensive. The tempting shortcut — quietly tweak that one customer's live flow — doesn't survive: it makes the drift pin go red and the build fail, because the deployed flow no longer matches its generator. The rule is written down, not vibes — do generator work and re-sync; never patch a live clone is a documented policy, enforced in review, under a KPI doc that says in as many words: reject the leaf patch when the root cause is structural. And a real knob has a price — you thread it through the generator, drift-pin it, and prove with a test that every existing template is byte-for-byte unchanged with it off. That cost is the point: it makes “just add a flag” expensive enough that a node property or a pinned variant is the easier path. Honest bottom line — there's judgment at the end of it, but it's judgment backed by a build that goes red when you cheat, not a good intention on a wiki.

A customization request is routed to one of three drift-pinned layers — a generator parameter, a per-node property, or a new pinned component variant — so the generator's parameter set never explodes into a bag of ad-hoc boolean flags.
Figure 3 — Customization routes to one of three drift-pinned layers (generator param, per-node config, or a new pinned variant) — never a twelfth ad-hoc flag on the generator.

2. Components share state — through one explicit context, not by passing it hand to hand

The composition greeting() + triage() + build_calendar_component(…) + take_a_message() reads cleanly, and it hides a real question: those components aren't independent. Triage has to hand the detected intent to the calendar; the calendar has to hand the confirmed appointment to the wrap-up. How does state flow between generators without one silently depending on something another didn't populate?

Two things carry it, both deliberate:

  • The interface is the edges, declared as parameters. booking_sources=["triage"] isn't decoration — it is the contract: “these conversational nodes route into me.” Components don't return state to each other through function composition; they're wired by the edges the generators emit, and the routing parameters are where that wiring is declared.
  • State lives in one explicit, run-scoped place. Every node reads and writes a single gathered_context the engine threads through the whole call — not a tangle of values passed hand to hand between generators. A component reads what it needs from that shared context by name.

Which surfaces the exact risk the critique names: a component depending on a value an earlier step might not have populated. We know it intimately, because it's where our worst bugs come from. The discipline against it is deterministic present/absent gating — a post-call step fires only when its required signal is actually present, and no-ops otherwise instead of acting on a blank. But “no-op instead of crash” isn't automatically the right behavior, and here's the honest version, from last week. A take-a-message flow persisted the message only when a reason field was present in the context. A caller gave their name, number, and reason out loud — then hung up a half-second early, before the step committed that field. The gate saw no reason, and the message was silently dropped: a real lead, lost. The bug was precisely “one component depended on state another step didn't populate in time.”

The fix is the tell. We didn't add a prompt asking the model to be faster. We made the dependency robust to the missing state: the message now also captures from a deterministic node-visit signal and the transcript itself, so a caller who said everything and hung up early still leaves a message. Inter-component state is a genuine design challenge — the composition diagram does hide it — and the honest answer isn't that it never bites. It's that the failures are deterministic, they surface as a specific dropped signal you can see, and the fix is to harden the contract, not to coax the model.

Components don't pass state hand to hand; every node reads and writes one run-scoped gathered_context, wired by declared edge parameters. A present/absent gate protects a step that depends on a value an earlier step might not have populated — and when a caller hung up before a field committed, the fix was to capture from a node-visit signal and the transcript instead of coaxing the model.
Figure 4 — Components share one run-scoped gathered_context (wired by declared edge params), not state passed hand to hand. The gate that protects a missing dependency is also where a real lead got dropped — and the fix hardened the contract, it didn't prompt the model.

One more honesty beat, because the fix above recovers from that bug — it doesn't prevent the next one. The drift pin proves a flow matches its generator; it says nothing about whether a component reads a context field that no upstream step produces. That's the real open edge. The direction is a requires/produces contract per component: each generator declaring the gathered_context fields it needs and the ones it sets, checked statically, so a component wired after something that never produces its input fails the build instead of a call. We already have the pieces in point form — a leaf's send-gate is a per-step “requires this field present” contract, and booking_sources is a declared routing contract — but they're local and hand-placed. The generalization — turning them into one checkable schema over the context — is what would have caught the dropped-lead dependency at build time instead of on a live call. We're not there yet: the transcript fallback was defense-in-depth while the contract layer is the structural close. Naming it plainly — the drift pin closed clone rot; the context contract is the next invariant on the list, and it isn't shipped.

Today each component carries local point-contracts over the shared context (a leaf's send-gate, booking_sources routing) and the drift-pin catches flow drift but not a missing producer — so it surfaces as a dropped lead in prod; the next invariant is one checkable requires/produces schema that fails the build when a component needs a field nobody upstream sets.
Figure 5 — Today: local point-contracts (a send-gate = 'requires this field'; booking_sources = routing), and the drift-pin catches flow drift but not a missing producer — so it surfaces as a dropped lead. Next: one checkable requires/produces schema over the context that fails the build. Named honestly — not shipped yet.

The three-level invariant stack

Step back and the two posts describe one thing: a stack of invariants, each closing a class of failure you can't trust the LLM to avoid on its own. Two of the three are live; the third is the one we named as forthcoming.

LevelMechanismWhat it closesStatus
ConversationThe railroad — a deterministic turn-taking engineSingle-conversation ordering bugs: cold context, double-utterance, dead air, mid-sentence cut-offs✓ shipped
FleetDrift-pinned generator compositionClone rot — one fix lands everywhere, CI-provable, no silent hand-edit survives✓ shipped
CompositionA requires/produces context contract, checked staticallyInter-component dependency bugs — a component reading a field no upstream step produces fails the build, not a live callbuilding
A three-level invariant stack: conversation (the railroad engine, shipped), fleet (drift-pinned generator composition, shipped), and composition (a requires/produces context contract, forthcoming) — the LLM runs free inside every level but can't violate the guarantee the level enforces.
Figure 6 — The three-level invariant stack. Each level closes a class of failure; the LLM runs free inside every level but can't violate the guarantee it enforces. Two shipped, one building — named honestly.

It's worth being precise about why that third level is a separate invariant, not just more of the drift pin. The drift pin proves structural fidelity — a deployed flow is byte-for-byte its generator. The context contract would prove semantic correctness across the composition boundary — that no component reads a field no upstream generator ever writes. They're orthogonal: you can have a perfectly drift-pinned fleet and still ship a component that depends on a value nothing populates. One guarantees each flow is exactly what you wrote; the other guarantees the pieces actually fit together. And through all three levels the same rule holds — the LLM runs free inside every level (it decides what to say, which tool to call), but it can't violate the guarantee the level enforces.

One level of that stack is still a promise, not a proof — and we'd rather say so plainly than draw it solid. If you've read this far you've seen the rhythm: every guarantee in this series was named first, then confirmed by a production story — the rails by the bugs that forged them, the drift pin by the clone rot it makes impossible. The context contract is the one guarantee still on the named-but-not-yet-confirmed side of that line: we've told you the mechanism and pointed at the pieces we already have, but we haven't shipped it or shown you the build it turns red. When we do, it gets the same treatment as everything before it — the code, and the exact bug it would have caught at build time instead of on a live call. The seam is open; we're telling you it's open; closing it in public is the next entry in this log.

The takeaway

“You don't have one railroad, you have fifty” is the right critique — and the answer isn't a bigger graph editor or more engineers drawing nodes. It's the opposite: fewer sources of truth. One drift-pinned generator per flow, composed into every vertical, so the fleet of conversations collapses back to a small set of functions that CI keeps honest. Fix the booking rail once, and every business that books is fixed — provably, not hopefully.

The single-conversation railroad makes an AI receptionist sound human. The component model is what lets it stay that way across fifty businesses and a hundred call types — which, in the end, is the only version of “human-quality AI” that survives contact with a real customer base. Start with the railroad pattern if you haven't read it; this is the layer on top.

Frequently asked questions

Can one AI receptionist handle many different call types?

Yes — and it should. Each call type (emergency, booking, billing, a message for a person, driving directions) is its own reusable, hardened flow, and a triage step routes the caller to the right one. Because every flow is generated from the same components, they all share the same guarantees: confirm before acting, never talk over the caller, and know when to hand off to a human.

How do you keep dozens of business-specific agents from drifting apart?

Every flow is emitted by a single generator, and a 'drift pin' test asserts the live, deployed conversation is byte-for-byte what the generator produces. A fix to the generator lands in every agent that uses it, and CI fails if any deployed flow falls out of sync — so there's one source of truth, not fifty snapshots.

Is this just LangGraph or Temporal?

They solve a different layer. LangGraph is a graph runtime for LLM apps; Temporal is a durable backend-workflow engine. Neither owns the real-time turn-taking that makes a live call sound human, nor the drift-pinned reuse that keeps a fleet of flows collapsed to one source of truth. You can run these components on top of an orchestration engine — but that engine isn't where the hard voice problem lives.

Does all this structure make the AI rigid or robotic?

No. The components generate the rails — when the agent may speak and what guards must hold — not the script. Inside every station a real language model handles the caller with full context and live tools. It's deterministic composition on the outside, LLM intelligence on the inside.

See it for yourself.

KUNI is the AI receptionist built on this architecture — reusable, hardened flows composed into an agent that answers your calls, texts, and WhatsApp 24/7, books real appointments, and sounds like a focused member of your team on every one of your call types.

Haven’t read the original? Start with The Railroad Pattern — this is the layer on top.