Separate Talking From Acting: A Guardrail Architecture for Production AI Agents
The bug that changed how I build agents wasn't a bad answer. The phrasing was fine: polite, on-brand, confident. The agent promised a customer that someone would "confirm the delivery window tomorrow." Nobody had scheduled anything, and nobody was going to call. The model had written a nice sentence about a commitment the business never made.
That's the moment AI agent guardrails stop being a content-moderation problem and become an architecture problem. Once a conversational agent can quote, record data or hand off to a human, the dangerous bugs aren't about tone anymore. They're about state: things the agent claims happened, numbers it made up, promises the business can't keep. You can't fix that with a better system prompt. You fix it by separating talking from acting and putting a checkpoint between what the model wants to say and what actually gets sent.
This post is the layered design I now default to for customer-facing agents with tools (a quoting assistant over a product catalog, a booking bot, an order-intake flow). It maps onto LangGraph, the Vercel AI SDK or a hand-rolled agent loop.
The Failure That Changes Your Architecture
Task agents fail in boring, repeatable ways. After reading enough transcripts, I sort almost every incident into one of these:
| Failure mode | What it looks like in transcripts | Root cause | Fix lives in |
|---|---|---|---|
| Loops | Asks for the same field three times | Progress inferred from chat history | Dialogue state |
| Misroutes | Jumps to checkout while the user is still asking questions | No explicit goal per turn | Policy |
| Confabulated values | A quantity or date the user never gave | Model fills empty slots with plausible defaults | Tool guards |
| Phantom tool results | "I've saved that for you" with no tool call | Prose and side effects are coupled | Write path + ledger |
| Unbackable promises | Invented warranty, future confirmation, off-catalog price | Nothing checks outgoing text | Output guard |
The first instinct is to add a line to the prompt for each row. "Never invent prices." "Always confirm before saving." That works for a week. The prompt grows into a wall of prohibitions, the model starts weighing them against each other, and you're back where you started with a longer prompt.
The fix is structural: give each failure a layer that owns it.
Stage the Turn: Understand, Decide, Act, Compose, Guard
Instead of one model call that reads the chat and does everything, I split each turn into explicit stages. Each one has a narrow job and a typed output.
- Understand. Extract intent and slots (product, quantity, date, contact) into a structured object. This is extraction, not conversation.
- Route. Decide which flow or skill this turn belongs to.
- Decide the goal. A policy picks exactly one objective for this turn, like "collect delivery date" or "present quote," based on the dialogue state.
- Act. If the goal requires a side effect, a tool does it. The tool's result goes into a ledger.
- Compose. A model writes the reply from the goal, the state and the ledger, and nothing else.
- Guard. A checker inspects the draft for things the business can't stand behind before it leaves.
Staging also lets you pick a model per stage. Extraction and routing are classification jobs, so a small, fast model is usually enough. Composition is where tone matters, so it gets the better model. The guard can be mostly rules with a small model for the fuzzy cases. You pay for capability only where you need it.
Here's who owns what:
| Concern | Understand | Router | Policy | Tools | Composer | Guard |
|---|---|---|---|---|---|---|
| Intent & slots | Owns | reads | reads | |||
| Which flow | Owns | |||||
| Turn objective | Owns | reads | ||||
| State changes / side effects | requests | Owns | ||||
| Tone & phrasing | Owns | checks | ||||
| Facts (prices, dates, specs) | requires grounding | Source | cites | checks | ||
| Promises & commitments | must not create | Owns |
If you can't fill a row in that matrix for your agent, that concern is probably being handled by "the prompt," which in practice means nobody owns it.
Talking Never Changes State
This is the rule everything else hangs on: the composer can't change state. Not by phrasing, not by implication, not by "I've noted that." The only way anything gets recorded, booked, quoted or escalated is through a tool call that a deterministic layer executes and logs.
Two things make that rule hold.
An explicit dialogue state. I stopped letting the model infer progress from chat history. The agent reads and updates a small structured object, and code decides who may write each part of it. A from-scratch generic shape:
{
"flow": "quote_request",
"step": "collect_delivery",
"collected": { "product_id": "p-123", "quantity": 4 },
"confirmed": ["product_id"],
"open_questions": ["delivery_date"],
"ledger": [
{ "action": "catalog_lookup", "ref": "p-123", "ok": true }
]
}
The difference between collected and confirmed matters. A value the model extracted isn't a value the user agreed to. Fields that carry money, dates or commitments have to be confirmed before any tool will act on them. That one distinction kills most of the loops (the agent can see what it already has) and most of the confabulation (an unconfirmed guess can't flow into a write).
A ledger of what was actually delivered. Every tool call appends an entry with its result. The composer gets the ledger as context, and the guard checks drafts against it. If the draft says "saved," the ledger has to contain a successful save for this turn. If it doesn't, the claim is a phantom and the draft gets rewritten. I covered how to test this side of agents in evaluating what your agent does, not what it says. The ledger is what makes those evals possible in the first place.
Guards at the tool boundary. Tools reject bad input deterministically: schema validation, required-confirmation checks, range checks, catalog membership. When a tool rejects, it returns a structured reason ("delivery_date not confirmed") that the policy can act on next turn. That beats an exception the model will paper over with a cheerful sentence. When the model makes the same mistake over and over (a date format, a unit), I fix it in code at the boundary instead of adding another paragraph to the prompt. Deterministic remediation is cheaper and doesn't regress when you swap models.
One portability note: keep tool schemas boring. Some providers reject schema features that others accept (regex lookaheads, certain combinators, deep nesting), so the same tool definition can fail to register when you change providers. Flat objects, enums and simple string patterns travel well. Do the clever validation in code after the call.
Policy Before Prose: One Objective per Turn, Grounding Required
A lot of misroutes come from asking the model to do several things in one reply. Answer the question, move the flow forward, upsell, and confirm a detail. It picks the wrong mix. So the policy layer chooses one goal per turn, and the composer gets that goal as its brief.
The policy also enforces grounding rules before the model gets a chance to improvise:
- No data, no assumptions. If a required slot is empty, the goal is to ask for it, not to assume a reasonable default.
- No catalog lookup, no price. The "present quote" goal isn't reachable unless the ledger contains a catalog lookup for the items in question this turn.
- No availability check, no appointment. You can't confirm a time unless a tool returned it as available.
- Out of scope means handoff. If the intent isn't one the agent is built for, the goal becomes "hand off," not "do your best."
This is the same idea I described in designing conversational agents with a deterministic core: code owns the flow and the model handles language. Here the policy is the piece of code that decides what the model is allowed to talk about this turn.
The instructions themselves get structure too. Instead of one long prose prompt, I write tagged sections (role, non-negotiables, tools, style, output format) and give the whole thing a token budget. Sections make it obvious where a new rule belongs. The budget forces me to delete a rule when I add one, and to move anything enforceable into code. If a non-negotiable can be checked deterministically, it belongs in the guard, not the prompt.
The Output Guard: Things Your Agent Must Never Say
The guard is the last layer, and it's the one people skip because "the prompt already says not to." The prompt is a request. The guard is enforcement. It runs on every outgoing draft and checks for a short, explicit list of claims the business can't stand behind.
| Forbidden pattern | Example | Detection | Action |
|---|---|---|---|
| Deferred promise | "Someone will call you tomorrow" | Rule (future-tense commitment phrases) + ledger check for a scheduled follow-up | Rewrite without the promise, or hand off |
| Invented warranty / guarantee | "That comes with a 5-year warranty" | Rule (warranty/guarantee terms) + lookup against product data | Block the claim, rewrite from catalog facts |
| Off-catalog price | A price not returned by any lookup | Extract numbers with currency, diff against ledger | Rewrite with ledger prices or ask to quote |
| Unconfirmed appointment | "You're booked for Thursday at 10" | Ledger must contain a successful booking | Rewrite as a proposal, not a confirmation |
| Unsourced numbers | Lead times, quantities, specs | Numbers in draft must trace to state or ledger | Remove or rephrase as a question |
| Phantom action | "I've saved your details" | Action verbs vs ledger entries this turn | Rewrite truthfully |
| Wrong register | Too casual, wrong formality, wrong language | Small model classifier | Rewrite |
| Leaked internals | Prompt text, tool names, IDs | Rule + canary strings | Block |
Two design choices matter here.
Rules first, models second. Most of these are cheap to catch with rules plus the ledger: currency patterns, future-tense commitment verbs, specific terms. A small model handles the fuzzy ones like register and implied promises. Rules are deterministic, so they're explainable and they don't drift when the provider updates a model.
Rewrite before block, block before ship. A failed check sends the draft back to the composer with the specific violation ("remove the delivery promise; no follow-up is scheduled"). If the rewrite fails again, the turn goes to a human instead of sending something unsafe. A short delay is fine. A false promise isn't.
The same list doubles as your eval suite. Every pattern in the table becomes a set of test conversations that try to provoke it, and the eval gym runs them on every prompt or model change. The "leaked internals" row overlaps with adversarial testing, which I go deeper on in prompt injection and leakage tests and which the OWASP Top 10 for LLM Applications frames well under sensitive information disclosure and excessive agency.
Cheap Routing First: Lexical Rules With an LLM Fallback
Routing every message through an LLM classifier is the default, and it's usually overkill. Most traffic is predictable: product names, explicit requests, keywords that map cleanly to a flow. So the router is two-tier:
- Lexical rules first. Keyword and phrase matching against a maintained vocabulary per flow. Fast, free, deterministic and easy to debug ("it matched
invoice"). - LLM fallback only when no rule matches or several match with conflicting flows. The classifier returns a flow plus a confidence. Low confidence becomes a clarifying question, not a guess.
The part people miss is negative controls. Every lexical rule set needs a list of messages that must not match it. Think of words that look like a product name but aren't, or a flow keyword used in a different sense. Without negative controls, vocabularies only grow, and each addition quietly steals traffic from another flow. I keep positives and negatives side by side in the eval set, so a new keyword that breaks a negative fails the build.
Humans in the Loop That Compound
Human handoff isn't only a safety valve. Done right, it's how the agent gets better. When a person corrects the agent (maps "the big grey one" to a specific product, fixes a misrouted intent), that correction should become memory the agent uses next time.
The trap is letting automation overwrite people. So learned memory follows a precedence ladder:
- Human beats automation, always. An automatic match never replaces a human-set mapping.
- Confirmed beats inferred. A mapping the user explicitly confirmed outranks one the model guessed.
- Ambiguous matches never teach. If the agent picked between two plausible candidates, that choice doesn't get stored as learned memory, even if nobody complained. Silence isn't confirmation.
- Corrections are scoped and reversible. Store who made them and when, so a bad correction can be found and undone.
The handoff itself (triggers, context summaries, who owns the conversation once a person steps in) is its own topic, and I covered it in human handoff for AI chat agents. LangGraph's interrupt and human-in-the-loop primitives fit this layer well, since they let a graph pause at a node, wait for a person, and resume with their input in state.
Budgets and Failure Modes
A staged turn has more model calls than a single-shot agent. That's a cost and latency trade-off you should make on purpose:
- A turn budget. Every model call inside a turn shares one time and token budget. If extraction plus routing plus a guard rewrite would blow it, the turn degrades gracefully ("let me check with the team") instead of timing out silently.
- Backoff on rate limits. Retry provider rate-limit errors with exponential backoff and jitter, capped by the turn budget. Never retry a tool that already wrote. Make writes idempotent with a key in the ledger so a retry can't double-book.
- Fail closed. If a lookup tool errors or returns nothing, the policy treats it as "no data," which means no price, no confirmation and a question or a handoff. An empty search result is exactly when a model is most tempted to invent.
- Graceful handoff as the floor. Every failure path ends in a human, never a dead end.
Mapping the Layers to Your Stack
None of this needs a specific framework. Here's how I'd map it onto common ones:
| Layer | LangGraph | Vercel AI SDK | MCP |
|---|---|---|---|
| Dialogue state | Typed graph state | Your own state object passed per request | Server-side session state |
| Understand / route | Nodes with conditional edges | generateObject with a schema, then a switch | n/a (client side) |
| Policy | A node that sets goal in state | Plain function before the main call | n/a |
| Tools / write path | Tool nodes | tools with execute | Tools with input schemas |
| Ledger | State reducer appending results | Collect tool results from the step output | Logged per tool call |
| Composer | LLM node with goal + ledger | generateText / streamText | n/a |
| Guard | Node before END, loops back to composer | Middleware or post-processing step | n/a |
| Human handoff | interrupt | Route to your inbox / queue | n/a |
My public langgraph-ts-agents repo has examples of typed state and conditional routing in LangGraph.js, and mcp-deep-dive explores the tool side against the Model Context Protocol spec. Anthropic's Building effective agents makes a related argument from the other direction: start with the simplest workflow that works and add autonomy only where it pays.
Checklist: How to Start
You don't need all of this on day one. Here's the order I'd add it to an existing agent:
- Read real transcripts and tag each failure with a row from the failure-mode table.
- Introduce a dialogue state object with
collected,confirmed,stepandopen_questions. Stop relying on chat history for progress. - Move every side effect behind a tool, and append each result to a ledger.
- Require confirmation for fields that carry money, dates or commitments before any tool acts on them.
- Add tool-boundary validation that returns structured rejection reasons.
- Add a policy step that picks one goal per turn and enforces grounding (no lookup, no price).
- Write your forbidden-claims list and implement the cheapest rule-based checks against the ledger first.
- Turn each forbidden pattern into eval cases and run them on every prompt or model change.
- Split the router into lexical rules plus an LLM fallback, with negative controls in the eval set.
- Define memory precedence before you store a single learned correction.
- Set a turn budget and make every failure path end in a handoff.
- Restructure the prompt into tagged sections with a budget, and move anything enforceable into code.
Conclusion
The model is good at language. It's bad at being the source of truth for what happened, what costs what, and what the business has promised. A guardrail architecture stops asking it to be. Tools own state, the ledger owns facts, the policy owns the goal, the guard owns promises, and people own corrections. The model gets to do the one thing it's best at: turning all of that into a reply a customer wants to read.
Separate talking from acting, and most of the scary agent bugs become ordinary bugs. Those show up in a ledger, fail an eval and get fixed in code.