LLMs at the Edges: Designing Conversational Agents With a Deterministic Core

12 min read
AIAI AgentsSoftware EngineeringGuardrailsHuman-in-the-Loop

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 / decisionWho handles itWhy
Which question comes nextFlow (code)Known shape; must be repeatable and auditable
"Tomorrow after 5 maybe?" into a time windowLLM extractionMessy natural language, low-risk structured output
"Is this person asking about price or complaining?"LLM classificationFuzzy intent, bounded label set
Tapped button or quick replyFlow (code)Already structured; no model needed
Anything with money, legal, or health consequencesHuman handoffRisk too high for either code or model to decide alone
Off-script question the flow can't answerLLM reply or human, by policyDepends on how much you trust your guardrails
Model confidence below thresholdHuman review queueCheaper 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.

Loading diagram...

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.

  1. 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.
  2. Classification. Map a message to one of a fixed set of intents: answering the question, asking something off-script, wanting a human, opting out.
  3. 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:

TaskSuggested model tierCost / latency notes
Field extraction into a schemaSmallest "nano/mini" tierNear-free per call; fast enough to feel instant in chat
Intent classification (fixed labels)Smallest tier, or a fine-tuned classifier at volumeBatch-friendly; easy to eval against a labeled set
Normalizing dates, amounts, namesSmallest tier plus deterministic post-processingLet code do the date math; the model only reads the phrase
Free-form reply to off-script questionsMid tier, grounded in your docsThe only call where tone and accuracy really matter; budget for it
Summarizing a conversation for a humanMid tierRuns 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.

Loading diagram...

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 modeWhat it looks likeHow to detect it
Silent dead endA node with no valid transition for some input; the bot just stops replyingStatic check: every node has a default/fallback edge. Alert on any conversation with an inbound message and no outbound within N minutes
Paused and forgottenBot paused for a human who never shows upAlert on HumanOwned conversations with no staff message after a threshold
Missed handoff alertHandoff notification fails to sendTreat the alert as an effect with a delivery receipt; retry and escalate
Outside the messaging windowRe-contact fails because the platform only allows free-form messages within a time window after the user's last messageTrack the window per conversation; use pre-approved templates when it's closed. Check your platform's messaging-window rules
Double sendWebhook retried, user gets the same question twiceIdempotency key on inbound message id before calling the runner
Extraction driftModel update changes how "next month" is bucketedLabeled extraction eval set, run on every prompt or model change
Injection through free textUser 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 unknown and 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:

  1. 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.
  2. Write the pure runner and the scripted conversation tests before any channel integration.
  3. Add extraction and classification with schema-constrained output, an unknown option and a confidence field. Start with the smallest model tier.
  4. Build the brain last: webhook handling, idempotency, effects execution, store and mirror.
  5. Make ownership explicit: bot/human state, takeover from an inbox, and an alert with a delivery check.
  6. Move timers out into scheduled jobs that emit events.
  7. Add the review queue for low-confidence extractions and turn corrections into evals.
  8. 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.