All posts

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:

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:

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:

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:

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

The model is the most flexible part of the system. Everything around it should be the least.

All posts