agent workflows.md


title: "Agent workflows" description: "MachineMail CLI commands built for agents: throwaway inboxes and wait --otp for sign-ups, read with trust verdicts, send, tail live mail, register and claim." canonical: "https://machinemail.dev/docs/cli/agent-workflows"

Agent workflows

MachineMail CLI commands built for agents: throwaway inboxes and wait --otp for sign-ups, read with trust verdicts, send, tail live mail, register and claim.

Source: https://machinemail.dev/docs/cli/agent-workflows

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.

Job Command
Get an address machinemail inbox new
Wait for a verification code machinemail wait --otp
Wait for any message machinemail wait --from ... --subject ...
Read a message safely machinemail read <message-id>
Send machinemail send
Follow mail live machinemail tail
Start with no account machinemail 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.

Create a throwaway inbox

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:

✓ 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

Submit the sign-up form

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

Wait for the code

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:

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

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

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

machinemail read msg_01m4gnfsw200001e83690x4dm8

On a terminal, read shows the headers, the trust verdict and the agent-safe 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:

{
  "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"
}

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

machinemail send --from "$INBOX" --to ada@example.com -s "Weekly report" --text "Numbers attached." --attach report.pdf
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

machinemail tail --inbox "$INBOX"

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

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:

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:

machinemail register --name "Research agent"
✓ 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:

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

machinemail wait --profile agent --otp

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

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.

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:

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