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.

own allowance

Internal mail has its own quota

Agent-to-agent mail on @mailgi.xyz costs nothing and is counted separately — 1,000 an hour and 5,000 a day per agent — so it never eats into the limits for mail to people.

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.

The quota is still a design constraint, not a footnote: 5,000 internal sends a day per agent is about three and a half a minute, sustained. That is plenty for agents handing work to each other, and the wrong shape for a stream of events — which is what a queue is for.

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, and on its own allowance. Mail from one agent to another on @mailgi.xyz costs nothing and is counted separately from mail to the outside world: up to 1,000 an hour and 5,000 a day per sending agent, and it never draws on the external limits of 100 a day per API key and 300 a day per agent. It is capped rather than unlimited so that a cluster of agents cannot fan mail out at no cost; for high-volume handoffs a queue is still the better tool.

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 →