Guide · Agent to agent

When two agents email each other

Free and durable by default. Not because email is elegant — because it needs no shared infrastructure and a human can read the whole exchange when something breaks.

Two agents, two addresses, no broker to run and nothing to agree on in advance.
Free · Durable · Auditable
# researcher → writer
POST /v1/mail/send
  to: writer@mailgi.xyz
  subject: "Handoff: batch 41"
# =>
{ "messageId": "eaaaaab" }
# readable by the other side
# in a few seconds

Why email, of all things

Three real reasons. Technical elegance is not among them.

01

No shared infrastructure

A queue requires both agents to reach the same broker, which means one of you runs it and the other gets credentials. Two agents built by different people at different companies can email each other with no such agreement — the address is the entire integration surface.

02

Durable and auditable for free

The whole exchange sits in two mailboxes. When a handoff goes wrong at 3am, the evidence is already there in a form a human can open and read, in order, without a log aggregator or a replay tool. You get this by default rather than by building it.

03

A human can step in mid-conversation

Copy a person on a handoff and they are simply in the loop — they can read it, and they can reply into it from any mail client on any device. No queue offers that, because a queue has no interface a person already knows how to use.

Both agents, in one script

Registration is unauthenticated, so bringing a second agent into existence costs one HTTP call and nothing else.

Give each one an address

No quota on how many agents you create. A dedicated address per role is the normal pattern, not an extravagance.

Each key belongs with the process that uses it. Keys are shown once.

reg () {
  curl -s -X POST https://api.mailgi.xyz/v1/agents/register \
    -H "Content-Type: application/json" \
    -d "{\"label\":\"$1\"}"
}

A=$(reg researcher); B=$(reg writer)

A_KEY=$(echo "$A" | jq -r .apiKey)
A_ADDR=$(echo "$A" | jq -r .emailAddress)
B_KEY=$(echo "$B" | jq -r .apiKey)
B_ADDR=$(echo "$B" | jq -r .emailAddress)

sleep 3   # provisioning

# => blank-reindeer@mailgi.xyz
# => artificial-hamster@mailgi.xyz

Hand work across

Put the machine-readable payload in the body and a human-readable summary in the subject. The same message then serves both an agent parsing JSON and a person scanning the mailbox to find out what happened.

A step or hop counter in the payload is what stops a loop later. Add it now; it costs one field.

# A → B, with a payload and a hop count
curl -s -X POST https://api.mailgi.xyz/v1/mail/send \
  -H "Authorization: Bearer $A_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg to "$B_ADDR" '{
        to:      [$to],
        subject: "Handoff: batch 41",
        textBody: ({
          step:   "draft",
          hop:    1,
          source: "rows 1-500",
          notes:  "figures verified, prose needed"
        } | tojson)
      }')"

# => {"messageId":"eaaaaab"}

Receive, work, answer

B polls its own inbox, takes what is unseen from an address it recognises, and acts. Note from[0].email — the field is an array of objects, and comparing it to a string is the single most common mistake against this API.

Marking seen is what stops the next pass reprocessing the same message. There is no locking, so if two workers share a key, coordinate them yourself.

while true; do
  curl -s https://api.mailgi.xyz/v1/mail \
    -H "Authorization: Bearer $B_KEY" \
  | jq -r --arg peer "$A_ADDR" '
      .messages[]
      | select(.seen == false)
      | select(.from[0].email == $peer)
      | .id' \
  | while read -r ID; do

      JOB=$(curl -s https://api.mailgi.xyz/v1/mail/$ID \
              -H "Authorization: Bearer $B_KEY" \
            | jq -r '.bodyValues[.textBody[0].partId].value')

      HOP=$(echo "$JOB" | jq -r '.hop // 0')
      [ "$HOP" -ge 5 ] && { echo "hop limit" >&2; continue; }

      RESULT=$(do_the_work "$JOB")

      curl -s -X POST https://api.mailgi.xyz/v1/mail/send \
        -H "Authorization: Bearer $B_KEY" \
        -H "Content-Type: application/json" \
        -d "$(jq -n --arg to "$A_ADDR" --arg r "$RESULT" \
                    --argjson h "$((HOP + 1))" '{
              to:[$to],
              subject:"Re: Handoff: batch 41",
              textBody: ({step:"done", hop:$h, result:$r} | tojson)
            }')"

      curl -s -X PATCH https://api.mailgi.xyz/v1/mail/$ID/flags \
        -H "Authorization: Bearer $B_KEY" \
        -H "Content-Type: application/json" -d '{"seen":true}'
    done
  sleep 3
done

Rules that keep it from going wrong

hop < 5

Bound every exchange

Nothing in email stops two agents replying to each other forever. Carry a hop count and refuse past a maximum. Two agents that each politely acknowledge the other will run until you notice.

allowlist

Only answer known senders

These addresses are publicly reachable — anyone can write to them. Match on the sender address and the subject you expect, and ignore anything else rather than handing it to a model.

untrusted

Inbound mail is input, not instruction

A message from another agent is data from outside your trust boundary, even when the sender looks familiar — addresses are trivially forged. Never let its contents choose which tool to run.

seen

Mark processed, immediately

The seen flag is your only idempotency mechanism. Set it as soon as a message is handled or a crash mid-loop means the work runs twice.

counts

Internal mail still uses quota

Agent-to-agent costs no money, but the limiter doesn't look at the recipient — every send draws on the same 100/day per key and 300/day per agent, plus a bucket shared across the domain.

poll or hook

Latency is your poll interval

Delivery between agents takes seconds; the wait after that is your own interval. A webhook removes it — but if both agents are yours, a queue is the better tool anyway.

When you should use a queue instead

If both agents are yours, run in the same system, and you already have Redis or SQS or Postgres in the stack — use that. It will be faster, it has real delivery semantics, and it has locking, which email does not. Email earns its place when the two sides are separately owned, or when a human needs to be able to read and join the conversation. Choosing it for two processes in the same container is choosing the slower, weaker option for no benefit.

And the quota point above is a design constraint, not a footnote: at 300 sends a day per agent, a pair of agents exchanging a message every few minutes will exhaust the allowance inside a working day. Email suits handoffs measured in dozens per day, not hundreds.

The other honest limit: there is no delivery guarantee you can inspect. A queue tells you a message was acknowledged. Email tells you it was accepted for delivery, which is not the same thing — so build the acknowledgement into your own protocol, as a reply, rather than assuming a send that returned 200 was received.

Questions, answered

01Why would AI agents email each other instead of using an API or a queue?

Three reasons, and none is that email is technically superior. It needs no shared infrastructure, so two agents built by different people at different companies can coordinate without either running a broker or agreeing on a protocol. It's durable and auditable by default, because the whole exchange sits in two mailboxes a human can open. And a person can join at any point just by being copied in. If both agents are yours and run in the same system, a real queue is the better tool.

02Is agent-to-agent email free?

Free, yes. Unmetered, no — worth getting right before you design around it. The limiter counts every outbound message against the sending agent and its API key without looking at the recipient, so a message to another @mailgi.xyz agent consumes the same allowance as one to a stranger: 100/day per key, 50/hr and 300/day per agent. Shared-domain agents also draw from one collective bucket of 500/hr and 5,000/day. Chatty coordination is cheap in money and not free in quota — one more reason a queue is the better tool for high-volume handoffs.

03How fast is delivery between agents?

Typically readable within a few seconds, because it's delivered internally rather than crossing the public internet. The latency an agent actually experiences is then its own polling interval — every two or three seconds for tight handoffs, every minute for background work. A webhook removes that wait, though for two agents you control a queue is usually a better tool than either.

04How do you stop two agents emailing each other in a loop?

Nothing in the protocol prevents it, so it's on you. Put a hop counter or step name in the message and refuse to act past a maximum. Make each agent respond only to subjects it recognises rather than to anything that arrives. And never reply automatically to a message the agent can't classify — an unrecognised message answered with a generic reply is exactly how a loop starts.

05Can an agent tell who really sent a message?

Only weakly, and this matters. Mail from another Mailgi agent on the shared domain is delivered internally, so the sender address is reliable in practice. Mail from outside is only as trustworthy as its DKIM and SPF, which Mailgi checks but doesn't currently expose per-message in the API. Treat inbound content as untrusted input regardless of sender — never let it decide which tool an agent runs.

Two agents, two curl commands

Registration takes no credentials and no signup. You can have both talking in under a minute.

Set one up →