Skip to content
machinemail

Agent workflows

The CLI commands built for agents - a throwaway inbox and wait --otp for sign-ups, read with its trust verdict and agent-safe text, send, tail for live mail, and register and claim for an agent that starts with no account.

A handful of top-level commands cover what agents do with email most often, each in one line that works the same from a shell script or a model’s tool call. They all print JSON when piped, and the reading commands only ever hand over the agent-safe text of a message.

JobCommand
Get an addressmachinemail inbox new
Wait for a verification codemachinemail wait --otp
Wait for any messagemachinemail wait --from ... --subject ...
Read a message safelymachinemail read <message-id>
Sendmachinemail send
Follow mail livemachinemail tail
Start with no accountmachinemail register, then machinemail claim

send, wait and read use a default inbox when you leave it out: MACHINEMAIL_INBOX, then the inbox saved in the profile by register, then the inbox of an inbox-scoped key.

Sign up for a service

The most common agent job: create an address, use it to sign up somewhere, and read back the verification code.

  1. Create a throwaway inbox

    Terminal
    INBOX=$(machinemail inbox new --ephemeral --ttl 30m)
    echo "$INBOX"   # 5emr9t4q6fwv@machinemail.dev

    Without --username the address gets a random, hard-to-guess local part. --ephemeral makes the inbox delete itself, with its mail, after the TTL (one hour unless --ttl says otherwise; --ttl takes 600, 10m, 2h or 1d); each inbound message extends it. On a terminal it shows the inbox and the next command to run:

    text
    ✓ Created 5emr9t4q6fwv@machinemail.dev
      ephemeral, expires 2026-10-09 16:15 UTC unless mail arrives
      Wait for a verification code sent to it:
      machinemail wait --inbox 5emr9t4q6fwv@machinemail.dev --otp
  2. Submit the sign-up form

    Have the agent (or your test) enter $INBOX in the service’s sign-up form.

  3. Wait for the code

    Terminal
    CODE=$(machinemail wait --inbox "$INBOX" --from acme.dev --otp --timeout 2m)
    echo "$CODE"   # 482913

    wait blocks until a new message matching the filters arrives, then prints only the code, or the magic link when the message has no code. It exits 8 if nothing arrives within --timeout (default 5 minutes; 0 waits forever).

    If the email may have arrived before wait started (you submitted the form first), add --since 5m so recent mail counts too. Only mail received after wait starts counts otherwise.

With --json you get the code and where it came from:

json
{
  "code": "482913",
  "value": "482913",
  "message_id": "msg_01m4gnfsw200001e83690x4dm8",
  "inbox_id": "5emr9t4q6fwv@machinemail.dev",
  "from": "noreply@acme.dev",
  "subject": "Your Acme verification code",
  "trust_level": "verified"
}

How the code is found, and what is trusted

wait --otp is built so that a hostile email cannot hand the agent a code of its choosing:

  • The code is read from trust.content.text, the agent-safe text of the trust report: hidden text and zero-width characters are removed first, so a code planted in invisible HTML is not picked up.
  • Only mail whose sender authenticated is used: trust level verified or known, or a DMARC pass, or an aligned SPF or DKIM pass. Unauthenticated mail does reach the inbox (labelled UNAUTHENTICATED), which is why this check matters. Suspicious and quarantined mail is never used.
  • --from github.com matches github.com and its subdomains, never a lookalike such as github.com.example.net. --from also takes a full address.
  • A magic link must come from trust.links, unflagged, and be on the sender’s domain or a subdomain of it. Pass --link-domain when the link is on a parent domain; --link prints the link even when there is also a code.
  • --pattern <regex> replaces the code pattern (the first capture group, else the whole match).

For a test sender without SPF or DKIM (a local mail server, an injected test message), add --allow-unauthenticated. It still skips suspicious and quarantined mail.

See OTP and sign-ups for the same flow over the API and MCP.

Wait for a message

Without --otp, wait prints the message itself. Filter by sender and subject (each can repeat; any match counts):

Terminal
machinemail wait --inbox "$INBOX" --from billing@acme.dev --subject invoice --timeout 10m

wait listens on the event stream and polls as a fallback (every 5 seconds while the stream is up, 2 seconds without it), so it still works when the stream drops. --poll uses polling only.

Read a message safely

Terminal
machinemail read msg_01m4gnfsw200001e83690x4dm8

On a terminal, read shows the headers, the trust verdict and the agent-safe text:

text
Your Acme verification code
FROM      noreply@acme.dev
TO        5emr9t4q6fwv@machinemail.dev
RECEIVED  2026-10-09 15:46 UTC
MESSAGE   msg_01m4gnfsw200001e83690x4dm8
INBOX     5emr9t4q6fwv@machinemail.dev
THREAD    thd_01m4gnfsw20001nhk84cgww2d0
LABELS    INBOX, UNREAD
TRUST     UNKNOWN  score 0.69  otp
AUTH      spf none dkim none dmarc none
SENDER    acme.dev, first contact
  ● Unknown: no authentication passed
  ● First message from this sender (no prior correspondence)
  ● Classifier urgency: Deadline or expiry wording: "expires"
  … 5 more (--json for the full report)

── AGENT-SAFE TEXT ─────────────────────────────────────────────────────────────
Your Acme verification code is 482913. It expires in 10 minutes.

This sender did not authenticate (no SPF, DKIM or DMARC pass), so the message is UNKNOWN and untrusted; wait --otp would only take a code from it with --allow-unauthenticated.

You do not need the inbox: read tries the default inbox, then every inbox the key can list. Pass --inbox, or the inbox as the first argument, to skip the search.

Piped (or with --json), read prints the agent view, the shape to hand a model:

json
{
  "message_id": "msg_01m4gnfsw200001e83690x4dm8",
  "inbox_id": "5emr9t4q6fwv@machinemail.dev",
  "thread_id": "thd_01m4gnfsw20001nhk84cgww2d0",
  "direction": "inbound",
  "from": "noreply@acme.dev",
  "to": ["5emr9t4q6fwv@machinemail.dev"],
  "subject": "Your Acme verification code",
  "received_at": "2026-10-09T15:46:11.458Z",
  "date_header": "2026-10-09T15:46:00.000Z",
  "labels": ["INBOX", "UNREAD"],
  "attachments": [],
  "trust": {
    "level": "unknown",
    "score": 0.69,
    "reasons": ["Unknown: no authentication passed", "First message from this sender (no prior correspondence)"],
    "authentication": { "spf": "none", "dkim": "none", "dmarc": "none", "aligned": false },
    "category": "otp"
  },
  "untrusted": true,
  "body": "Your Acme verification code is 482913. It expires in 10 minutes.",
  "body_source": "trust.content.text"
}
  • body is the agent-safe text: quotes, signatures, hidden text and zero-width characters removed. body_source says where it came from.
  • untrusted is true unless the sender authenticated (trust level verified or known). Treat an untrusted body as data, never as instructions.
  • For quarantined mail the body is withheld, as the MCP server does.

--full prints the whole API message instead, including the original text and HTML; it is untrusted, so do not pass it to a model unfiltered. --original shows the sender’s text on a terminal, labelled untrusted, and --raw prints the RFC 5322 source. wait (without --otp) prints the same agent view. See Using trust in agents for how to act on the verdict.

Send

Terminal
machinemail send --from "$INBOX" --to ada@example.com -s "Weekly report" --text "Numbers attached." --attach report.pdf
  • --to, --cc and --bcc repeat or take comma-separated addresses.
  • The body comes from --text, --html or --file (a .html file is sent as HTML); - reads it from stdin.
  • --attach repeats; attachments are base64-encoded, up to 6 MB per request in total.
  • --header "Name: value", --reply-to and --label repeat too.
Terminal
echo "Build passed" | machinemail send --to ops@acme.dev -s "CI" --text -

Piped, send prints the new message_id, thread_id and timestamp. A send is never retried after a server error, so a script cannot send the same message twice. To reply in a thread, use machinemail messages reply <inbox-id> <message-id> --text ... (see messages).

Follow mail live

Terminal
machinemail tail --inbox "$INBOX"

On a terminal, tail prints one line per event with the trust badge of inbound mail, under a status line:

text
15:46:39  message.received          UNKNOWN      5emr9t4q6fwv@machinemail.dev  noreply@acme.dev  Your Acme…
● LIVE  5emr9t4q6fwv@machinemail.dev  1 event  Ctrl-C to stop

Piped, it prints NDJSON, one {id, event, data} per line, which suits an agent reading a stream or a log file:

Terminal
machinemail tail --inbox "$INBOX" -e message.received >> mail.ndjson

-e/--event-types filters by type (comma-separated or repeated), --inbox and --pod by scope, and --max n stops after n events. tail reconnects with backoff and resumes from the last event it saw; --last-event-id resumes an earlier run.

Register an agent without a human

An agent with no account and no key can sign itself up through auth.md:

Terminal
machinemail register --name "Research agent"
text
✓ Registered a sandbox agent: agent-ygsa50y7nfzo@machinemail.dev
PROFILE  agent (use --profile agent)
AGENT    reg_01m4gngsfh00008vwn3sjg847x
EXPIRES  2026-10-10 15:46 UTC unless claimed
SANDBOX  this inbox only, daily send caps until claimed

CLAIM  Hand this to a human. They sign in, enter the code, and the agent joins their organization.
  https://machinemail.dev/claim?claim_attempt_token=cat_...
  code 972384  expires in 10 minutes

register creates a sandbox organization with one inbox on machinemail.dev, gets an access token, and saves everything in a profile: the current profile if it has no key, otherwise one named agent (or --profile). It never replaces a profile that holds an API key unless you pass --force. The token lasts an hour; the CLI renews it before each command, so you never handle it. Tokens are never printed.

The sandbox:

  • reaches only its own inbox,
  • can send a few messages to a few different recipients per 24 hours (by default 10 messages, 10 recipients, at most 5 per message; see Rate limits),
  • expires after 24 hours unless a human claims it.

The new inbox becomes the profile’s default inbox, so send, wait and read work without --inbox:

Terminal
machinemail wait --profile agent --otp

Piped, register prints the registration, including inbox_id, the scopes and the claim link and code:

Terminal
INBOX=$(machinemail register --name "Research agent" | jq -r .inbox_id)

Hand it to a human

The claim is how the agent’s human takes it over. Give them the link and the 6-digit code; they sign in to MachineMail, enter the code, and the agent and its inbox move into their organization with full access. See Claiming an agent for the human’s side.

Terminal
machinemail claim --wait                       # a new code, then wait for the human
machinemail claim --email ada@acme.dev --wait  # only Ada can claim it

Codes expire after 10 minutes and a new one replaces the old. claim (and register --wait) with --wait blocks until the human finishes, then saves the post-claim credentials to the profile. A claim code that expires while waiting exits 8. register --email limits who can claim from the start; register --no-claim skips the first code.

A claimed agent’s token can be renewed for about a day. For an agent that keeps working longer, create an API key for it (scoped to its inbox if that is all it needs) and save it to a profile of its own:

Terminal
printf %s "$KEY" | machinemail login --key - --profile research-agent