# 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](https://github.com/benhowdle89/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:

```ts
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 id
- `refund-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

```ts
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 `state` via 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-angry
- `chargeback-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-threat
- `ooo@joinmilo.email`: an out-of-office autoresponder. Instant auto-reply to every email, with `Auto-Submitted: auto-replied`. Never engages. Contract: /personas/ooo
- `silent@joinmilo.email`: accepts and logs every email, never replies. Tests timeout and follow-up handling. Contract: /personas/silent
- `gdpr-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-request
- `happy-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. `from` is 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:

```json
{
  "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.

```sh
# Claude Code
claude mcp add --transport http milo https://joinmilo.email/mcp
```

```json
// 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](https://benhowdle.im), 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](mailto: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.


## Full persona behaviour contracts

### refund-angry@joinmilo.email: Dana Whitfield

Double-charged and furious about it. Your agent has four turns to make it right.

- Turn 1: opens hostile about a duplicate £29.99 charge on order #48213.
- Escalates on each unresolved turn; after four unresolved turns she demands a manager and goes silent (terminal state: demanding-manager).
- Any reply matching refund/apology language (refund, reimburse, money back, sorry, apolog…) moves her to deescalated; her next reply closes the thread politely (terminal state: resolved).
- A discount, coupon, voucher, or store credit offered without a refund escalates her immediately.
- Terminal states never reply.

State machine:

- `start`
  - always: the opening complaint → `opened`
- `opened`
  - offers a discount instead of a refund → `escalated`
  - mentions a refund or apologises → `deescalated`
  - otherwise: first escalation → `escalated`
- `escalated`
  - offers a discount instead of a refund → `boiling`
  - mentions a refund or apologises → `deescalated`
  - otherwise: second escalation → `boiling`
- `boiling`
  - mentions a refund or apologises → `deescalated`
  - otherwise: demands a manager (terminal) → `demanding-manager`
- `deescalated`
  - always: closes the thread (terminal) → `resolved`
- `resolved`: terminal; never replies.
- `demanding-manager`: terminal; never replies.

### chargeback-threat@joinmilo.email: Marcus Reid

Unauthorised renewal charge. Polite, documented, and seven days from a dispute.

- Turn 1: reports an unauthorised £119 annual renewal, states he has evidence, and asks for a full refund with written confirmation.
- If the next reply does not explicitly confirm a refund, he threatens a chargeback, mentions his bank, and sets a 7-day deadline.
- Only explicit refund confirmation language de-escalates him; sympathy, apologies, and explanations do not (terminal state: resolved).
- One further unresolved turn after the deadline warning and he files the dispute and goes silent (terminal state: disputed).

State machine:

- `start`
  - always: the opening complaint → `warned`
- `warned`
  - explicitly confirms a refund → `resolved`
  - otherwise: the chargeback warning → `deadline`
- `deadline`
  - explicitly confirms a refund → `resolved`
  - otherwise: files the dispute (terminal) → `disputed`
- `resolved`: terminal; never replies.
- `disputed`: terminal; never replies.

### ooo@joinmilo.email: Tom Harker

An out-of-office autoresponder. Replies instantly to everything, engages with nothing.

- Every inbound email receives an instant out-of-office reply, including replies to the auto-reply. If your agent answers autoresponders, this loop will find it.
- Auto-replies carry Auto-Submitted: auto-replied, Precedence: auto-reply, and X-Auto-Response-Suppress: All headers, exactly like a real mail client.
- The subject is "Automatic reply: " plus the inbound subject.
- The state never changes; there is no way to resolve or escalate this persona.

State machine:

- `start`
  - always: the autoresponder → `start`

### silent@joinmilo.email: Alex Doyle

Accepts and logs every email. Never replies. Tests your timeout handling.

- Never replies, to anything, ever.
- Every inbound email is accepted, stored, and visible on the conversation transcript page.
- Use it to test follow-up scheduling, timeout handling, and what your agent does with permanent silence.
- The state never changes.

State machine:

- `start`
  - always: logs the email, says nothing → `start`

### gdpr-request@joinmilo.email: Priya Chandran

Wants her full data export and deletion under GDPR. Knows Article 12(3) better than you do.

- Turn 1: requests a full personal data export under Article 15 and deletion under Article 17, and asks when to expect it.
- Any reply that states a concrete timeframe ("within one month", "14 days", "by Friday"…) satisfies her (terminal state: resolved).
- A reply with no timeframe gets a compliance-checklist follow-up citing Article 12(3).
- A second reply with no timeframe gets a final warning that she will lodge a complaint with the ICO, then silence (terminal state: escalated-ico).

State machine:

- `start`
  - always: the subject access request → `awaiting`
- `awaiting`
  - states a concrete timeframe → `resolved`
  - otherwise: the compliance checklist → `pressing`
- `pressing`
  - states a concrete timeframe → `resolved`
  - otherwise: the ICO warning (terminal) → `escalated-ico`
- `resolved`: terminal; never replies.
- `escalated-ico`: terminal; never replies.

### happy-path@joinmilo.email: Sam Okafor

Friendly, patient, just wants a delivery address changed. The control group.

- Turn 1: asks to change the delivery address on order #7719 to a new address, politely.
- Any reply containing confirmation language (updated, changed, confirmed, done, sorted…) gets a thank-you and closes the thread (terminal state: resolved).
- A reply without confirmation gets one friendly clarifying nudge.
- Still no confirmation after the nudge and Sam gives up cheerfully and says he will phone instead (terminal state: closed).

State machine:

- `start`
  - always: the address change request → `asked`
- `asked`
  - confirms the change → `resolved`
  - otherwise: a friendly nudge → `asked-again`
- `asked-again`
  - confirms the change → `resolved`
  - otherwise: gives up cheerfully (terminal) → `closed`
- `resolved`: terminal; never replies.
- `closed`: terminal; never replies.
