LLMs at the Edges: Designing Conversational Agents With a Deterministic Core
The first chat agent I put in front of real customers was fully agentic. One big system prompt, a handful of tools, and a model deciding what to ask next. It demoed beautifully. Then it went live, and within days I was reading transcripts where the bot skipped a required question, asked another one twice, and now and then wandered into questions it was never meant to answer.
None of those failures came from the model misunderstanding people. They came from the model driving the conversation. So I flipped the design, and I've used the flipped version ever since: a deterministic conversational agent with LLM calls only at the edges. Code owns the conversation. The model reads messy human input and turns it into clean, typed data. That's it.
This post covers the architecture I keep coming back to for lead qualification, intake and booking assistants: the kind of bot a small business needs to be boringly reliable, not clever.
Agentic vs Deterministic: When a Deterministic Conversational Agent With LLM Extraction Wins
Start with an honest question: does this conversation have a shape?
Most business conversations do. A lead-qualification bot for a local business needs a name, what the person is interested in, a budget range, a timeframe, and a way to get back to them. An intake assistant needs a fixed set of fields before a human can act. The order can bend a little, but the destination is known in advance.
When the destination is known, letting a model pick each next step means paying for flexibility you don't need with reliability you can't afford. You end up writing prompt rules like "always ask for the budget before the timeframe" and hoping they hold over a thousand conversations. They mostly do, which is the worst outcome: rare, hard-to-reproduce failures.
Here's how I split it:
| Input / decision | Who handles it | Why |
|---|---|---|
| Which question comes next | Flow (code) | Known shape; must be repeatable and auditable |
| "Tomorrow after 5 maybe?" into a time window | LLM extraction | Messy natural language, low-risk structured output |
| "Is this person asking about price or complaining?" | LLM classification | Fuzzy intent, bounded label set |
| Tapped button or quick reply | Flow (code) | Already structured; no model needed |
| Anything with money, legal, or health consequences | Human handoff | Risk too high for either code or model to decide alone |
| Off-script question the flow can't answer | LLM reply or human, by policy | Depends on how much you trust your guardrails |
| Model confidence below threshold | Human review queue | Cheaper to ask a person than to guess |
Fully agentic designs still have a place. Open-ended support over a large knowledge base, research assistants, internal copilots: those benefit from a model choosing its own path. I experimented with that style in langgraph-ts-agents and langgraph-langserve-starter, and graph-based orchestration is great when the path genuinely varies. But if you can draw the conversation on a whiteboard, put it in code.
The Pure Runner: No I/O, No Model Calls, Fully Testable
The core of the design is a flow runner that is a pure function. It takes the current conversation state plus a structured event, and returns the next state plus a list of things to do. It never sends a message, never calls a model, never touches a database.
step(state, event) -> { nextState, effects[] }
event: { type: "answer", field: "timeframe", value: "this_month" }
effects: [ { send: "ask_budget" }, { save: { timeframe: "this_month" } } ]
Everything with side effects lives in a thin layer around it, which I call the brain. The brain receives the webhook, calls the model to turn raw text into a structured event, feeds that event to the runner, and then executes the effects the runner returned.
Why bother with the purity? Three reasons.
Testing becomes trivial. A conversation is a list of events in, a list of states and effects out. You can replay a hundred scripted conversations in milliseconds, with no phone, no sandbox number, no API key. Snapshot the effects and you'll know the moment a refactor changes what the bot says.
Bugs get a clear owner. If the bot asked the wrong question, the runner is wrong. If it understood "next week" as "today", extraction is wrong. If the message never arrived, the brain or the channel is wrong. That separation saves hours when you're reading a transcript at 11 p.m.
The flow becomes data a non-developer can review. Once the runner is pure, the flow itself (nodes, questions, options, transitions) can be a declarative definition. A business owner can read it. You can diff it in a PR. That's the same instinct behind spec-driven development: make the intended behavior an artifact you can review before it runs.
Where the LLM Belongs: Extraction, Classification, Normalization
With the runner owning control flow, the model's job shrinks to three things.
- Extraction. Pull typed fields out of free text. "I'm Sam, looking for something for my daughter, nothing crazy, under 300 or so" becomes a name, an interest and a budget bucket.
- Classification. Map a message to one of a fixed set of intents: answering the question, asking something off-script, wanting a human, opting out.
- Normalization. Turn "tmrw arvo", "mañana a la tarde" and "tomorrow afternoon" into the same value.
All three are structured-output tasks. I use schema-constrained generation (in the Vercel AI SDK that's generateObject with a schema) and I always include a confidence field and an explicit "unknown" option. A model that's allowed to say "I couldn't find a budget" is far more useful than one forced to invent one.
extract_budget output schema (illustrative):
bucket: "under_300" | "300_to_800" | "over_800" | "unknown"
confidence: number between 0 and 1
evidence: short quote from the user's message
The evidence field is underrated. When a reviewer looks at a low-confidence extraction, seeing the exact phrase the model relied on turns a five-minute investigation into a five-second glance.
Because these tasks are narrow, you rarely need a frontier model:
| Task | Suggested model tier | Cost / latency notes |
|---|---|---|
| Field extraction into a schema | Smallest "nano/mini" tier | Near-free per call; fast enough to feel instant in chat |
| Intent classification (fixed labels) | Smallest tier, or a fine-tuned classifier at volume | Batch-friendly; easy to eval against a labeled set |
| Normalizing dates, amounts, names | Smallest tier plus deterministic post-processing | Let code do the date math; the model only reads the phrase |
| Free-form reply to off-script questions | Mid tier, grounded in your docs | The only call where tone and accuracy really matter; budget for it |
| Summarizing a conversation for a human | Mid tier | Runs once per handoff, so cost is negligible |
If free-form replies are allowed at all, ground them. A small RAG over your own docs with pgvector is usually enough for a business FAQ, and it keeps the model from inventing your opening hours.
Option Design: One ID for Button, Typed Answer, Stored Value and Route
This one sounds minor and causes the most bugs I've seen in chat flows.
Every option in a question gets one stable id, and that id is used everywhere: the button payload, the value the extractor must return when someone types instead of tapping, the value written to the store, and the key the runner uses to choose the next node.
question: timeframe
options:
- id: this_week label: "This week"
- id: this_month label: "This month"
- id: just_looking label: "Just looking"
If a user taps "This month", the event is this_month. If they type "probably in a couple of weeks", the extractor's schema only allows this_week | this_month | just_looking | unknown, so it returns this_month. The runner sees the exact same event either way. There is no second code path for typed answers, no "label matching" that breaks the day someone rewrites "This month" as "Within the month".
Interactive buttons and list messages on business messaging platforms make tapping easy, but people type anyway. Design for both from the start and they collapse into one route.
Human Takeover and the Inbox: Who Owns the Conversation, and When
Every production chat agent needs a way for a person to step in. The key design decision is that ownership is explicit state, not a vibe.
A conversation is either bot-owned or human-owned. When a staff member takes over from the admin inbox, the conversation moves to human_owned, the runner stops producing outbound effects, and inbound messages go straight to the inbox. When they hand it back, the runner resumes from a known node, not from wherever the model thinks it should be.
Two things I learned to keep outside the runner:
- Timed re-contact. "If they haven't answered in a day, nudge them once" is a scheduled job that reads the store and emits an event. Putting timers inside the flow makes the runner impure and the tests flaky.
- Notifications. When a conversation enters
HumanOwned, someone must actually find out. That alert is an effect the brain executes, and it needs its own delivery check.
I go much deeper on triggers, context packets and the return path in Human Handoff for AI Chat Agents.
Failure Modes: Silent Dead Ends, Missed Alerts, Platform Template Windows
Deterministic doesn't mean failure-free. It means the failures are findable. These are the ones I watch for:
| Failure mode | What it looks like | How to detect it |
|---|---|---|
| Silent dead end | A node with no valid transition for some input; the bot just stops replying | Static check: every node has a default/fallback edge. Alert on any conversation with an inbound message and no outbound within N minutes |
| Paused and forgotten | Bot paused for a human who never shows up | Alert on HumanOwned conversations with no staff message after a threshold |
| Missed handoff alert | Handoff notification fails to send | Treat the alert as an effect with a delivery receipt; retry and escalate |
| Outside the messaging window | Re-contact fails because the platform only allows free-form messages within a time window after the user's last message | Track the window per conversation; use pre-approved templates when it's closed. Check your platform's messaging-window rules |
| Double send | Webhook retried, user gets the same question twice | Idempotency key on inbound message id before calling the runner |
| Extraction drift | Model update changes how "next month" is bucketed | Labeled extraction eval set, run on every prompt or model change |
| Injection through free text | User text tries to steer the model ("ignore your instructions…") | Model only fills a schema; it can't change the flow. Still test it |
That last row is one of the quiet benefits of this architecture. If the model's only output is a constrained object, a prompt injection can at worst produce a wrong field value, not a wrong action. It's the same principle as separating talking from acting, and it lines up with the excessive-agency guidance in the OWASP Top 10 for LLM Applications. I still red-team it, though; prompt injection tests belong in the suite even when you think the blast radius is small.
Human-in-the-Loop Review for Low-Confidence Extraction
Confidence fields are only useful if something happens when confidence is low. My rule is simple: below a threshold, the extracted value is provisional. The conversation keeps moving (don't make the customer wait for a reviewer), but the record is flagged and lands in a review queue.
The review screen should be fast. Show the user's original message, the evidence quote, the proposed value and the allowed alternatives. A reviewer should be able to accept or correct with a single keystroke. If review takes more than a few seconds per item, people stop doing it.
Then close the loop. Every correction is a labeled example. Feed them into your extraction eval set, and you'll know whether the next prompt tweak actually helps. That's the core idea behind an eval gym for conversational agents: production corrections become regression tests.
Pick the threshold by looking at the data, not by intuition. Say your review queue shows the model is almost always right above some confidence level and frequently wrong below it; that's your line. Revisit it whenever you change models.
Testing the Whole Flow Without a Phone
Because the runner is pure, most testing never touches a real channel. My usual setup:
Scripted conversation tests. Each test is a list of user events and the expected sequence of questions and saved fields. Cover the happy path, every branch, typed versus tapped answers for each option, and "unknown" answers at every node.
A demo seeder. A script that fills the store with realistic fake conversations in every state: active, idle, re-contacted, human-owned, completed, closed. Now the admin inbox, the spreadsheet mirror and the alerts can be checked visually without waiting for real traffic.
A flow preview. Render the flow definition as a diagram (Mermaid works fine) so you and the business owner can walk it together. Many dead ends are obvious once you can see them.
Extraction evals, separately. A labeled set of messy messages per field, scored for exact match on the option id. These are the only tests that call a model, so they run on prompt or model changes rather than on every commit.
A small set of real-channel smoke tests. One or two conversations through the actual platform before each release, mostly to catch template, webhook and formatting problems.
Node-by-node checklist
For every node in the flow, check:
- It has a fallback transition for
unknownand off-script input - Every option has a single stable id used for button, extraction, storage and routing
- Typed and tapped answers route to the same next node (covered by a test)
- The extraction schema for this node only allows its option ids plus
unknown - There's a defined behavior when the user asks for a human here
- Re-contact copy exists, plus a template if the messaging window may have closed
- The saved fields are visible in the inbox and the mirror
How to Start
If you're building or rebuilding a chat agent this week, here's the order I'd follow:
- Draw the conversation. Nodes, questions, options with ids, and the end states. If you can't draw it, maybe you do need an agentic design.
- Write the pure runner and the scripted conversation tests before any channel integration.
- Add extraction and classification with schema-constrained output, an
unknownoption and a confidence field. Start with the smallest model tier. - Build the brain last: webhook handling, idempotency, effects execution, store and mirror.
- Make ownership explicit:
bot/humanstate, takeover from an inbox, and an alert with a delivery check. - Move timers out into scheduled jobs that emit events.
- Add the review queue for low-confidence extractions and turn corrections into evals.
- Instrument the failure modes in the table above before launch, not after the first angry customer.
If you're curious about how I structure agent work more generally, the multi-agent experiments in Akashi and my notes on Claude Code agents and skills show the other end of the spectrum, where letting the model plan really does pay off.
Conclusion
The models are good enough now that the tempting move is to hand them the whole conversation. For customer-facing bots with a known shape, I think that's still the wrong trade. Cheap small models have made language understanding almost free; reliability is the scarce thing. Put code in charge of what happens next, use the model to understand what people mean, and give humans a clear way in. You lose some charm in the demo. You gain a bot you can test, debug and trust on a Saturday night when nobody is watching the inbox.