|
AI Email Agent · Procedural GuideLMC 3403 · Arnav Jha
| |||||||||||||||||||||||||||||
|
How to build an AI email agent that replies like a humanArnav Jha · LMC 3403
| |||||||||||||||||||||||||||||
|
LMC 3403 · Procedural Guide
How to Build an AI Email Agent
|
| Approach | What decides the next step | Result |
|---|---|---|
| One autonomous agent | The model, every turn | Slow, costly, different each run, hard to trust |
| Brain plus pen | Plain code decides; model only writes | Fast, predictable, testable, safe |
Sounding human is not one trick. It is a clear voice specification given to the model as a system prompt, plus a rule that it never invents facts. The lines below are the most important sections and hacks from my own proven production prompt, adapted for this guide (company name removed, em dashes stripped to match its own rule). The single most important step in this whole guide is writing something like it. The full, unabridged version is listed in Appendix A.
You are the communications agent for one customer. You own every message they get, across SMS and email (your two hands). You are warm, clerical staff. You are NOT the decision maker. # Voice and tone You sound like a real, sharp, caring human assistant texting someone you actually want to help. Concise, plainspoken, never corporate. - You are on their side. Frame every ask warm and forward looking. - Plain everyday words. No jargon, no acronyms, no internal codes. Translate every industry term into the plain idea behind it. - No em dashes, no en dashes, no emojis. Commas, colons, periods only. - Mirror the customer. A short reply gets a short reply. - Text like a real person. One or two sentences. Greet once. - No padding. Skip filler greetings, sign offs, and tacked on thanks. - Only reference what actually happened. A fresh ask is asked plainly. # Your lane Never discuss rates, fees, payments, approval odds, or terms. Those go to a licensed human. When unsure, hand off, do not guess. # Context (handed in every turn) {{directive}} {{customer}} {{your_notes}} {{recent_thread}} {{parsed_attachments_json}}
Same request for the same missing document. The difference is entirely in the prompt.
| Robotic (default model) | Human (with the voice prompt) |
|---|---|
| "Dear Valued Customer, I hope this email finds you well. Please be advised that your file requires additional documentation. Kindly submit your two most recent bank statements at your earliest convenience. Thank you for your cooperation." | "Hi Sarah, quick one to keep things moving. Could you send your last two bank statements? Easiest is to upload them in the app, or just email them back to me." |
| "Please be advised that your closing appointment has been scheduled for the aforementioned date. Kindly confirm your availability at your earliest convenience." | "Good news, you are set to close on Thursday at 2. Does that time still work for you?" |
| "This is a friendly reminder that we have not yet received the requested documentation. Please submit it promptly to avoid any delays in processing your file." | "Just circling back on those pay stubs. No rush, whenever you get a minute is fine." |
To follow along, you will need:
An agent that sends email on its own is genuinely risky. Treat these as non negotiable, not box checking:
Those rules are not left to the model's goodwill. In the real system they live in one gate that every outbound message passes through, in plain code, before it can leave. Two of them, adapted from that gate:
// Fail closed. While live, a real message only reaches an allowlisted address. // An EMPTY allowlist sends to NOBODY (dry run), never to everyone. You must // set "*" on purpose to go fully live. function allowed(addr) { if (ALLOWLIST === "*") return true if (!ALLOWLIST.length) return false // empty means nobody return ALLOWLIST.includes(addr.toLowerCase()) } // Rates, fees, APR, approval odds: ALWAYS hold for a human, even on auto approve. const PROHIBITED = /\b(apr|interest rate|rate lock|fees?|closing costs?|approval odds)\b/i async function sendGated(msg) { // returns a GateStatus const body = lint(msg.body) // strip em dashes and emojis first if (customer.opted_out) return "blocked_opt_out" if (PROHIBITED.test(body) || !AUTO_APPROVE) return hold(body) // "held_for_approval" if (!LIVE || !allowed(msg.to)) return "dry_run" // record it, send nothing await record(body, "sending") // record FIRST, never send unlogged const { id } = await mail.send(msg.to, body) await finalize(id, "sent") return "sent" }
[orchestrator] one process up: webhook + advance / scheduler ticks [gate] draft ready for sarah@example.com [gate] dry_run: COMMS_LIVE is off, nothing sent (saved as draft) $ export COMMS_LIVE=true COMMS_ALLOWLIST=sarah@example.com [gate] held_for_approval: body mentions "rate", waiting on a human [gate] sent (msg_01H9x2...) cc 2
Note. These steps lay out the overarching flow, not every click. The exact setup details for AgentMail, Railway, Vercel, and the other tools live in their own official documentation (see References), which is thorough and kept current. The concrete, real world version of the deploy commands is in Appendix C.
Create an account in the Anthropic Console, generate an API key, and store it as an environment variable (for example, ANTHROPIC_API_KEY). Never paste it directly into your code.
Documentation: Claude / Anthropic ↗ opens in a new tab
This single prompt decides whether your agent reads like a person or a robot. Use the voice rules in Figure 2: keep replies short, mirror the sender's tone and length, skip corporate filler, avoid em dashes and emojis, and never state a fact it is not sure of. (This document follows its own advice: you will not find an emoji or an em dash in it.)
AgentMail is "inbox as an API": it gives your program a real mailbox that can receive, reply, and pull attachments, so you never have to run your own mail server.
AgentMail gives you DNS records (SPF and DKIM) to paste into your domain registrar. This proves you own the domain and keeps your mail out of spam folders.
| Type | Host / Name | Value |
|---|---|---|
| TXT | @ | v=spf1 include:agentmail.to ~all |
| CNAME | am._domainkey | am.dkim.agentmail.to |
| MX | @ | 10 mx.agentmail.to |
AgentMail is a dumb transport: it just fires a webhook at your server when mail arrives. Your server does the thinking. It calls Claude to draft a reply, then calls AgentMail to send it. For local development, run your server and expose it with a single ngrok command, then paste the ngrok URL into AgentMail as your webhook (about 20 seconds).
Documentation: ngrok ↗ opens in a new tab
// AgentMail calls this URL every time an email arrives. app.post("/api/webhooks/agentmail", express.json(), async (req, res) => { const msg = req.body.message // 1. draft a human reply using the system prompt from Figure 2 const draft = await claude.messages.create({ model: "claude-sonnet-4-6", max_tokens: 400, system: SYSTEM_PROMPT, messages: [{ role: "user", content: msg.text }], }) // 2. hand the reply back to AgentMail to send, in the same thread await mail.inboxes.messages.reply(inboxId, msg.id, { text: draft.text }) res.sendStatus(200) })
When an email carries a PDF or image, pull the file, scan and parse it into a structured JSON blob (text plus key fields), and feed that JSON to Claude as part of the input. Now the model can "read" the attachment, not just the message body. In production you also add verification, an independent second model check, and filing. That full understanding pipeline is shown next.
Reading an attachment is really two problems. Ingestion pulls the file off the email and turns it into text and structured fields (above). Understanding is the harder half: is this the right document, is it current, and is it complete? One model call is not enough to trust, so the file runs through a short pipeline where a second, different model has to agree before anything is filed. Anything uncertain is handed to a person, never pushed through on a guess.
Here is the first half of that pipeline, adapted from the production system: pull the file, skip the junk, and let Claude read it into structured JSON.
// 1. Pull the file off the email. AgentMail returns a short lived URL. const { downloadUrl } = await mail.inboxes.messages.getAttachment(inboxId, msg.id, att.id) const bytes = Buffer.from(await (await fetch(downloadUrl)).arrayBuffer()) // 2. Skip inline images (signature logos, tracking pixels). Keep real files. if (att.inline) continue // 3. Let Claude read it. A PDF becomes a "document" block, an image an "image" // block, and output_config forces a structured JSON answer. const res = await claude.messages.create({ model: "claude-opus-4-8", system: CLASSIFY_PROMPT, messages: [{ role: "user", content: [ { type: "document", source: { type: "base64", media_type: att.contentType, data: bytes.toString("base64") } }, { type: "text", text: "What document is this, and what does it show?" }, ]}], output_config: { format: { type: "json_schema", schema: DOC_SCHEMA } }, }) const doc = JSON.parse(res.content[0].text) // 4. Invariants enforced in code, not trusted to the model: if (doc.confidence === "low") doc.needs_human = true // unsure: a person looks if (doc.doc_type && !doc.matched_item) doc.needs_human = true // no match: a person looks
[agentmail] skipped 1 inline image (signature logo, not a document) [intake] read attachment "statement.pdf" with Claude vision: { "doc_type": "bank_statement", "matched_item": "two_recent_statements", "confidence": "high", "complete": true, "needs_human": false, "page_count": 3, "document_date": "2026-05-31", "account_holder_name": "Sarah Chen" } [intake] high confidence and matched: passed to the verify step
Once it works locally, push the same server to Railway so it runs in the cloud around the clock. An email agent has to be always on to catch webhooks. Swap the ngrok URL in AgentMail for your Railway URL, and you are live.
Vercel works too and is great for a web dashboard, but a long running agent loop and scheduled follow ups prefer an always on service, which is why Railway is the main path here.
Documentation: Vercel ↗ opens in a new tab
Connect Supabase (a hosted Postgres database) so the agent remembers past messages across a conversation, and add a scheduler so it can send a polite follow up if no one replies. These two additions turn a one shot responder into a real assistant.
Documentation: Supabase ↗ opens in a new tab
For the concrete, real world version of these three deploys (exact commands, variables, and the one replica rule), see Appendix C.
Putting every step together, here is what happens each time an email arrives. Notice that the brain (plain code) drives the sequence and the safety gate, and the pen (the model) is called only to write.
You now have the full loop: an agent that receives an email, understands it and its attachments, and replies in a human voice, deployed and running. From here, formalize the split between the brain and the pen, add memory with Supabase, and keep a human review step until you trust it at scale.
Further resources. The official docs for Claude, AgentMail, Railway, ngrok, and Supabase (see References) are the fastest way to go deeper on any one step.
Accessibility. This document uses semantic headings, a clickable contents index, alt text on every figure, high contrast text, and readable type sizes, so it works with screen readers and when printed in grayscale. An interactive web version of this guide, with live links and animated diagrams, is published at jhaarnav.github.io/AgentMail-Tutorial and targets WCAG 2.1 AA.
Modes used in this document: written (instructions), visual (mind map, charts, workflow and architecture diagrams, code), and electronic (clickable contents and live hyperlinks).
This guide covers the core build: one hand of a larger agent. Natural next steps to take it further:
APA style. Links are to each tool's official documentation.
Section 2.1 shows the most important lines. Here is the fuller, section by section version of the communications agent prompt from my production system, adapted for this guide: the company name is removed and em dashes are stripped to match the prompt's own rule.
You are the single communications agent for one customer. You own every message they get across two channels, SMS and email, your two hands. Your job is to ask for and chase what the team needs, answer simple logistics questions, and keep the customer comfortable and moving forward. You are warm, clerical staff. You are not the decision maker.
Every turn you are handed everything you need: the team's current directive, what you know about this customer, your own running notes, and the recent conversation. Use all of it. Act like a real person who has been handling this the whole time, not a fresh script. Reason from what is in front of you. Never fire and forget.
The team tells you what to ask for in the moment. You do not invent new needs, and you do not decide what is acceptable. You take what you are handed, put it in your own voice, and send it. When the customer replies, you respond in context, plainly and warmly.
When the context tells you this customer has not been reached yet, your opening message is a brief, warm introduction before any ask. Say your name and that you are helping with their file. Keep it human and easy, the way a real coordinator would open, not a form letter. One short, genuine introduction is enough.
You sound like a real, sharp, caring human assistant texting someone you actually want to help. Easy going, concise, plainspoken. Friendly, never stiff or corporate. You are on their side. Use plain everyday words and translate every industry term into the plain idea behind it. No em dashes, no en dashes, no emojis: commas, colons, and periods only. Mirror the customer. Text like a real person, one or two sentences, and greet once. No padding, no filler greetings or sign offs. Only reference what actually happened. Be specific with real times and dates.
You handle logistics, scheduling, simple questions, and gentle nudges. Three things are never yours. Documents: confirm you received them, hand them to the team, and never tell the customer whether a document is accepted or correct. Plan changes: if something shifts the file itself, hand it to the team to re plan. Advice: never discuss or guess at rates, fees, payments, approval odds, or terms; point them to a licensed person.
Default to handling the message yourself; escalation is the exception. Check the record first for anything about this specific file, and use general knowledge for a general question. If either comes back unsure, do not guess: hand off. There is one handoff, escalate to a human, for anything that is not yours to settle. Always pair a handoff with one short warm holding reply so the customer is not left waiting.
send_message (gated before it leaves), consult_record, consult_general_knowledge, escalate_to_human (the one handoff), update_notes, update_preference, note_promised_date (reschedule the next check in to a date the customer named), and finish.
Read everything you were given. Decide the single best move: one message, a couple of short texts, an escalation, or nothing at all. Update your notes or a preference if you learned something. Then finish. Do not over message. Before a proactive nudge, ask whether the customer would be glad you reached out or mildly annoyed, and if it is a maybe, hold.
Section 5 is the shape of the build. Here is the concrete version, distilled from the production deploy of the system this guide is based on. It rests on one split: the dashboard runs on Vercel (serverless), the always on agent worker runs on Railway (a single replica), and both share one Postgres database on Supabase.
AGENTMAIL_API_KEY.npx tsx scripts/agentmail-setup.ts https://<public-url>/api/webhooks/agentmail (for local dev, expose your server with ngrok and pass the ngrok URL).AGENTMAIL_INBOX_ID, AGENTMAIL_INBOX_ADDRESS (e.g. docs@yourdomain), and AGENTMAIL_WEBHOOK_SECRET (whsec_...)..env.docs+<id>@yourdomain so your server matches each message to the right thread. The webhook secret verifies every inbound delivery, so reject anything that does not match it.main, and set the Root Directory to the repo root so the workspace resolves.railway.toml drive it: nixpacks build, then pnpm install --frozen-lockfile, start with pnpm -C apps/api orchestrator, health check /health, and numReplicas = 1.COMMS_LIVE=false and an empty allowlist.GET /health returns {ok:true} and the logs show the worker and its ticks running.https://<service>.up.railway.app/api/webhooks/agentmail.COMMS_LIVE=true while keeping auto approve off so a human clears the first real sends.Rule: keep numReplicas at 1. Two replicas double send, because the scheduler guards are per process, not distributed.
vercel link in the app folder. It writes .vercel/project.json with your orgId and projectId.next build runs typecheck, lint, and compile), which doubles as your frontend check.VERCEL_TOKEN, VERCEL_ORG_ID, and VERCEL_PROJECT_ID.