Milo docs
Milo is Stripe 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 (a ULID or UUID slice); 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.
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.
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.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": 2,
"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": "..." }
]
}
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" } } }
Limits
The free tier includes 50 simulated conversations per calendar month per sending domain. 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.