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:

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:

The events that move a wait between states:

FromTriggerTo
(none)Send returns result: "queued"waiting
waiting / nudgedmessage.received with this wait’s thread_idreplied
waiting / nudgedDeadline passes and nudges remainnudged (send one more)
waiting / nudgedDeadline passes and no nudges remainabandoned
waiting / nudgedmessage.delivery_updated with bounced, suppressed or complainedabandoned, at once
abandonedmessage.received on the thread (a late reply)replied
waiting / nudgedmessage.failed or message.submission_unknown for a message it sentpaused, for a person
(never starts)Send returns approval_requiredno 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:

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.

ContextFirst deadlineNudgesAbandon afterWhat abandoned means
Verification code or link2 to 5 minutes0the first deadlineFail the step; ask the app for a new code
Internal colleague1 business day11 more business dayEscalate to the human who owns the task
Vendor quote or info2 business days23 business days after lastHand to a person, or proceed with the other quotes
Human approval of a sendnot this patternn/athe approval’s own expiryNo. Nothing is sent
Cold outreach5 business days1the first nudge deadlineStop permanently for this sequence

The cases where these flip:

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

All guides · Documentation · Inboxes