Putting an LLM in the reply loop
Deterministic checks before the model, labels that can't slide backwards, and an autonomous reply agent that is allowed to ask.
When a prospect replies to an outbound email, someone has to decide what that reply means. Is it a yes? A no? An out-of-office? A polite "talk to my colleague instead"? At Roger, a model reads every reply first, and its answer decides what the system does next.
That makes the classifier one of the most consequential pieces of AI in the product. This post is about what it took to make it trustworthy: deterministic checks in front of the model, labels that can't slide backwards, and an autonomous reply agent that is allowed to say "I don't know".
What the model decides
Every inbound reply gets one label. Each label has its own schema with the fields that matter for it: a return date for an out-of-office, the named contacts for a redirect, an interest level and next action for a positive reply, and a flag for when the prospect asks to be removed. The label then drives the sequence:
| Label | What happens |
|---|---|
| Bounce | The sequence ends |
| Out of office | The next step moves past the return date |
| Interested, neutral, not interested | Pending steps are cancelled and the thread becomes a conversation |
| Role change, redirect | Same as above, and the named contact may become a referral |
Each label is a zod schema, and the model returns structured output that is parsed against it. The parser is forgiving about presentation (it strips code fences and conversational preambles) and strict about content. Output that doesn't match the schema is an error, not a best guess.
Deterministic first
The classifier started as regular expressions, moved to an LLM, and then ended up as both, in that order.
The reason was a bug report I won't forget: the model was labelling ordinary human replies as bounces. Nothing in the prompt was wrong exactly; a reply that quoted an earlier delivery failure looked enough like one. The fix wasn't a better prompt. Delivery status notifications are machine-readable, so the worker now parses their structured status fields deterministically and decides "is this a bounce?" before the model is ever called.
The same goes for automatic messages. Headers like Auto-Submitted are checked in code, and a "redirect" that arrives with automatic-reply headers is treated as an auto-reply, not as a person pointing us to someone. The model sees only what the cheap, exact checks couldn't settle.
Labels have precedence
A conversation's label can only move up. Once a thread is interested, a later "thanks, talk soon" doesn't demote it to neutral, and a label a user set by hand is never overwritten by the model. That logic lives in a small priority module, not in the prompt, because "don't make the pipeline forget what it already knew" is a property of the system, not of any single call.
The classifier also receives the thread's history, not just the latest message. A one-line "sounds good" means something very different after a pricing question than after "should I take you off the list?"
Context is also where the subtle mistakes live. One real conversation looked like interest: the prospect was enthusiastic and asked for a call. They were pitching their own product. We added explicit rules to separate buyer intent from a counter-pitch, and tests that assert those rules stay in the prompt.
A referral is not an invitation
"You should talk to Priya, she owns this" is valuable. "Looping in Priya" with Priya cc'd, a blanket "please direct all vendor email to procurement", and an automatic "I've left the company, contact Priya" are all different, and only some of them are invitations to email Priya.
Getting this right took about a dozen fixes. The design that came out of it:
- Reply processing only records the referral. It enqueues a job and moves on. The expensive work, such as enriching the new contact and drafting the outreach, happens in a worker.
- A gate decides whether outreach is appropriate, with header and pattern checks running before any model is asked.
- The customer reviews it. They get a preview with a signed link to edit or decline. It only starts automatically if the organization opted in, and only after a delay.
- Duplicates are absorbed by the database. A partial unique index means the same reply processed twice produces one referral.
Autonomy that can ask
With autonomy switched on, a model drafts the response to replies labelled neutral or interested, and follows up on threads that have gone quiet. Its output is a structured decision, not just text:
- reply — here is the message to send.
- ask_user — I need something only the customer knows.
- stop — this conversation shouldn't continue.
ask_user turned out to be the most important of the three. The customer gets the question with a signed link to answer it, and the answer is stored and supplied as context to future conversations, so the agent asks each question once.
The guardrails are mostly about honesty:
- It writes as the sender, in the first person.
- Prospect text is untrusted input, never instructions.
- It never promises what the system can't do, such as sending a calendar invite or using a booking link that doesn't exist.
- Replies are short, with a hard word cap per channel.
Customers choose a reply delay and can preview drafts before they go. Right before sending, the system re-reads the settings and checks whether the thread changed after the draft was written. If the prospect sent another message in the meantime, the draft is thrown away instead of answering a conversation that has moved on.
Treat model output like user input
Every place a model's answer is persisted, it gets the same suspicion as a form submission:
- Fields the model shouldn't control are overwritten. If the template says a follow-up goes out three days later, the model's opinion on timing is discarded.
- Decisions are revalidated against real data. When a model recommends changes to a campaign, its output is checked against the organization's actual campaigns before anything is saved.
- Repair passes run only when needed. Generated emails are compared against the template, and the extra clean-up call only happens when the output has drifted.
- Providers fail over. When a web-search call is rate-limited or refuses, the request falls back to a second provider.
- Concurrency limits come from measurement. One backend accepted requests happily up to a certain concurrency and then refused them, even though its quota headers said there was room. The fix was an in-process limiter set from what we measured, not from what the documentation implied.
Cost and model churn
Every model call records the feature, organization, campaign, and sequence it belongs to, and cost accounting includes prompt-cache reads and writes. Before that was fixed, one feature's spend was over-recorded several times over. Cost you can't attribute is cost you can't reason about.
Models also change constantly. The sequence generator alone has moved across three providers and several model generations. That stayed a chore rather than a project because every provider sits behind one small helper with the same structured-output interface, and each task picks its own model: fast and cheap for filling a template per contact, the strongest available for writing the template itself and for autonomous replies.
Finally, all of this runs in queue jobs, not request handlers. LLM calls are slow, rate-limited, and occasionally strange, and the queue gives them retries, concurrency limits, and somewhere to wait.
What I'd keep
- Put deterministic checks in front of the model for anything that is machine-readable.
- Keep state rules out of prompts. Precedence, idempotency, and permissions belong in code.
- Give agents an "ask" option and remember the answers.
- Validate model output like user input, and discard fields the model shouldn't own.
- Attribute every call's cost from the first day.
- Make swapping models cheap, because you'll do it often.
The model is the most flexible part of the system. Everything around it should be the least.
All posts