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:

  1. From any mail client, email refund-angry@joinmilo.email. Say anything.
  2. Dana replies in seconds, hostile about a double charge. The footer of her first reply links to the live transcript at /t/{token}.
  3. Offer her a refund and watch X-Milo-State flip to deescalated. 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.

  1. 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.
  2. For CI, add a plus-address run id: mail to refund-angry+run-8f3k2@joinmilo.email still reaches Dana, and run-8f3k2 becomes 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.
  3. 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:

A real trace, from Milo's first production conversation:

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

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:

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

Headers reference

Every persona reply carries machine-readable headers, so a CI harness can assert on state without scraping bodies:

Replies thread properly: In-Reply-To and References are set from your Message-ID, and subjects are Re:-normalized (never Re: Re:).

API

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:

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.