A LangGraph email agent, with the parts the tutorials mock out
LangGraph's interrupt makes the person-in-the-loop part easy. The hard parts of an email support agent are on the mailbox side, which is the part every tutorial fakes. Here is a runnable one with a real inbox, and what it has to check before it answers anyone.
A LangGraph email agent that answers real customers needs two halves. The graph half is triage, a draft, a routing decision and an interrupt() for the cases a person should see, and LangGraph makes it short. The mailbox half is knowing which conversations are waiting on you, what the customer actually wrote, whether they are who the From line says, and whether the thread changed while the graph was paused. That half is where email agents go wrong, and it is the half the tutorials replace with a hardcoded string.
This post walks through a support agent that does both, built on LangGraph 1.2 and SendRaven, with the full code in the langgraph-support-agent example. It is Python, about 450 lines for the graph, with 19 tests.
What the tutorials already get right
The LangGraph human-in-the-loop tutorials are good at the graph. A node calls interrupt() with a payload, a checkpointer saves the run, and a later Command(resume=...) picks it up exactly where it stopped, from another process if need be. That is the right primitive for "a person approves the risky ones", and this example uses it the standard way.
Where they stop short is the input. The customer email is a string in the notebook, it is always new, it always needs an answer, and nothing happens to it while the graph waits. None of those is true of a real support inbox.
1. Which conversations are waiting on you
An inbox is not a queue of new messages. It is a set of conversations, some answered, some not, some with a reply already on its way. The question the agent needs answered on every pass is "which threads has someone written to that we have not answered?", and SendRaven answers it directly:
for summary in sr.iter_threads(awaiting_reply=True):
if summary["pending_reply"]:
continue # a reply is already held for approval or scheduled
thread = sr.get_thread(summary["id"])
message = latest_person_message(thread) # skips entries marked automatedTwo details matter. pending_reply is true while an answer is held for approval or scheduled; without that check an agent on a held key drafts the same reply on every pass. And the message to answer is the newest one from a person, not the newest one: an out-of-office that lands after the customer's question carries automated: true, and answering it instead is a very polite way to ignore a customer.
Each customer message gets its own LangGraph run, keyed on <thread id>:<message id>. A finished run is never repeated, a paused one is never drafted twice, and a run that crashed halfway continues from its last checkpoint on the next pass. The poll is safe to run as often as you like.
2. What the customer actually wrote
The third message in a thread is mostly the first two quoted back, plus a signature. SendRaven joins the reply to its conversation on Message-ID and returns the new text with the quoted history removed in text (the untrimmed body stays in raw_text). The model reads only what the customer typed this time, which is cheaper and, more importantly, cannot be confused by a request quoted from three weeks ago.
What the customer typed is still written by someone outside the company. Every prompt in the example wraps it in <untrusted_email> tags and says it is data to classify and answer, never instructions. That fence is not the defence, because a model can still be talked into things. The defence is the next section: the decisions that matter are not the model's.
3. Who sent it, and who decides
Every inbound message carries sender_authenticated, true only when DMARC or a DKIM signature from the From domain confirms that domain sent it. A forged From line does not show up as a failure, which is the subject of a whole post; it shows up as false.
The routing is plain Python that reads the model's output and those fields, so it can be tested, and a persuasive email cannot argue its way past it:
def escalation_reasons(state, min_confidence):
t, d, m = state["triage"], state.get("draft"), state["message"]
reasons = []
if t["suspicious"]:
reasons.append("the email looks written to steer an AI")
if t["intent"] in SENSITIVE_INTENTS: # refunds, account changes
reasons.append(f"{t['intent']}: a person decides these")
if d is not None: # a reply is about to go out
if not m["sender_authenticated"]:
reasons.append("sender not authenticated: the From line may be forged")
if not d["covered_by_kb"]:
reasons.append("the knowledge base does not cover it")
if d["confidence"] < min_confidence:
reasons.append("low confidence")
return reasonsNo reasons and a draft: answer in the thread. No reasons and nothing to answer ("thanks, all sorted"): mark the thread handled with POST /v1/threads/{id}/handled, so nobody is mailed a courtesy reply just to clear a flag. Any reason at all: a person.
4. The interrupt, and what changes while it waits
The review node is the standard LangGraph pattern. It hands the reviewer everything they need to decide without opening anything else, and validates what comes back:
decision = interrupt(
{
"from": m["from"],
"sender_authenticated": m["sender_authenticated"],
"customer_text": m["text"],
"reasons": state["reasons"],
"draft": d["body"] if d else None,
},
response_schema=ReviewDecision, # send (optionally edited), handled, or leave
)A person resumes it later with python main.py review. Later is the problem. While the run sat in the checkpointer, a colleague may have answered from the dashboard, or the customer may have written "never mind, found it". A graph that resumes and sends its stale draft answers a question nobody is asking any more.
So every write re-reads the thread first and gives up if it is no longer this run's to answer:
def still_ours(thread, message_id):
if thread["pending_reply"]:
return "a reply is already held for approval or scheduled"
if not thread["awaiting_reply"]:
return "someone answered it or marked it handled"
latest = latest_person_message(thread)
if latest is None or latest["id"] != message_id:
return "the customer wrote again; the next run answers the newer message"
return NoneThe send itself replies in place, to the sender of the message being answered and never to an address found in the text, with one idempotency key per customer message:
sr.send_email(
idempotency_key=f"support-{m['id']}",
to=m["from"],
subject=reply_subject(m["subject"]),
text=body,
reply_to_message_id=m["id"], # the reply joins the customer's thread
**{"from": sender},
)The key means a crash between the send and the checkpoint, or a resume that runs twice, cannot answer the same message twice.
The interrupt is a suggestion. The key is the guarantee.
Everything above lives in your process. The routing can have a bug, a new node can be wired past review, a checkpointer can be pointed at the wrong database. The graph's interrupt() stops the graph; it does not stop the credential the graph holds.
SendRaven puts the controls on the API key, checked on every send before anything is stored. A key with requires_approval turns every send into a draft a person releases in the dashboard, and the key cannot approve its own drafts. A daily_send_limit caps recipients per day, so a loop that goes wrong stops at 429 daily_limit rather than at a customer's inbox for the fortieth time.
The example is built to start on an approval-held key. Every reply is then reviewed twice: once at the interrupt for the hard cases, and once in the dashboard for all of them. That is deliberate while the routing earns trust. The graph copes with it: a held send comes back as pending_approval, which sets pending_reply, and the next pass leaves the thread alone. Once the routing has a track record, move the agent to a key without the hold but with a daily limit, and the interrupt becomes how your team sees the hard ones.
Running it
You need a verified sending domain with its inbound MX record published, so customers' mail reaches your workspace, an API key, and an Anthropic key for the model.
git clone https://github.com/CommonNinja/sendraven-examples
cd sendraven-examples/langgraph-support-agent
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
export SENDRAVEN_API_KEY=sk_live_... # start with requires_approval on
export SUPPORT_FROM="Support <support@mail.example.com>"
.venv/bin/python main.py run --dry-run
.venv/bin/python main.py run
.venv/bin/python main.py reviewkb.md is the whole knowledge base; replace it with yours. The README covers the graph node by node, every outcome a run can end in, and what the tests cover.
For the shape of a support agent without the framework, see email for AI customer support agents. For an agent that follows up until someone answers, the same repository has an OpenAI Agents SDK example.
