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
That Replies Like a Human

A fast, beginner friendly guide for junior developers, CS students, and founders who need to ship. Build an agent that reads an incoming email (and its attachments), understands it, and writes back like a real person.

About the author
Arnav Jha is the co-founder and CEO of Loandock, where he is building AI that replaces mortgage marketing companies. He is a computer science student at Georgia Tech, and spent roughly two years shadowing loan officers before writing a single line of code. Loandock clones a loan officer's face and voice to create their content, runs their ads automatically, and follows up with every lead in under a minute. It is backed by six-figure funding from Georgia Tech and generates over $100K a year in revenue. This guide distills patterns from the production communications agent he built there.

What you will build

An agent that receives an email, understands it and any attachments, and writes a natural, human sounding reply on its own, then deploys to the cloud so it runs around the clock.

Who this is for

Junior developers, CS students, and founders who can code a little, have never built an agent, and need to ship fast.

The three tools you will use

Claudethe LLM that reads and writes
AgentMailthe mailbox, send and receive
Railwayalways on cloud hosting

Contents

Preface: what this is, and where it is used

Before the how, the why. The thing you are about to build, a loop that takes in a message, understands it, and answers in natural language, is not a novelty email bot. It is the reusable core of an entire category of production AI.

Customer support agents Conversational assistants Sales follow up Scheduling Intake and onboarding Document chasing Any clerical task

At Loandock I use this exact loop as a borrower communications agent: it emails and texts borrowers, asks for the documents a loan file is still missing, and reads what comes back. Learn the pattern once here on email, and you can point it at almost any repetitive, language heavy task a person is doing by hand today.

1. Introduction: the brain and the pen

An AI email agent is a program that receives an email, figures out what it means, and writes and sends a reply on its own, with no human drafting each message.

Here is the mental model that keeps these systems reliable: separate the brain from the pen. A deterministic "brain" owns the overarching agenda, the actual job to be done, such as verify this person's income, send this month's payment reminders, or run a support conversation to resolution. It decides what should happen and when, in plain code you can trust. The "pen" is the language layer, a large language model (LLM) whose only job is to turn the brain's intent into words a human is happy to read. The brain decides; the pen writes.

The brain decides. The pen writes.

1.1 One master agent, many hands

In a full system, the email agent is just one "hand" of a larger master agent. Other hands parse documents, verify them, and file them. This guide builds one hand: the email pen.

Master agent owns the agenda (the brain) Email agent this guide (the pen) SMS agent Read documents Verify File to record Follow ups
Figure 1. One master agent, many hands. This guide builds the highlighted email hand.

1.2 Who does the work

A well built agent leans on plain code for almost everything and calls the model only for the narrow part it is genuinely best at: language and judgment. In my production system, the deterministic brain does the large majority of the work, and the LLM pen a small, carefully scoped slice.

1.3 Why not one big autonomous agent

A real story from building Loandock

When I first built Loandock's processor, I tried the opposite of this: one autonomous agent per file, left to reason through every step on its own. Stress tested on a real loan, it never even finished its first turn. It burned the entire run re deriving state my code already knew, and it behaved differently every time. I replaced it with a deterministic brain and a narrow LLM pen. Keep the machine in charge of what to do, and let the model handle only how to say it.

ApproachWhat decides the next stepResult
One autonomous agentThe model, every turnSlow, costly, different each run, hard to trust
Brain plus penPlain code decides; model only writesFast, predictable, testable, safe

2. How it sounds human

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.

2.1 The system prompt (real, adapted)

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}}
Figure 2. The voice section of the real system prompt, adapted. The full version is in Appendix A.

2.2 Robotic reply vs human reply

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."

3. Requirements

To follow along, you will need:

  • A Claude API key from the Anthropic Console, the LLM that reads and writes.
  • An AgentMail account and API key, plus a domain you own for sending.
  • ngrok (free) to expose your local server to the internet during development.
  • A Railway account for deployment.
  • Node.js 18 or newer installed locally.

4. Safety information

An agent that sends email on its own is genuinely risky. Treat these as non negotiable, not box checking:

  • Never hardcode API keys. Put every secret in environment variables. A key leaked in a public repo can cost you real money within minutes.
  • Start in a sandbox with a fail closed allowlist. Only send to addresses you explicitly list, and make an empty list mean "send to nobody," not "send to everyone." Point it at a real domain only after it behaves.
  • Keep a human in the loop before autosend. Hold drafts for approval until you trust it. A bad automated reply reaches a real person instantly, and you cannot unsend it.
  • Force hold sensitive content. If a draft mentions prices, rates, legal commitments, or anything you are not licensed to say, stop it and route it to a human. A model can be talked into saying things it should not.
  • Assume the LLM can hallucinate. It will confidently invent refunds, deadlines, and facts. Ground it in real data and never let it make promises on your behalf unchecked.
  • Mind data leakage. Whatever you feed the model leaves your machine and goes to the provider. Do not send secrets or data you are not allowed to share.

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"
}
The safety gate, adapted from the production system. The fail-closed allowlist and the prohibited-content hold are enforced in code, so no prompt can talk past them.
Console output · the gate deciding
[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

5. Step by step instructions

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.

1
Get your Claude API key.

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.

2
Write the system prompt that makes it sound human.

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.)

3
Create an AgentMail account and grab your API key.

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.

Live docs, embedded: AgentMail ↗
4
Add and verify your sending domain.

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.

TypeHost / NameValue
TXT@v=spf1 include:agentmail.to ~all
CNAMEam._domainkeyam.dkim.agentmail.to
MX@10 mx.agentmail.to
Figure 3. The SPF, DKIM, and MX records AgentMail gives you. Paste these at your domain registrar to verify the domain and keep your mail out of spam. Values shown are illustrative.
5
Wire the receive, generate, send loop.

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).

Sender customer AgentMail mailbox transport Your Server ngrok (dev), Railway (prod) BRAIN, code decides what and when PEN, Claude writes the reply 1. email in 2. webhook 3. send reply 4. reply out
Figure 4. The round trip. AgentMail only moves bytes; your server holds the brain and the pen.
// 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)
})
The receive, generate, send loop in about twenty lines. AgentMail calls your webhook, your server calls Claude, then your server hands the reply back to AgentMail. Everything else in this guide wraps safety and memory around these two calls.
6
Let it understand attachments.

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.

PDF / image attachment Scan and parse OCR / extract JSON blob { fields } Claude reads it as input
Figure 5. How the agent "reads" an attachment: file, parsed JSON, model input.

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.

Attachment PDF or image Classify what is it? (vision) Verify right? current? complete? Independent judge 2nd model agrees? File it mark received both accept uncertain or models disagree Escalate to a human review queue any doubt is checked by a person, never filed on a guess
Figure 6. The document understanding pipeline. Two different models must agree before a file is accepted, and every uncertain case goes to a human, so a wrong document is never filed silently.

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
Reading an attachment, adapted. The model returns a verdict as JSON, and simple code decides when a human must look before anything is filed.
Console output · reading one attachment
[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
7
Deploy to Railway.

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.

Live docs, embedded: Railway ↗
8
Or use Vercel.

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.

9
Add memory and follow ups (brief).

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.

For the concrete, real world version of these three deploys (exact commands, variables, and the one replica rule), see Appendix C.

6. The full workflow, end to end

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.

Sender customer AgentMail transport Your server the brain, code Pen Claude, LLM Human loan officer 1 sends email 2 webhook 3 verify, match, parse to JSON 4 if safe: draft reply or escalate (unsure / sensitive) reply text 5 safety gate allowlist, review, block 6 send reply + schedule follow up 7 reply delivered
Figure 7. The end to end loop as a sequence diagram. The brain (code) drives every step and the safety gate; the pen (Claude) is called only to write, and a human is looped in only when needed.

7. Conclusion, resources, accessibility

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).

8. Where this goes next

This guide covers the core build: one hand of a larger agent. Natural next steps to take it further:

  • Add more channels as extra hands of the same brain, so SMS and WhatsApp share the voice and safety rules you already wrote.
  • Ship the full document pipeline in code (the classification prompt, the hard-fact checks, and the double-accept rule behind Figure 6).
  • Add a test and evaluation suite that replays real threads and checks the agent's behavior before every deploy.
  • Budget cost and latency at volume, so you know what each reply costs in tokens and hosting before you scale.
  • Grow the pen into a fuller agent with durable memory, so it remembers every past thread, not just the recent one.

9. References

APA style. Links are to each tool's official documentation.

  1. Anthropic. (2026). Claude (Opus 4.8) [Large language model]. Used only for the visual elements of this document (layout, headers and footers, tables, diagrams, and placeholder visuals). All written content and technical ideas are the author's own or paraphrased from the author's production work. https://claude.ai
  2. Anthropic. (2026). Claude API documentation. https://docs.anthropic.com
  3. AgentMail. (2026). AgentMail documentation. https://docs.agentmail.to
  4. Railway. (2026). Railway documentation. https://docs.railway.com
  5. Vercel. (2026). Vercel documentation. https://vercel.com/docs
  6. ngrok. (2026). ngrok documentation. https://ngrok.com/docs
  7. Supabase. (2026). Supabase documentation. https://supabase.com/docs

Appendix A: the full system prompt

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.

Role

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.

You always have the full picture

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.

How you get work

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.

First contact

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.

Voice and tone

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.

Your lane

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.

Who answers

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.

Your tools

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.

Each turn

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.

Appendix B: glossary

LLM (large language model)
The AI, such as Claude, that reads text and writes text. In this guide it is the "pen."
System prompt
The fixed instructions that set the model's role, voice, and rules on every request.
Webhook
An automatic message one service sends to a URL of yours when something happens, for example AgentMail telling your server that mail arrived.
ngrok
A tool that gives your local computer a public URL, so a webhook can reach it during development.
SPF and DKIM
DNS records that prove you own a sending domain and keep your mail out of spam.
Allowlist
The explicit list of addresses the agent is permitted to send to. Fail closed means an empty list sends to nobody.
Gate
The single point in code that every outgoing message passes through for safety checks before it is sent.
Escalate
Hand a message to a human instead of answering it, used whenever the agent is unsure or the topic is sensitive.

Appendix C: final production grade deployment

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.

C.1 AgentMail

  1. Get your API key from the AgentMail console and set AGENTMAIL_API_KEY.
  2. Run the one time setup, passing your public webhook URL: 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).
  3. The script creates the inbox and registers the inbound webhook, then prints four values: AGENTMAIL_INBOX_ID, AGENTMAIL_INBOX_ADDRESS (e.g. docs@yourdomain), and AGENTMAIL_WEBHOOK_SECRET (whsec_...).
  4. Copy all four into your .env.
  5. Senders email 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.

C.2 Railway (the always on worker)

  1. New Railway service, connect the GitHub repo on branch main, and set the Root Directory to the repo root so the workspace resolves.
  2. Let railway.toml drive it: nixpacks build, then pnpm install --frozen-lockfile, start with pnpm -C apps/api orchestrator, health check /health, and numReplicas = 1.
  3. Add your variables (API keys, database URL). Keep every safety flag off for the first deploy, for example COMMS_LIVE=false and an empty allowlist.
  4. Deploy, then confirm GET /health returns {ok:true} and the logs show the worker and its ticks running.
  5. Take the public Railway domain and point your AgentMail webhook at https://<service>.up.railway.app/api/webhooks/agentmail.
  6. Go live safely: dark run first so nothing sends, add one test recipient to the allowlist, then set 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.

C.3 Vercel (the dashboard)

  1. Vercel hosts the Next.js dashboard. The long running worker cannot run here, which is exactly why it lives on Railway.
  2. Run vercel link in the app folder. It writes .vercel/project.json with your orgId and projectId.
  3. Every pull request gets a Vercel preview build (next build runs typecheck, lint, and compile), which doubles as your frontend check.
  4. For automated deploys, collect VERCEL_TOKEN, VERCEL_ORG_ID, and VERCEL_PROJECT_ID.
  5. Turn off Vercel's auto promote to production so a single deploy path stays in control, and keep preview deployments on.
  6. To roll back, open the Vercel dashboard, go to Deployments, and promote the previous production build.