Inboxes · September 26, 2026
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.
An agent emails a supplier for a quote and then has to decide how long to wait. Pick four hours and it nags someone who was going to answer tomorrow morning. Pick two weeks and the workflow sits open long after anyone could still act on the answer. People answer email on a distribution with a long tail, so no single fixed timeout is right. You need an agent waiting for email reply timeout pattern with more than one deadline, and a clear rule for what happens when each deadline passes.
This piece gives you that pattern. It covers three states, the events that move a wait between them, why a nudge has to go into the existing thread, which delivery signals you can trust, and a table of starting timeouts by context, including the cases where the advice flips. If the thing you are waiting on is a person deciding whether mail may go out at all, that is a different problem with a different rule, covered in human approval before an agent sends email.
Waiting for a machine and waiting for a person are different problems
Most guides on this topic are about a verification code arriving in a test inbox. That wait is short and behaves predictably: the sending system either emits the message within a minute or two or something is broken. The right response to a timeout is to fail loudly. Emailing the sender is pointless, because nothing on the other end reads mail.
A person is different in three ways that change the design:
- The latency has a long tail. Most replies to a given kind of request come fairly soon, and a meaningful share come days later, after a weekend, a trip, or a busy week. A timeout set to catch the typical reply gives up on the slow ones. A timeout set to catch the slow ones leaves every fast failure hanging.
- You can prompt them. A short follow-up in the same conversation moves a request up an inbox. A machine cannot be prompted this way.
- Prompting has a price. Every follow-up costs the recipient’s goodwill and, as covered below, an email unit. Too many follow-ups to a stranger can end in a spam complaint, and a complaint suppresses that address for your whole workspace.
There are no reply-latency statistics in this article. We have no measurement of our own that fits, and a figure taken from someone else’s population says little about how fast your supplier answers a purchasing question. Every number below is a recommended starting default. Tune it against the replies your own inbox actually receives.
The agent waiting for email reply timeout pattern: three states
The pattern that fits a long tail is a small state machine per conversation. There are three states and one exit:
- waiting: the original request went out, and the first deadline is running.
- nudged: at least one follow-up went out, and the next deadline is running.
- abandoned: the agent has stopped following up and has handed the outcome to whatever handles “no answer”, whether that is a human, a fallback vendor, or a closed ticket.
- replied (exit): an inbound message arrived on the thread. The wait is over, and your workflow decides what the reply means.
The events that move a wait between states:
| From | Trigger | To |
|---|---|---|
| (none) | Send returns result: "queued" | waiting |
| waiting / nudged | message.received with this wait’s thread_id | replied |
| waiting / nudged | Deadline passes and nudges remain | nudged (send one more) |
| waiting / nudged | Deadline passes and no nudges remain | abandoned |
| waiting / nudged | message.delivery_updated with bounced, suppressed or complained | abandoned, at once |
| abandoned | message.received on the thread (a late reply) | replied |
| waiting / nudged | message.failed or message.submission_unknown for a message it sent | paused, for a person |
| (never starts) | Send returns approval_required | no clock runs |
Two rows decide whether this works in practice.
Abandoned is not deleted. A reply that arrives three days after you gave up is
still a reply. Keep the wait record, so a late message.received on that thread
resolves it rather than landing as an orphan the agent does not recognise. Whatever
your abandon path did in the meantime, such as asking another vendor, now has to be
reconciled. That is a business decision, and the record is what lets you make it.
The clock starts only when mail actually left. If the send came back as
approval_required, nothing has been sent yet, so nobody has anything to reply to.
Start the wait when the approved message is queued. A queued result is also not the
end of the story: the send can still end in message.failed or
message.submission_unknown. After submission_unknown you do not know whether the recipient has the message, and a
nudge would make things worse: it either duplicates the original or refers to a
message they never received. Pause the clock and surface it to a person instead.
A nudge must reuse the thread
Send every follow-up as a reply to your own last message in the conversation, never
as a new message. In this product that means passing reply_to_message_id on the
send. There is no separate endpoint and no subject-line trick.
This is how the build behaves. When a send carries reply_to_message_id, the parent
must exist in the same inbox, and the credential needs messages:read as well as its
send scope (packages/domain/src/services.ts:1541). At submission time the worker
reads the parent’s RFC Message-ID and its stored References. It sets In-Reply-To
to the parent’s ID, and it sets References to the parent’s chain plus the parent,
deduplicated and trimmed to the last 50 entries (services.ts:4406-4412). Those two
headers are written onto the outbound message by the mail adapter
(packages/mail/src/resend.ts:110-111).
Now look at the inbound side, which is why a fresh message breaks the wait. When mail
arrives, the thread is resolved by looking up the message’s In-Reply-To and
References values against message IDs already stored in that inbox
(services.ts:2965-2974). There is no fallback to subject matching. If nothing
matches, the message starts a new thread.
Trace a nudge sent as a new message with the subject “Following up on the quote”. The
supplier hits reply on the nudge, because it is the one on top. Their reply references
the nudge’s Message-ID, so it lands in the nudge’s thread. Your wait is keyed on the
original thread’s thread_id, so it never sees the reply and goes on to send a second
nudge to someone who has already answered. On the supplier’s side, meanwhile, the
original request and the follow-up now show as two separate conversations.
Keeping the nudge in the thread avoids all of that. The recipient’s reply comes back
into one thread, whichever message they reply to, and one thread_id is enough to
detect it. Keeping an agent’s replies in the right thread
goes through the header mechanics in more detail, including the clients and forwards
that strip them.
Two practical details. Give each nudge an idempotency key derived from the wait and the
nudge number, such as nudge-msg_7f21c-2, so a crash between sending and saving state
cannot produce a second nudge. And keep the subject as Re: plus the original subject.
Clients that group by subject will then agree with the ones that group by headers.
Some replies still arrive without the headers, usually because someone forwarded the
thread or started a fresh message to the same address. Those land in a new thread. The
safe fallback is to check a message.received in an unknown thread against the
senders of your open waits, and to hand ambiguous matches to a person rather than
guessing.
Delivered is a weak signal, and there is no open or read event to subscribe to
Only one per-recipient signal comes back after a send: message.delivery_updated. It
carries message_id, recipient and a status. The status comes from the sending
provider’s own callbacks. email.delivered becomes delivered, and bounce,
complaint, suppression, delay and failure map the same way
(services.ts:4540). The event is emitted at services.ts:4585.
What each status means for the wait:
deliveredmeans the receiving mail server accepted the message. It does not mean the message reached an inbox rather than a spam folder, and it says nothing about a person. Treat it as confirmation that the clock should be running, nothing more. Acceptance by a provider and delivery by a receiving server are two separate claims, and neither one is about a reader.delayedis not a reason to act. Leave the deadline alone.bounced,suppressed,complainedmean waiting is pointless. Move straight to abandoned. A complaint, a provider suppression or a permanent bounce also inserts a workspace-wide suppression for that address (services.ts:4579-4580), so a nudge would be refused withrecipient_suppressedanyway.
The customer-facing event catalogue in packages/contracts/src/events.ts:1-20 has 18
types, and none of them is an open or a read. The name email.opened does appear
in the build, but only in the list the provider adapter uses to classify incoming
provider callbacks (packages/mail/src/resend-inbound.ts:127). The status map at
services.ts:4540 has no entry for it, so an open callback produces no customer
event. email.read is not an event in this build at all. In packages/domain and
workers the string appears only as an OAuth scope name
(packages/domain/src/oauth-consent.ts:89, workers/mcp/src/index.ts:137). Do not
build a handler for either one, because it will never fire.
That is also the right design, not merely a gap. An open is recorded when something fetches a tracking image, and plenty of things do that without a person. Apple Mail Privacy Protection loads remote content through an Apple proxy. Postmark’s support documentation, checked on 2026-09-26, says the proxy download “will prompt a false-positive open event”, because it triggers the tracking pixel whether or not anyone reads the message. Corporate security gateways fetch images and links to scan them, which does the same. An open is therefore not evidence of a human, and an agent that nudges faster “because they opened it” is nudging on the behaviour of a proxy. The only signal that proves a person engaged is a reply.
Timeout defaults by context, and when the advice flips
These are our recommended starting defaults, not measurements. Count them in business days in the recipient’s timezone where you know it. A deadline that expires on Saturday morning produces a nudge nobody reads until Monday, where it sits on top of the original.
| Context | First deadline | Nudges | Abandon after | What abandoned means |
|---|---|---|---|---|
| Verification code or link | 2 to 5 minutes | 0 | the first deadline | Fail the step; ask the app for a new code |
| Internal colleague | 1 business day | 1 | 1 more business day | Escalate to the human who owns the task |
| Vendor quote or info | 2 business days | 2 | 3 business days after last | Hand to a person, or proceed with the other quotes |
| Human approval of a send | not this pattern | n/a | the approval’s own expiry | No. Nothing is sent |
| Cold outreach | 5 business days | 1 | the first nudge deadline | Stop permanently for this sequence |
The cases where these flip:
- The reply gives a date. “I’ll have numbers next Tuesday” is a reply, but it is not an answer. Resolve the wait as replied, then open a new wait with its first deadline one business day after the stated date. Do not nudge before a date someone gave you.
- An out-of-office arrives. Most auto-replies carry the threading headers, so it
usually lands on the thread as a normal
message.receivedand resolves the wait. Your reply classifier has to recognise it and reopen the wait, pushing the deadline past the stated return date. Treat it as a pause, not an answer. - An external deadline exists. If the quote is useless after Friday, work backwards from Friday. Choose the abandon time first, then fit the nudges in front of it. The defaults above assume the answer stays useful indefinitely.
- The verification code comes from a staffed desk. Some “send us an email to confirm” flows are read by a person. Then it is a human reply, and it belongs in the internal or vendor row.
- Cold outreach gets any negative reply. “Not interested”, “remove me”, or a reply with no apparent intent all mean abandoned, permanently, and the address should never enter another sequence. One follow-up is the ceiling because a complaint suppresses the address across the workspace, as described above.
The approval row stands apart because in it a timeout means no. When an agent’s send
is held for review, silence from the reviewer must never turn into permission, and in
this product it cannot. An approval that nobody acts on expires after seven days (the
approval row is written with expires_at = now() + interval '7 days' at
services.ts:1662), and an expired approval can no longer release the send. The build
emits
approval.requested when the hold is created (services.ts:1678) and
approval.rejected when a reviewer rejects (services.ts:2094). approval.approved
and approval.invalidated are declared in the catalogue but not emitted by this build,
so do not wait on them. A reply timeout
means the other thing: nothing has come back yet, and the agent decides whether to ask
again. Mixing the two is how an agent ends up treating a vendor’s silence as a refusal,
or a reviewer’s silence as consent. The policies that hold a send for review live in
the sending controls.
A working waiter over the events cursor
This script keeps waits in a local JSON file, reads events from the project cursor, and
sends in-thread nudges. It runs on Node 20 or later with no dependencies. The key needs
events:read, messages:send, and messages:read (required for any send that
carries reply_to_message_id). Run it on a
schedule, for example every five minutes. If you already receive events by webhook,
call onEvent from your handler as well, after verifying the signature and
deduplicating on the event ID. Every transition is idempotent, so handling the same
event twice changes nothing.
// waiter.mjs Node 20+. Env: EFA_API_KEY, EFA_PROJECT_ID, EFA_INBOX_ID
import { readFile, writeFile } from "node:fs/promises";
const API = "https://api.emailforagents.ai/v1";
const P = process.env.EFA_PROJECT_ID;
const I = process.env.EFA_INBOX_ID;
const STORE = "./waits.json";
const DAY = 86_400_000;
const DEAD = new Set(["bounced", "suppressed", "complained"]);
async function api(path, init = {}) {
const res = await fetch(`${API}${path}`, {
...init,
headers: { Authorization: `Bearer ${process.env.EFA_API_KEY}`, ...init.headers },
});
const body = await res.json();
if (!res.ok) throw Object.assign(new Error(body.error?.code), { code: body.error?.code });
return body;
}
const load = async () =>
JSON.parse(await readFile(STORE, "utf8").catch(() => '{"cursor":null,"waits":{}}'));
const save = (s) => writeFile(STORE, JSON.stringify(s, null, 2));
// Call only after a send returned result: "queued". gaps = [first, ...after each nudge]
// `to` is the send's recipient array, e.g. [{ email: "quotes@supplier.example" }]
export async function startWait(sent, { to, subject, gaps = [2 * DAY, 2 * DAY, 3 * DAY] }) {
const s = await load();
s.waits[sent.thread_id] = {
state: "waiting",
threadId: sent.thread_id,
lastId: sent.message_id,
sentIds: [sent.message_id],
to,
subject,
gaps,
step: 0,
deadline: Date.now() + gaps[0],
paused: null,
};
await save(s);
}
export function onEvent(s, evt) {
const d = evt.data;
if (evt.type === "message.received") {
const w = s.waits[d.thread_id];
if (w && w.state !== "replied") Object.assign(w, { state: "replied", replyId: d.message_id });
}
if (evt.type === "message.delivery_updated" && DEAD.has(d.status)) {
const w = Object.values(s.waits).find((x) => x.sentIds.includes(d.message_id));
if (w && ["waiting", "nudged"].includes(w.state))
Object.assign(w, { state: "abandoned", reason: `${d.status}:${d.recipient}` });
}
if (evt.type === "message.failed" || evt.type === "message.submission_unknown") {
const w = Object.values(s.waits).find((x) => x.sentIds.includes(d.message_id));
if (w && ["waiting", "nudged"].includes(w.state)) w.paused = evt.type; // a person decides
}
}
async function nudge(w) {
const n = w.step + 1;
try {
const res = await api(`/projects/${P}/inboxes/${I}/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": `nudge-${w.sentIds[0]}-${n}`,
},
body: JSON.stringify({
to: w.to,
subject: /^re:/i.test(w.subject) ? w.subject : `Re: ${w.subject}`,
reply_to_message_id: w.lastId,
text: "Following up on the note below, in case it got buried. No rush if it's in hand.",
}),
});
if (res.result !== "queued") return void (w.paused = `held:${res.approval_id}`);
w.sentIds.push(res.message_id);
Object.assign(w, { state: "nudged", lastId: res.message_id, step: n });
w.deadline = Date.now() + w.gaps[n];
} catch (err) {
if (err.code === "quota_exceeded" || err.code === "payment_required") w.paused = "quota";
else if (err.code === "recipient_suppressed")
Object.assign(w, { state: "abandoned", reason: err.code });
else throw err; // transient: the next run retries with the same Idempotency-Key
}
}
async function tick(s) {
for (const w of Object.values(s.waits)) {
if (!["waiting", "nudged"].includes(w.state) || w.paused || Date.now() < w.deadline) continue;
if (w.step < w.gaps.length - 1) await nudge(w);
else Object.assign(w, { state: "abandoned", reason: "no_reply" });
await save(s);
}
}
export async function run() {
const s = await load();
for (let more = true; more;) {
const q = s.cursor ? `&cursor=${s.cursor}` : "";
const page = await api(`/projects/${P}/events?limit=100${q}`);
for (const evt of page.data) onEvent(s, evt);
s.cursor = page.next_cursor ?? s.cursor;
more = page.has_more;
await save(s); // commit the cursor only after the batch is applied
}
await tick(s);
}
if (import.meta.url === `file://${process.argv[1]}`) await run();
A few design choices in there are deliberate. The cursor is saved only after its batch
has been applied, so a crash replays events instead of skipping them. A nudge held for
approval pauses the wait rather than advancing it, because a held nudge has not been
sent. A message.failed or message.submission_unknown on any message the wait sent
pauses it too, because nudging after either one is guessing. And the abandon step
only records a reason. What abandonment triggers belongs to
your workflow, not to the timer. If the stored cursor ever returns 410 cursor_expired, restart without a cursor. Event types, payloads and webhook signing
are in the events and webhooks reference.
When the wait ends in replied, the next job is picking up the workflow where it
stopped, with the reply as input.
Resuming an agent workflow on an email reply
covers that half, and how an agent reads its inbox
covers turning the reply’s body into something the agent can act on.
Every nudge costs an email unit
A nudge is an ordinary send, and it is metered the same way. An email unit is one
message received or one recipient sent to, so a nudge to three people costs three
units. The reply you are waiting for costs one more unit when it arrives. On the free
allowance of 100 units a month, a vendor-quote workflow that sends one request and two
nudges to a single contact spends three units before anyone answers. Test mail is never
charged, so run the whole schedule in a test inbox first with the gaps shortened to
seconds. A test inbox cannot reach a real recipient, and a message injected with
POST /projects/{project_id}/test/inbound (scope test:simulate) takes no threading
headers and always opens a new thread. That makes it a good test of the unknown-thread
fallback, not of the replied transition. For that one, call onEvent with a
message.received event carrying the wait’s thread_id.
When the allowance runs out, sending stops, and a nudge fails with a 402 rather than a
retryable error. Mail already received stays readable. The waiter above pauses the
wait with reason quota instead of treating that as a failed nudge or a silent
recipient. That matters, because abandoning a supplier for not replying to a nudge
that never went out is a wrong conclusion recorded as fact.
What happens when an agent’s email allowance runs out
covers the reservation and settlement mechanics, and why a 402 must never go through a
retry loop.
Two choices keep the cost in proportion. Nudge only the recipients whose answer you
need, not everyone on the original CC line, but do reply in the same thread so the
history stays visible. And cap nudges per wait in code, not in a prompt. The
gaps array in the waiter is that cap. An agent that can decide for itself to follow
up one more time has no stopping rule, and it will keep following up.
The address the agent waits from is a real inbox with a durable history, and that history is what makes this pattern work. Threads, event cursors and per-recipient delivery state are part of the agent inbox rather than something you reconstruct from logs.
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.
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.
What Happens When an Agent's Email Allowance Runs Out
Sending returns HTTP 402, not 429. What keeps working, how a unit is counted, which reservations stay held, and the upstream quota you do not control.