Controls · September 27, 2026
Stopping an Infinite Agent Auto-Reply Loop
Four stopping rules that end an agent email loop with an auto responder: a thread reply budget, auto-reply signals, a sender window, and an approval gate.
An agent email loop starts with one polite message. Your agent answers a customer, the customer’s out-of-office replies, and the agent, which was built to be helpful, answers the out-of-office. That produces a second out-of-office, or a ticketing system’s “we got your message” receipt, and now two automated systems are writing to each other with nobody reading. An infinite reply loop between an agent and an auto responder does not look like an attack: every message is well-formed and every send is authorised. It ends only when something runs out.
This article settles how to stop one mechanically: four rules, in the order you should add them, what each one catches and misses, and exactly which parts of this product help and which are yours to build. For the wider map of outbound threats, of which loops are one row, read the threat model for agent email.
Why an agent email loop with an auto responder never ends on its own
A loop needs two parties that each treat the other’s mail as a reason to send. An LLM
agent is worse than a classic autoresponder in one specific way: an autoresponder sends
the same canned text, and the standard defences (one reply per sender per week, never
answer MAILER-DAEMON) were written for it. An agent reads the incoming message and composes a new, relevant answer each time. An
out-of-office that says “I’m away until Monday, for urgent matters contact
billing@example.com” is, to a helpful model, an instruction with a new recipient in it.
The mail protocol has an answer, and it is more than twenty years old.
RFC 3834 says automatic responses “SHOULD
NOT be issued in response to any message which contains an Auto-Submitted header field”
with any value other than no, that an automatic reply should itself carry
Auto-Submitted: auto-replied, and that “away” style responders should not send the same
response to the same sender more than once within several days, with “a 7-day period”
recommended as the default.
That protocol answer only works if both ends can read and write the header. On this product, today, neither end can, and the rest of this piece is built around that fact:
- This product’s send path does not set
Auto-Submitted. It setsIn-Reply-To,Referencesand an internal correlation header, and no other custom header. The send request schema is strict and has no field for headers, so you cannot add one either. So an RFC 3834 compliant responder on the other side has no way to know your agent is automated, and it will answer. - Inbound headers are not exposed to the agent. A message comes back with sender,
recipients,
reply_to, subject, preview and threading position. The body endpoint returnstext,html,body_availableandoriginal_raw_available, and that last field is hard-codedfalsein this build. Themessages:rawscope exists in the scope list, but no endpoint returns raw MIME or arbitrary headers. Your agent cannot readAuto-Submitted,Precedence,List-IdorX-Autoreplyon what it received.
Loop detection is your application’s logic, not a product guarantee. The product gives you ceilings that cap the damage, described below. None of them detects a loop.
The model deciding to stop is not a stopping rule
The tempting fix is a line in the system prompt: “Do not reply to automated messages.” It will work most of the time, which is the problem. A stopping rule has to hold on the one occasion it matters, and a model instruction fails that test for four reasons.
- It is probabilistic. The same out-of-office classified correctly nine times is answered on the tenth. A loop only needs one miss per round trip to continue.
- The input is adversarial by accident. Auto-replies are written to sound personal (“Thanks for reaching out! I’ll get back to you soon”) and often contain a redirect address. Inbound mail is untrusted input, and a message that says “reply to confirm” can be doing so on purpose. The prompt injection case and the loop case share a mechanism: the text the model reads decides whether it sends.
- The model has no durable counter. Each run sees whatever context you assembled. If you strip quoted reply text to save tokens, which you should, you have also removed the evidence that this is the ninth exchange.
- The model can be the thing that changed. A prompt edit or a new model version changes a prompt rule’s behaviour silently. A database counter does not drift.
The rule, then: every stopping check runs in code, before the model is invoked, on state the model cannot write. The model can still decide not to reply. That is a bonus, not the control.
Rule 1: a per-thread reply budget
Add this first. It needs no classification, it catches the common case, and it is impossible to talk your way past.
The rule: count the automatic replies your agent has sent in this thread. If the count has reached the budget, do not invoke the model for a reply. Mark the thread for a human instead. Three is a starting point for a support or intake agent, not a measured optimum: a real correspondent rarely needs more than three automated answers in one conversation before a person should look, and a loop reaches three within minutes. Tune it from your own threads once you have a few weeks of them.
You can compute the count from the thread itself. The thread messages endpoint returns
each message’s direction, from, state and position in the thread:
curl "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/threads/$THREAD_ID/messages?limit=100" \
-H "Authorization: Bearer $EFA_API_KEY"
Counting direction == "outbound" from that response is a floor, not the answer, for
two reasons that bite in practice:
- A reply waiting for approval is not a message yet. When a send becomes an approval
request, the API returns
result: "approval_required"with adraft_idand anapproval_id, and no message exists in the thread until a human approves it. If inbound mail keeps arriving while three replies sit in the queue, a thread-only count says zero. Keep your own counter, keyed bythread_id, and increment it when you submit, whether the result isqueuedorapproval_required. - Outbound messages include ones a human sent. If an operator replies in the thread, that is not an automatic reply and should not spend the agent’s budget. Your counter knows which sends were the agent’s; the thread does not.
So the durable version is a row in your own store:
# Your application's logic. Runs before the model is called.
BUDGET = 3
def may_auto_reply(thread_id: str) -> bool:
row = db.get("thread_budget", thread_id) # your table, not the API
return (row.agent_replies if row else 0) < BUDGET
def record_auto_reply(thread_id: str, result: dict) -> None:
# Count on submission, not on delivery. "approval_required" counts too.
if result["result"] in ("queued", "approval_required"):
db.increment("thread_budget", thread_id, "agent_replies")
What the budget misses: any auto-reply that does not thread. Inbound mail joins an
existing thread only when its In-Reply-To or References point at a message this inbox
already knows. Many out-of-office replies carry those headers, so they land on the
thread. A reply that does not, such as a ticketing receipt sent with a new subject
(“[Ticket #88213] We received your request”) and no references, opens a new thread with
a fresh budget every time. That gap is why rule 3 exists.
Rule 2: check for auto-reply signals you can actually see
The RFC 3834 check is the right second rule in principle: refuse to answer anything
carrying Auto-Submitted with a value other than no, a Precedence of bulk, list
or junk, a List-Id, or vendor markers such as X-Autoreply. In this build you cannot
read any of those headers, as set out above. Do not write code that pretends to.
What you can read is the sender address, the display name, the subject and the body. Those carry weaker signals, and a weaker signal applied deterministically still beats a strong one the model is asked to notice:
| Signal (visible via the API) | Treat as | Why |
|---|---|---|
Sender local part is mailer-daemon, postmaster | Never reply | Bounces and delivery reports, not a correspondent. |
Sender local part contains noreply, no-reply, donotreply | Never reply | The sender has told you no human reads replies. |
Sender is one of your own inboxes (compare to list_inboxes) | Never reply | Agent-to-agent loop inside your own project. |
Subject starts Automatic reply:, Auto:, Out of Office | Do not answer | Out-of-office conventions. Log it and pause the wait instead. |
Subject contains Undeliverable, Delivery Status Notification | Never reply | A bounce, whatever the sender claims to be. |
Subject is a ticket receipt ([Ticket #, Request received) | Do not answer | Helpdesk acknowledgement. Answering creates the next ticket. |
The own-inbox row is the one people forget. Two agents in one project, each with its own
address and each told to “reply to anything addressed to you”, will loop through the
real transport (the design side of that case is in
two AI agents emailing each other): agent A’s reply leaves as normal mail, arrives at agent B as
message.received, and B answers. Pull your inbox addresses once from list_inboxes
(or GET /projects/{project_id}/inboxes, which returns each inbox’s address) and
refuse to auto-reply to any of them.
Keep this rule narrow. A customer who writes “Auto: renewal question” is a person, so a false positive should mean “a human sees it”, not “it is dropped”. Route matches to the same review path the budget uses.
Rule 3: a same-sender window
The third rule catches what the first two miss: auto-replies that break threading and carry no recognisable marker. Helpdesk products have shipped this rule for years. Kayako Classic’s support documentation, as read on 27 September 2026, describes “loop blocker rules” with a timeframe, a maximum number of emails fetched from one address inside it, and a time after which the block is removed. That is a mature, configurable inbound filter, and it is more than this product offers: there is no per-sender rate rule on the receiving side here, so you write it yourself. RFC 3834 recommends seven days between identical “away” notices to one sender.
For an agent, key the window on the sender address, not on the thread:
# Your application's logic.
WINDOW_SECONDS = 24 * 3600
MAX_AUTO_REPLIES_PER_SENDER = 5
def sender_allows(sender: str, now: float) -> bool:
sender = sender.lower()
recent = db.count("auto_replies", sender=sender, since=now - WINDOW_SECONDS)
return recent < MAX_AUTO_REPLIES_PER_SENDER
Two design choices matter more than the numbers.
Count your replies to the sender, not their messages to you. A busy customer who sends eight real questions in a day is not a loop. Five automatic replies to one address in a day is a problem whatever caused it.
Use the address you would reply to. Inbound messages expose both from and
reply_to. A loop runs through whichever address your agent writes to, so that is the
key. Note that this product requires recipients to be supplied explicitly on a reply;
reply_to_message_id threads the reply but does not pick the destination. Whatever
address your code puts in to is the one to count.
Twenty-four hours and five replies are starting points, not recommendations backed by data. The numbers flip with the use case. An intake agent that only acknowledges should allow one automatic reply per sender per day, closer to the RFC’s advice. A support agent in a genuine back-and-forth needs more, and relies on rule 1 to bound any single conversation.
Rule 4: the approval gate as the backstop
The last rule is not a detector. It turns the send into a decision a person makes, so the loop stops at the first message nobody approves. It is the backstop because it is the only rule that does not depend on predicting the shape of the loop.
There are two ways to put an inbox’s agent behind the gate, and both are product behaviour rather than your code:
- A draft-only credential. A key or connection that holds
drafts:submitwithoutmessages:sendcan compose a complete reply, but every submission that the policy does not deny outright becomes an approval request. The reason code isdraft_only_principalwhen no policy rule asked for approval, or the policy’s own reason (such asalways) when one did. approval_mode: "always"on the inbox policy. Every send from the inbox returns HTTP 202 withresult: "approval_required", and no message exists until a human approves it in the console. That emitsapproval.requested; a rejection emitsapproval.rejected.
The gate holds outbound mail. It does not hold inbound mail, and in a loop that matters:
the other side keeps writing, each arrival is a message.received, and your agent keeps
drafting. The signal to watch is not one pending approval but a growing stack of them on
one thread or to one address. Rule 1’s counter, incremented on approval_required,
stops the drafting too.
Two details make the gate a sound backstop. An approval is bound to a hash of the exact payload, so an edited draft cannot ride an old approval. And approvals expire after seven days, so an unattended queue ends as nothing sent rather than as a burst when someone finally clicks through. The full mechanics, including what the approval events do and do not tell you, are in human-in-the-loop approval for agent email.
The gate’s weakness is people. A reviewer who approves forty near-identical replies to an out-of-office has supervised a loop, not stopped it. Rules 1 to 3 keep the queue short enough to read.
The circuit breakers the product does enforce
Below your four rules sit the ceilings the platform applies whether or not your code is
correct. They are set on the inbox policy, which lives at
PUT /projects/{project_id}/inboxes/{inbox_id}/policy, and they are documented in full
in sending controls. The ones that bound a loop:
curl -X PUT "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/policy" \
-H "Authorization: Bearer $EFA_SESSION_CREDENTIAL" \
-H "Content-Type: application/json" \
-d '{
"expected_version": 3,
"send_enabled": true,
"approval_mode": "direct",
"allow_domains": [],
"allow_addresses": [],
"block_domains": [],
"block_addresses": ["auto-ack@helpdesk.example.com"],
"daily_recipient_limit": 200
}'
All eight fields are required, the schema is strict, and expected_version must match
the version you last read or the write fails with HTTP 412 version_conflict. Only a
current human owner or administrator can write it. An agent credential cannot, so an
agent in a loop cannot raise its own ceiling.
daily_recipient_limit is the real circuit breaker, and its behaviour is precise.
It counts the distinct To, Cc and Bcc recipients of each live message against a bucket
for the current UTC day. The reservation is taken inside the same transaction that queues
the send, before message.queued is emitted. If any policy layer that applies to the
inbox (workspace, project or inbox) has a limit without headroom, the request fails with
HTTP 403 policy_denied and the message “The daily recipient limit has been reached.”
Nothing is queued. Unlike the send endpoint’s denial for a block or allow rule, this one
carries no details.reason_codes. The error vocabulary also declares a daily_limit_exceeded code; this build
never throws it, so match on policy_denied and the message.
What happens next is equally exact. The inbox is not paused, no event is emitted, and the
next UTC day’s bucket starts empty, so an unattended loop resumes at midnight UTC. A
failed send releases its reservation; an accepted one consumes it; a
submission_unknown holds it. Test-mode sends never touch the meter, so a loop you
reproduce in a test project will not trip the cap. A reply held for approval reserves
nothing until it is approved, which means an approval can be refused at the moment of
approving because the day’s cap is spent.
The other levers, and their limits:
block_addressesandblock_domainsstop sends to the looping counterparty: the send fails withpolicy_deniedand reason codeblocked_recipient. Policy is checked before any approval request is created and again when a human approves, so a block added while replies sit in the queue stops them too. Blocks do not stop that party’s mail arriving; the policy governs outbound only. Allow and block rules are covered in restricting which domains an agent can email.send_enabled: falseis the stop button. New sends fail withpolicy_deniedand reason codesend_disabled, and queued messages not yet submitted to the provider are re-checked and canceled. Receiving continues.- The monthly allowance counts every received message and every recipient sent to. A loop spends it from both directions, and blocking your outbound does not stop the inbound half. When it runs out, sending stops; received mail stays readable.
project.sending_pausedis in the declared event list, but no code path in this build emits it. Do not build an alert on it. Alert on your own counters, and onpolicy_deniedresponses from the send endpoint.
Order of operations, and how to test it
Put the checks in this order in the handler for message.received, cheapest and most
certain first, and stop at the first one that says no:
- Sender is one of your own inboxes, a null or no-reply sender, or a bounce: drop.
- Thread budget spent: route to a human, do not call the model.
- Sender window spent: route to a human, do not call the model.
- Subject or body looks like an auto-reply or ticket receipt: route to a human.
- Call the model. Submit with an
Idempotency-Keyderived from the inbound message ID, so a retried handler cannot send twice. Increment both counters onqueuedorapproval_required.
Then the product’s layers apply on their own: recipient rules, the approval gate, and the daily cap.
You can check rules 1 and 3 without real mail. In a test project, inject inbound messages
with POST /projects/{project_id}/test/inbound, using a sender like
auto-ack@helpdesk.example.com and a subject such as [Ticket #88213] We received your request. Each injected message opens a new thread, because the test inbound request has
no threading fields. That is exactly the thread-breaking receipt rule 1 cannot see, so it
is the right fixture for rule 3: inject six in a row and assert that your handler
submitted at most five replies to that address and routed the rest to review. Test sends
never consume the daily cap, so only your code can stop this test from looping.
For the broader test harness, including how to wait for message.received in CI, see
testing the inbound loop in CI. And when you decide
how much of this to put behind a person rather than a counter, the
controls overview sets out what each policy lever holds back. Give
each agent its own inbox, too, so one agent’s loop cannot spend another’s inbox-level
daily cap.
Keep reading
The Agent Email Threat Model
Assets, actors, trust boundaries and eight threats for an agent with an email address, with the layer where each control binds and where ours stops short.
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.
Timeouts for an Agent Waiting on an Email Reply
A timeout pattern for an agent waiting on an email reply: three states, the events that move them, in-thread nudges, and defaults by context.