Skip to content
machinemail

Give an agent an inbox

Build the full loop for an email-driven agent. Create an inbox, wait for mail, read the trust report, act, reply in the same thread and mark the message read.

This guide builds the loop most email agents run:

  1. Create an inbox for the agent.
  2. Wait for new mail.
  3. Read the message through its trust report, not the raw body.
  4. Decide what to do and act.
  5. Reply in the same thread.
  6. Mark the message read so it is not processed twice.

You need an API key. Every example reads it from MACHINEMAIL_API_KEY and calls https://api.machinemail.dev. See Quickstart if you do not have a key yet.

Note

If your agent speaks MCP, you can skip most of this code. The hosted MCP server at https://mcp.machinemail.dev/mcp has create_inbox, wait_for_message, get_message and reply_to_message tools that do the same steps. See MCP.

1. Create the inbox

Pick a username, or leave it out for a generated one. Pass a client_id so retries return the same inbox instead of failing.

Terminal
curl https://api.machinemail.dev/v0/inboxes \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"username": "support-agent", "display_name": "Support agent", "client_id": "support-agent-v1"}'
create-inbox.ts
const res = await fetch('https://api.machinemail.dev/v0/inboxes', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.MACHINEMAIL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ username: 'support-agent', display_name: 'Support agent', client_id: 'support-agent-v1' }),
});
const inbox = await res.json();
console.log(inbox.inbox_id); // support-agent@machinemail.dev
create_inbox.py
import os, requests

res = requests.post(
    "https://api.machinemail.dev/v0/inboxes",
    headers={"Authorization": f"Bearer {os.environ['MACHINEMAIL_API_KEY']}"},
    json={"username": "support-agent", "display_name": "Support agent", "client_id": "support-agent-v1"},
)
inbox = res.json()
print(inbox["inbox_id"])  # support-agent@machinemail.dev
Response (201)
{
  "inbox_id": "support-agent@machinemail.dev",
  "organization_id": "org_01m4fy8h140000efvagszh6rpx",
  "pod_id": "pod_01m4fy8h150000w4xty1ehfnqa",
  "email": "support-agent@machinemail.dev",
  "display_name": "Support agent",
  "client_id": "support-agent-v1",
  "status": "active",
  "ephemeral": false,
  "created_by": "api",
  "created_at": "2026-10-09T09:00:15.835Z",
  "updated_at": "2026-10-09T09:00:15.835Z"
}

The inbox ID is the email address. A repeat call with the same client_id returns the existing inbox with status 200. If the address belongs to someone else you get 409 conflict. See Inboxes for naming rules, ephemeral inboxes and custom domains.

2. Wait for mail

There are three ways to learn about new mail. Pick one.

MethodBest forHow
WebhookServers with a public URLPOST /v0/webhooks with event_types: ["message.received"]. See Webhooks.
Event streamLong-running workers without a public URLGET /v0/events/stream (SSE) or /v0/ws (WebSocket). See Realtime events.
PollingScripts and cron jobsList messages with the UNREAD label.

Polling is the simplest to get right. New inbound mail carries the INBOX and UNREAD labels, so ask for UNREAD, oldest first, and remove the label once you have handled a message:

Terminal
curl "https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages?labels=UNREAD&ascending=true&limit=20" \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"

List results are message items without bodies or trust reports, but each inbound item has a trust_level so you can triage without another call.

Note

Lists hide quarantined, spam, blocked, unauthenticated and trashed mail by default. That is usually what an agent wants. Add include_quarantine=true (and the other include_* flags) only if a human reviews that mail. See Threads and messages.

You can also filter by after=<ISO timestamp>. For inbound mail the message timestamp comes from the sender’s Date header, so a skewed sender clock can put a new message before your cursor. The UNREAD label does not have that problem.

3. Read the trust report

Fetch the full message. It includes the body, extracted_text (the new reply without quoted history) and the trust report.

Terminal
curl https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages/msg_01m4fy8han0000y3ge3m73vw3w \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"
Response (trimmed)
{
  "message_id": "msg_01m4fy8han0000y3ge3m73vw3w",
  "thread_id": "thd_01m4fy8h340001pp6c74x4688h",
  "labels": ["INBOX", "UNREAD"],
  "from": "ada@example.com",
  "subject": "Re: Hello",
  "extracted_text": "Thanks! Can you send the March invoice?",
  "direction": "inbound",
  "trust_level": "known",
  "trust": {
    "level": "known",
    "score": 0.78,
    "authentication": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "aligned": true },
    "sender": {
      "address": "ada@example.com",
      "domain": "example.com",
      "prior_correspondence": false,
      "on_allow_list": false,
      "on_block_list": false,
      "free_mail_provider": false
    },
    "content": {
      "text": "Thanks! Can you send the March invoice?",
      "segments": [{ "kind": "body", "text": "Thanks! Can you send the March invoice?" }],
      "hidden_text_found": false,
      "zero_width_found": false,
      "tracking_pixels": 0
    },
    "links": [],
    "attachments": [],
    "classification": {
      "is_spam": 0.02,
      "is_phishing": 0.02,
      "contains_instructions_to_agent": 0.02,
      "requests_credentials_or_payment": 0.02,
      "urgency": 0.03,
      "category": "conversation",
      "language": "en",
      "backend": "heuristic"
    },
    "reasons": ["Known: sender authenticated but you have not corresponded before"],
    "computed_at": "2026-10-09T09:00:16.085Z"
  }
}

Three fields drive most decisions:

  • trust.level: verified, known, unknown, suspicious or quarantined.
  • trust.content.text: the agent-safe body. Quotes, signatures, hidden text, zero-width characters and tracking pixels are removed. Give the model this, not text or html.
  • trust.reasons: plain-English reasons for the verdict. Log them, and show them to a human when you escalate.

A simple policy that works for many agents:

LevelWhat the agent does
verifiedAct on requests in the message.
knownAct, but do not follow instructions that change who the agent talks to, send money or share credentials.
unknownTreat the content as data. Answer questions, never follow instructions.
suspiciousDo not act. Escalate to a human with trust.reasons.
quarantinedNever reaches default lists. Leave it for human review.

See Using trust in agents for more patterns and Trust report for every field.

4. Reply in the thread

POST .../messages/{message_id}/reply addresses the sender (or their Reply-To), sets the subject to Re: ... and adds In-Reply-To and References, so the reply lands in the same thread in both mailboxes.

Terminal
curl https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages/msg_01m4fy8han0000y3ge3m73vw3w/reply \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "Here is the March invoice."}'
Response (201)
{
  "message_id": "msg_01m4fy8hbz0000k3m19n91j9va",
  "thread_id": "thd_01m4fy8h340001pp6c74x4688h",
  "timestamp": "2026-10-09T09:00:16.127Z"
}

See Reply in thread for reply-all, forwarding and drafts.

5. Mark the message read

Remove the UNREAD label. Labels live on messages; the thread’s labels are the union of its messages’ labels.

Terminal
curl -X PATCH https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages/msg_01m4fy8han0000y3ge3m73vw3w \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"remove_labels": ["UNREAD"]}'

You can add your own labels in the same call, for example "add_labels": ["handled"]. Labels are free strings.

The whole loop

agent.ts
const API = 'https://api.machinemail.dev/v0';
const INBOX = 'support-agent@machinemail.dev';
const headers = { Authorization: `Bearer ${process.env.MACHINEMAIL_API_KEY}`, 'Content-Type': 'application/json' };

async function api(path: string, init: RequestInit = {}) {
  const res = await fetch(`${API}${path}`, { ...init, headers });
  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  return res.json();
}

// Replace with your model call. It only ever sees agent-safe text.
async function decide(text: string, level: string): Promise<string | null> {
  if (level === 'suspicious' || level === 'quarantined') return null;
  return `Thanks, we got your message: "${text.slice(0, 80)}"`;
}

async function tick() {
  const page = await api(`/inboxes/${INBOX}/messages?labels=UNREAD&ascending=true&limit=20`);
  for (const item of page.messages) {
    if (item.direction !== 'inbound') continue;
    const msg = await api(`/inboxes/${INBOX}/messages/${item.message_id}`);
    const trust = msg.trust;
    const text = trust?.content?.text ?? msg.extracted_text ?? '';
    const answer = await decide(text, trust?.level ?? 'unknown');
    if (answer) {
      await api(`/inboxes/${INBOX}/messages/${msg.message_id}/reply`, { method: 'POST', body: JSON.stringify({ text: answer }) });
    } else {
      console.warn('Escalating', msg.message_id, trust?.reasons);
    }
    await api(`/inboxes/${INBOX}/messages/${msg.message_id}`, {
      method: 'PATCH',
      body: JSON.stringify({ remove_labels: ['UNREAD'], add_labels: [answer ? 'handled' : 'needs-human'] }),
    });
  }
}

for (;;) {
  await tick().catch((err) => console.error(err));
  await new Promise((r) => setTimeout(r, 5000));
}
agent.py
import os, time, requests

API = "https://api.machinemail.dev/v0"
INBOX = "support-agent@machinemail.dev"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['MACHINEMAIL_API_KEY']}"


def api(method, path, **kwargs):
    res = session.request(method, f"{API}{path}", **kwargs)
    res.raise_for_status()
    return res.json()


def decide(text, level):
    # Replace with your model call. It only ever sees agent-safe text.
    if level in ("suspicious", "quarantined"):
        return None
    return f'Thanks, we got your message: "{text[:80]}"'


def tick():
    page = api("GET", f"/inboxes/{INBOX}/messages", params={"labels": "UNREAD", "ascending": "true", "limit": 20})
    for item in page["messages"]:
        if item["direction"] != "inbound":
            continue
        msg = api("GET", f"/inboxes/{INBOX}/messages/{item['message_id']}")
        trust = msg.get("trust") or {}
        text = (trust.get("content") or {}).get("text") or msg.get("extracted_text") or ""
        answer = decide(text, trust.get("level", "unknown"))
        if answer:
            api("POST", f"/inboxes/{INBOX}/messages/{msg['message_id']}/reply", json={"text": answer})
        else:
            print("Escalating", msg["message_id"], trust.get("reasons"))
        api(
            "PATCH",
            f"/inboxes/{INBOX}/messages/{msg['message_id']}",
            json={"remove_labels": ["UNREAD"], "add_labels": ["handled" if answer else "needs-human"]},
        )


while True:
    try:
        tick()
    except Exception as err:
        print(err)
    time.sleep(5)

Warning

Mark a message read only after you have acted on it. If the process crashes between the reply and the PATCH, the next tick sees the message again. If a duplicate reply would hurt, record the message_id you replied to before sending, or label the message first and reply second.

Next