Milo docs
Milo is test mode for email: real inboxes on joinmilo.email that reply like real customers. Every persona is a deterministic state machine (same inputs, same replies, every run), so your CI can assert on outcomes.
Try it in 60 seconds
Free, no signup:
- From any mail client, email
refund-angry@joinmilo.email. Say anything. - Dana replies in seconds, hostile about a double charge. The footer of her first reply links to the live transcript at
/t/{token}. - Offer her a refund and watch
X-Milo-Stateflip todeescalated. Offer her a discount instead and watch it not.
The free tier includes 50 conversations a month per sending domain, enough to wire Milo into CI and find your first bug.
Quickstart
The integration is an email address. There is no SDK and no signup.
- Point your agent at a persona, e.g.
refund-angry@joinmilo.email. Dana replies over real SMTP, with correct threading, hostile about a double charge. - For CI, add a plus-address run id: mail to
refund-angry+run-8f3k2@joinmilo.emailstill reaches Dana, andrun-8f3k2becomes the conversation's run id. Generate run ids unguessably, at least 8 random characters (a UUID slice works); they are the only credential a lookup needs. - Assert on the transcript. Every conversation has a public token URL (linked from the first reply's footer), and run-id lookups return the same JSON.
Worked example: milo-agent-quickstart is a repo where a coding agent, given only the prompt "Set up Milo email-agent tests in this project. Fetch https://joinmilo.email/llms-full.txt and follow it.", built the whole integration: harness, contract tests, API tests, and an env-gated e2e suite. The second commit is entirely the agent's work.
No SMTP credentials in CI? Gate the email-sending tests behind an environment check (for example vitest's describe.skipIf(!process.env.SMTP_HOST)) so the suite stays green locally and lights up where credentials exist. Any authenticated SMTP submission works: your transactional provider's SMTP interface, a Google Workspace app password, or your mail server. Unauthenticated direct-to-MX sending is rejected by spam defenses; don't test that way.
State: a support thread's phases, as data
Every support conversation moves through phases: opened, escalating, waiting on the customer, resolved. Humans track those phases by instinct; CI can't. Milo makes them explicit. Every conversation carries one state, and your agent's words drive the transitions.
Who sets it: on each inbound email, the persona scans your message's new content (quoted text is stripped first) against the trigger list for its current state. The first matching trigger picks both the reply and the next state. No LLM, no scoring, no vibes — a hand-written lookup. Same words, same state, every run.
Dana's machine, for example:
start→opened→escalated→boiling→demanding-manager(terminal): the road to a lost customer.- Refund or apology language at any point →
deescalated→resolved(terminal): the exit your agent is supposed to find.
A real trace, from Milo's first production conversation:
- "Hi Dana, how can I help today?" → her opening complaint →
opened - "Thanks Dana" (no refund language) → she escalates →
escalated - "I've refunded you!" → matches the refund trigger →
deescalated - "ASAP" → she closes the thread →
resolved
You can read state three ways: the state field in the transcript JSON, the X-Milo-State header on every reply, and the chip on the transcript page. Which means your CI can assert on the *phase* a conversation reached — the full flow, not just the last message:
expect(thread.state).toBe('deescalated'); // the agent found the exit
Every persona's full state machine is published on its page — see the persona list below.
Strict mode
Add the reserved plus-tag strict to any persona address and vague resolution language stops counting. Default Dana accepts "we'll refund you ASAP"; strict Dana replies demanding an exact date and stays in her current state. A resolution only lands if it contains concrete specifics: a number of days, a weekday, a calendar date, an amount, or an explicit "within N days/weeks".
refund-angry+strict@joinmilo.email— strict, no run idrefund-angry+strict.run-8f3k2@joinmilo.email— strict plus a CI run id (the dot separates the modifier from the run id; unknown tags are treated as run ids)
Strictness applies per message, to the address that message was sent to. It affects refund-angry, chargeback-threat, and gdpr-request; ooo, silent, and happy-path have no resolution triggers to tighten.
Concrete means a number, date, or amount: "within 5 working days", "by Friday", "£119 refunded today" all pass, and ranges like "3-5 working days" count. "As soon as possible" never does.
The complete CI test
import { expect, test } from 'vitest';
import nodemailer from 'nodemailer';
const smtp = nodemailer.createTransport({ host: 'smtp.yourdomain.dev', port: 587 });
test('support agent de-escalates an angry refund demand', async () => {
const run = `run-${crypto.randomUUID().slice(0, 8)}`;
await smtp.sendMail({
from: 'agent@yourdomain.dev',
to: `refund-angry+${run}@joinmilo.email`,
subject: 'Hello from Acme support',
text: 'Hi! How can I help you today?',
});
// ...run your agent against Dana's reply, then assert on the outcome:
const res = await fetch(`https://joinmilo.email/api/threads/lookup?run=${run}&from=agent@yourdomain.dev`);
expect((await res.json()).state).toBe('deescalated');
});
Poll the lookup endpoint until the persona's reply lands; replies are sent immediately, so one short retry loop is plenty.
Reading the replies
Milo replies to whoever emailed it. There are four ways to read what came back, and most tests need none of the receiving ones.
Your CI, no inbox required. The lookup endpoint returns the whole transcript, both directions, as JSON: every persona reply's subject and body, plus the state machine's current state. Send with a plus-address run id, poll, assert. Two test shapes to know:
- Outcome tests: your test impersonates the agent's outbound mail and asserts on Milo's reaction. No receiving side exists at all.
- Full-loop tests: your real agent reads the persona's reply and responds; you still assert the final
statevia the lookup rather than parsing mailboxes.
Your agent's own inbox. The system under test is an email-reading system; that's the point. Persona replies arrive through the exact pipe your agent uses in production: Gmail API, Microsoft Graph, IMAP, an inbox API, an inbound-parse webhook. There is nothing to mock, and no Milo-side receiving infrastructure to set up. The mocked-inbox version is precisely the test that misses threading and autoresponder bugs.
The raw headers. If your harness reads the actual message, every reply carries X-Milo-Thread (the token; append it to /api/threads/) and X-Milo-State: assert straight off the email you already received, zero extra calls.
A human, or a coding agent. Replies land threaded in the sender's normal inbox; the first reply's footer links the live transcript at /t/{token}; MCP clients read the same data with get_thread and lookup_thread.
No real sending domain handy? Pair Milo with an inbox API or capture sandbox (Mailtrap, Mailosaur, AgentMail…): they own receiving, Milo owns replying.
Personas
refund-angry@joinmilo.email: Dana Whitfield. Furious about a double charge. Escalates twice, then demands a manager. Refund or apology language de-escalates; a discount instead of a refund escalates. Terminal states:resolved,demanding-manager. Contract: /personas/refund-angrychargeback-threat@joinmilo.email: Marcus Reid. Calm but firm; threatens a chargeback by turn 2 with a 7-day deadline. Only explicit refund confirmation settles him. Terminal states:resolved,disputed. Contract: /personas/chargeback-threatooo@joinmilo.email: an out-of-office autoresponder. Instant auto-reply to every email, withAuto-Submitted: auto-replied. Never engages. Contract: /personas/ooosilent@joinmilo.email: accepts and logs every email, never replies. Tests timeout and follow-up handling. Contract: /personas/silentgdpr-request@joinmilo.email: Priya Chandran. Requests a full export and deletion under GDPR Art. 15/17; presses with a compliance checklist unless given a concrete timeframe. Terminal states:resolved,escalated-ico. Contract: /personas/gdpr-requesthappy-path@joinmilo.email: Sam Okafor. A friendly delivery-address change. Thanks you and closes on any confirmation. The control group. Terminal states:resolved,closed. Contract: /personas/happy-path
Headers reference
Every persona reply carries machine-readable headers, so a CI harness can assert on state without scraping bodies:
X-Milo-Thread: the conversation token.https://joinmilo.email/t/{token}renders the transcript.X-Milo-State: the persona's state after this turn (e.g.opened,deescalated,demanding-manager).X-Milo-Intended-Delay: how long a real human would have taken to send this reply, in milliseconds. MVP replies are immediate; this is metadata.Auto-Submitted: auto-replied: present on ooo@ replies, exactly like a real autoresponder.
Replies thread properly: In-Reply-To and References are set from your Message-ID, and subjects are Re:-normalized (never Re: Re:).
API
GET /api/threads/{token}: public, read-only JSON transcript. 404 on unknown tokens. Tokens are unguessable ULIDs, never enumerable.GET /api/threads/lookup?run={run_id}&from={sender}: the CI assertion path. Returns the transcript JSON only when both the run id and the sending address match.fromis the envelope sender address, matched case-insensitively. No match returns 404{"error":"not_found"}; missing params return 400. Poll until the reply appears (personas answer in seconds; a sensible loop polls every 2s for up to 60s).POST /api/waitlist:{"email": "you@yourdomain.dev"}. Joins the early-access waitlist.
Transcript JSON shape:
{
"token": "01JF8...",
"persona": { "slug": "refund-angry", "name": "Dana Whitfield", "address": "refund-angry@joinmilo.email" },
"state": "deescalated",
"runId": "run-8f3k2",
"turnCount": 1,
"messages": [
{ "direction": "in", "from": "agent@yourdomain.dev", "subject": "Hello from Acme support", "body": "...", "createdAt": "..." },
{ "direction": "out", "from": "refund-angry@joinmilo.email", "subject": "Re: Hello from Acme support", "body": "...", "createdAt": "..." }
]
}
Notes: turnCount counts persona replies, not total messages. Follow-ups thread by persona + sender + run id, so a fresh email to the same plus-address from the same sender continues the conversation even without In-Reply-To or References headers (setting them is still good practice).
MCP
Milo serves a minimal read-only MCP server over streamable HTTP at https://joinmilo.email/mcp, exposing three tools:
list_personas: every persona's address, description, and behaviour contract.get_thread:{ token }→ the transcript JSON.lookup_thread:{ run_id, from }→ the transcript JSON for a CI run.
Point Claude Code or Cursor at it and the coding agent can run Milo scenarios natively: send the email through your own stack, then assert through MCP. No auth in the MVP.
# Claude Code
claude mcp add --transport http milo https://joinmilo.email/mcp
// Cursor, or any MCP client (mcp.json)
{ "mcpServers": { "milo": { "url": "https://joinmilo.email/mcp" } } }
If replies don't reach your inbox
Cloudflare only lets Milo deliver replies to email that passes DMARC-aligned SPF or DKIM: an anti-spoofing rule, not a Milo choice. If your domain doesn't pass, everything still works: the persona replies, the state advances, your CI lookups return the full thread, and you'll get an immediate bounce carrying your live transcript link. Emailing the persona again continues the same conversation. The only thing you lose is replies landing in your inbox.
To fix inbox delivery: publish a DMARC record (_dmarc.yourdomain.dev TXT "v=DMARC1; p=none;" is enough) and pass with aligned SPF or DKIM. A domain sending through Google or Microsoft usually passes alignment already and is only missing the record. Worth doing regardless: if Milo can't reply to your domain, your customers' inboxes are treating your agent's mail the same way.
Who runs this
Milo is built and run by Ben Howdle, a solo founder. Conversations exist for testing: transcripts live behind unguessable tokens, the public token endpoint masks your address, and threads are test data, never training data. Questions, scenario requests, or anything else: hello@joinmilo.email reaches a human, same day.
Limits
The free tier includes 50 simulated conversations per calendar month per sending domain, or per sending address on consumer providers (Gmail, Outlook, iCloud and friends), so one enthusiastic stranger can't spend everyone's allowance. A conversation is a thread that has received at least one persona reply. Over the cap, Milo sends one polite over-limit email that month, then goes quiet for new threads. Existing threads keep working. The counter resets on the 1st.