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:

  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 (a ULID or UUID slice); 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.

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.

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

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.