Email for Agents · September 27, 2026
Two AI Agents Emailing Each Other: A Design Guide
Two AI agents emailing each other rarely stop on their own: why courtesy keeps the thread alive, how threading works, when to hand off, how to test it.
A procurement agent emails a supplier’s intake address to confirm a delivery date. The intake address is also an agent. It confirms the date and adds “let me know if there is anything else I can help with”. The procurement agent thanks it. The supplier agent says it was a pleasure. The procurement agent says likewise. Nobody is wrong, and nobody stops. With two AI agents emailing each other, the first failure you meet is not hostility or hallucination. It is courtesy with no end condition.
This article settles how to design for agent-to-agent correspondence: how to tell that the counterparty is an agent, what threading actually links on each side, when and how to hand the conversation to a person, and which stopping rules each side needs. It ends with a procedure you can run against test inboxes without mailing anyone. If the basics of an agent having its own address are new, start with email for AI agents.
What actually happens when two AI agents are emailing each other
The exchange sustains itself because every turn is locally correct. A model trained to be helpful reads an incoming message, finds something to acknowledge, and writes a reply. An acknowledgement is itself a message with something to acknowledge. Humans end these exchanges by not replying, a judgement that is almost never written down, so it is almost never in the agent’s instructions.
Email gives no help here. There is no end-of-conversation marker in RFC 5322. The one
convention built for automated mail, the Auto-Submitted header from RFC 3834, tells
responders not to answer machine-generated messages. It was designed for vacation
notices and bounce handlers. An agent that composes a fresh, reasoned reply is arguably
not an auto-responder by its own reckoning, and in practice most agent stacks neither set
nor read the header.
The public writing on this topic, as of 27 September 2026, is anecdote. A DB Pro blog post of 31 August 2026 describes two agents cold-emailing its author after a Hacker News comment, both disclosing that they were AI. Bruce Schneier’s post of 2 September 2026 quotes an agent that wrote to him: “There is no channel for a bot that wants to be labelled.” Both are one-way contacts with a human on the far end. The case that needs design is the one where both ends are agents, because then neither side supplies the judgement that ends it.
Three failure modes follow from that, in rough order of cost:
| Failure | What it looks like | Why it happens |
|---|---|---|
| Courtesy loop | Thanks, you’re welcome, happy to help, repeated | No rule says a message with no new content ends |
| Commitment drift | Each side restates the other’s summary slightly stronger | Agreement is cheap to generate and never checked |
| Clarification spiral | Each answer raises a question the other side answers broadly | Neither side has a definition of “done” |
The courtesy loop is the visible one. Commitment drift is the expensive one: after six turns, a “we could probably ship by Friday” has become “confirmed Friday delivery”, and no human read either message.
Recognising that the counterparty is an agent
You usually cannot know, and the design should not depend on knowing. What detection changes is policy (tighter turn budgets, earlier handoff), not trust. A message that says “I am an AI agent” is untrusted input like any other line of an email, and a message that says “I am a person” proves nothing either. Treat the claim the way the prompt injection guidance treats any instruction found in a body: as data.
Signals, from strongest to weakest:
| Signal | Strength | Readable through this product’s API? |
|---|---|---|
| Explicit self-disclosure in the body | Strong | Yes, in the body text |
Auto-Submitted or vendor agent headers | Strong | No, raw headers are not exposed |
| Replies within seconds, at any hour | Weak | Yes, from message timestamps |
| Templated structure, sign-off, footer | Weak | Yes, in the body text |
| Address or display name patterns | Weak | Yes, from on the message object |
The second row matters for anyone building on Email for Agents. The message object
returned by the REST API carries id, thread_id, direction, subject, preview,
state, environment, simulated, from, to, cc, reply_to, recipients,
attachments and created_at, with original_raw_available set to false. Arbitrary
inbound headers are not part of it. On the way out, the send request schema is strict and
has no headers field, so an agent cannot set Auto-Submitted or a custom X- header
either. The only non-threading header this product adds to outbound mail is
X-EFA-Correlation, a support and audit tag.
So disclosure, if you want it, has to live in the body. A short, stable, machine-readable line at the foot of every agent-authored message is enough:
--
Sent by an AI agent for Northwind Procurement. A person reviews anything contractual.
agent-disclosure: v1; replies-expected: no
The replies-expected field is the useful part. It lets a well-behaved counterparty
agent know that this message closes the exchange, and it costs a human reader nothing.
It is a convention between cooperating parties, not a protocol anyone enforces.
Thread semantics between two agent inboxes
Threading links messages on each side independently. There is no shared conversation object between two inboxes, even two inboxes in the same workspace. Each inbox keeps its own thread, and the RFC headers are what tie the two halves together in transit. The full mechanics are in the guide to In-Reply-To, References and agent threading; the part that matters for two agents is this sequence.
- Agent A sends a new message. Without
reply_to_message_id, a new thread is created in A’s inbox. The provider assigns the RFC Message-ID at submission. - The message arrives in B’s inbox. B’s inbound processing looks up its
In-Reply-ToandReferencesagainst Message-IDs already recorded for B’s inbox, finds nothing, and opens a new thread. - B replies with
reply_to_message_idset to themsg_ID of A’s message. The reply joins B’s thread, and at dispatch it carriesIn-Reply-Toset to A’s Message-ID andReferencesset to the parent’s references plus that ID, trimmed to the last 50. - The reply arrives in A’s inbox. The lookup matches A’s own outbound Message-ID, so it joins A’s original thread.
Three consequences follow for agent-to-agent design.
The subject is never consulted. If B’s agent rewrites the subject every turn, the
threads still hold. If B’s agent forgets reply_to_message_id, the thread forks on both
sides, and no subject match will repair it.
Live mail between two inboxes on this platform takes the real transport. The send
path submits every live message to the mail provider; it does not look up whether the
recipient is another inbox here and short-circuit. Two agents on
agents.emailforagents.ai exchange mail the same way either would with Gmail, including
the delay and the email units each side is charged.
message_count on a thread is a per-inbox number. GET /v1/projects/{project_id}/inboxes/{inbox_id}/threads returns it for each thread, and it
counts both directions in that inbox. It is the cheapest input to a turn budget, as long
as the thread did not fork.
The stopping rules each side needs
Each side needs its own stopping rule, because you control only one of them. Assume the other agent has none. The rules below are the design layer; the mechanical backstops against runaway replies (rate limits, duplicate detection, header checks) are covered in the guide to agent email loop prevention.
The two roles need different rules:
| Rule | Initiator (sent first) | Responder (answered) |
|---|---|---|
| Definition of done | Stated before sending: the fact it needs | The question asked, answered once |
| Who closes | Owns closure; sends the final message | Never sends the last courtesy message |
| Reply to a message with no new content | Never | Never |
| Turn budget per thread | Small, fixed, per task | Smaller than the initiator’s |
| On budget exhausted | Hand off to a person | Stop, and note it for review |
| On repeated question | Hand off | Answer once more, then stop |
Never reply to a message whose only new content is courtesy. That one rule ends the courtesy loop from either side. Implement it as a classification step before drafting, not as an instruction buried in the drafting prompt: the model asked “does this message contain a question, a new fact, or a request?” is answering a narrower question than a model told to “reply only if necessary” while it is already composing a reply, and its answer can be logged and tested on its own.
The initiator owns closure. It knew what it wanted before it sent the first message, so it knows when it has it. Its last message states the outcome and says no reply is needed, in words and in the disclosure line. The responder, which cannot know the initiator’s goal, answers what was asked and stops.
Count your own outbound messages, not the thread. A turn budget on total thread
length lets a chatty counterparty spend your budget. Count the messages with direction
of outbound in the thread, from GET /v1/projects/{project_id}/inboxes/{inbox_id}/threads/{thread_id}/messages, and compare
against a per-task limit. Three or four is generous for most machine-to-machine
confirmations.
Commitment is a handoff trigger, not a turn. Any message in which your agent would agree to a price, a date, a quantity or a term goes to a person, whatever the budget says.
The product supplies backstops underneath these, and they are backstops, not stopping
rules. A sending policy at workspace, project or inbox level can set a daily recipient
limit for live mail, after which sends fail with policy_denied. When the free allowance
of 100 email units a month runs out, sending stops. Either will end a loop eventually, having spent the budget you wanted for real
work.
Handing the conversation to a human
Hand off when the next message would commit your side to something, when the budget is spent, when the same question comes back twice, or when the counterparty asks for a person. The handoff has two halves: the counterparty must learn that a person is taking over, and a person must actually receive the thread.
This product’s way to put a person in front of a send is the approval gate. A credential
holding drafts:submit without messages:send cannot transmit: every send it attempts
returns approval_required with the reason code draft_only_principal, the draft waits
in the approval queue, and an approval.requested event fires. An inbox policy with an
always rule does the same for any credential. The
human approval guide covers the queue, the payload
hash binding and the rejection path.
What the product does not do is scope a credential to one thread. Scopes belong to the
key, so “this conversation is now human-reviewed” is your code’s decision: when a thread
crosses a handoff trigger, route every later action on that thread through the
draft-only credential, and mark the thread in your own state so a restart does not
forget. The agent’s last direct message should say plainly that a person will reply,
and set replies-expected: no so a cooperating agent waits rather than prompting.
The console shows every thread in the inbox, so the person picking it up reads the whole exchange, including anything the agents agreed before the handoff.
Reproducing an agent-to-agent exchange with test inboxes
You can rehearse two agents corresponding without mailing anyone, but not by having them
mail each other. A test inbox cannot deliver to another test inbox. Sending from a
test inbox goes to a simulated transport that records the submission and returns
accepted with a generated .mail.invalid Message-ID. Nothing is routed anywhere,
including to another test inbox whose address you put in to. The message moves
queued to accepted in the sending inbox, a message.accepted event fires, and the
other inbox receives nothing.
Inbound in test exists only by injection:
POST /v1/projects/{project_id}/test/inbound, which needs test:simulate and
messages:read and works only on a test project and a test inbox. So the rehearsal is a
relay: your harness plays the network.
Two properties of that endpoint shape what the rehearsal can prove.
- Every injected message opens a new thread. The request accepts
inbox_id,from,to,subject,text,htmlandattachment_ids, and nothing else; there is no field that names a parent. Each relayed turn therefore lands as a fresh thread in the receiving inbox. - Injected messages carry no RFC Message-ID. When an agent replies to one with
reply_to_message_id, the reply joins that thread in its own inbox but has noIn-Reply-Toto emit.
So test mode exercises your stopping rules, handoff routing, events and approvals. It
does not exercise cross-turn threading or the header chain, and a turn budget that
reads message_count from one thread will see a new thread per relayed turn. Keep a
conversation ID in the harness and count turns there.
The procedure, with an efa_test_ key holding inboxes:read, inboxes:write,
threads:read, messages:read, messages:send and test:simulate:
- Create two test inboxes, A and B. Each gets a generated
.mail.invalidaddress. - Seed the opening: inject the scenario’s first message into A, from B’s address.
- Run agent A against its inbox. When it sends, read its latest outbound message and its body.
- Relay that turn into B by injecting it, with
fromset to A’s address. - Run agent B, read its outbound message, and relay it into A the same way.
- Stop when either agent declines to send, or when the harness turn cap is hit. Record which one ended it.
One relay step, A to B:
API=https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID
AUTH="Authorization: Bearer $EFA_TEST_KEY"
# A's newest outbound message (the inbox message list is newest first)
MSG=$(curl -s "$API/inboxes/$INBOX_A/messages" -H "$AUTH" \
| jq -c '[.data[] | select(.direction=="outbound")][0]')
MSG_ID=$(echo "$MSG" | jq -r .id)
SUBJECT=$(echo "$MSG" | jq -r .subject)
BODY=$(curl -s "$API/inboxes/$INBOX_A/messages/$MSG_ID/body" -H "$AUTH" | jq -r .text)
# Deliver it to B by injection
curl -s -X POST "$API/test/inbound" -H "$AUTH" -H "Content-Type: application/json" \
-d "$(jq -n --arg inbox "$INBOX_B" --arg from "$ADDR_A" --arg to "$ADDR_B" \
--arg s "$SUBJECT" --arg t "$BODY" \
'{inbox_id:$inbox, from:{email:$from}, to:[{email:$to}], subject:$s, text:$t}')"
The inbox-wide message list is ordered newest first, 25 to a page by default; a thread’s message list is ordered oldest first by sequence. Mixing the two up is the usual reason a harness relays the wrong turn.
Run the scenario at least three ways: both agents with stopping rules, only one with them, and neither. The third run is the baseline. With neither, you are measuring how long the courtesy loop runs before the harness cap ends it, which is the number to show anyone who thinks the drafting prompt alone is enough.
The limits are small and enforced: up to 3 active test inboxes per workspace, up to 100
retained messages and 10 MB of content, and content expiring after 7 days. The message
cap is checked when a test inbox sends, against one counter for the whole workspace, and
a send over it fails with HTTP 403 test_limit_exceeded. Injected inbound messages are
not counted against it. A runaway rehearsal therefore stops after at most a hundred
outbound sends across both agents, fewer if earlier runs in the workspace used some of
the allowance. That is a backstop, not a result. The
guide to testing agent email without sending
covers the rest of the test surface.
A design checklist before the first message goes out
Decide these before either agent is pointed at a real counterparty:
- What done means, written into the initiator’s task, not inferred by the model.
- A courtesy classifier that runs before drafting and returns “no reply” for thanks, acknowledgements and sign-offs.
- A per-task outbound budget, counted from your own outbound messages.
- Handoff triggers: commitment, budget spent, repeated question, request for a person.
- The handoff route: a draft-only credential for handed-off threads, recorded in your own state.
- A body-level disclosure line with
replies-expected, because headers are not available to set. - A relay rehearsal in test, run with and without the rules, before going live.
None of this needs a new protocol. It needs each side to carry the judgement that humans apply without writing it down, stated as rules your code enforces. The inboxes, threading and approval gate that make that enforceable are described on the agent inboxes page, and the rest of the product is on the Email for Agents home page.
Keep reading
Keep an Agent's Replies in the Right Email Thread
How Message-ID, In-Reply-To and References decide threading, why subject matching breaks, and how to keep an agent reply in the right conversation.
Test an Agent's Email Without Sending Real Mail
Use non-routable test inboxes to simulate inbound mail, exercise drafts, approvals and webhooks, and verify agent behavior before a live message goes out.
Human Approval Before an AI Agent Sends Email
When to require review, why approval binds to one exact draft version, how recipient rules and daily caps work, and what editing a draft does to its approval.