A person who loses track of an email conversation scrolls up. An autonomous agent has no scroll bar. It knows about earlier messages only through whatever links the conversation together, and in email those links are three headers: Message-ID, In-Reply-To and References. When they are missing or wrong, the agent does not get an error. It keeps working from the one message in front of it.

Four things go wrong when threading breaks, and each has a mechanical cause you can design around. The mechanics of sending and reading threads through the API are covered in multi-turn conversations via email; this post is about what the agent loses without them.

1. The agent answers without the history

If a reply is not linked to the conversation it belongs to, the agent sees one message: "Yes, go ahead with the second option." Second option of what? A model given only that line will either ask a question the human already answered or, worse, pick something plausible and act on it.

When the link holds, the agent can load the whole exchange before it writes a word. GET /v1/threads/:threadId/context?budget=3000 returns the thread as one block sized for a prompt: the newest messages in full, older ones condensed or dropped to fit, with counts of what was condensed and dropped so the agent knows the history it holds is partial. Both sides are included, because Lumbox stores the agent's own outbound mail in the same inbox as the replies.

2. The human sees a stranger

The other side of the conversation depends on the same headers. When an agent answers with a new message and copies "Re:" into the subject, the human's client has no In-Reply-To to follow, so the answer can show up detached from the question. The person then has to work out which request it refers to, and they tend to reply to whichever message they find first, which splits the conversation into two.

In Lumbox the rule is simple because the API enforces it. POST /v1/inboxes/:id/send has no field for custom headers, so it always starts a new conversation. POST /v1/inboxes/:id/reply takes an email_id, sets In-Reply-To to that message's Message-ID, includes it in References, and addresses the reply to that message's sender. An agent that continues conversations only through /reply, always with the id of the latest inbound message, gives the human a thread their client can follow.

3. The same message gets handled twice

Duplicates reach an agent in more ways than one. A sending server retries a delivery it thinks failed. Your code receives an email.received webhook and also sees the same message on its next poll. The process crashes after replying but before recording that it replied, then restarts and handles the message again.

Each needs its own guard:

  • On arrival, Lumbox drops a message whose Message-ID it has already stored, which covers redelivery of the identical message.
  • In your code, keep a set of handled email_ids, or add a label with POST /v1/emails/:id/labels and skip labelled messages, so the webhook and the poll cannot both act.
  • On the reply itself, send an Idempotency-Key derived from the inbound message id. Lumbox stores the first successful response for 24 hours and replays it, with an Idempotent-Replayed: true header, instead of sending again. A failed attempt releases the key so a retry can go through.

The email.received webhook is signed with HMAC-SHA256 in X-Lumbox-Signature, and Lumbox does not retry it if your endpoint is down, so a slow poll is what catches a missed delivery. The webhook and the poll will therefore see some of the same messages, which is why the handled-id check sits in front of both.

4. Two machines reply to each other forever

The dangerous loop is an agent answering an auto-responder that answers the agent. A vacation reply, a ticketing system's "we got your message" or another company's support bot each send something that looks like a reply, and a naive agent replies to it. RFC 3834 describes how automatic responders should behave, including an Auto-Submitted header that marks a message as machine-generated so other software knows not to answer it. An auto-responder that ignores the header, or an agent that never checks it, will answer anyway.

The Lumbox email object does not include raw headers, so the guards an agent can run through the API work on the sender address and on the thread itself. The thread matters because it lets the agent count its own turns:

const API = "https://api.lumbox.co/v1";
const headers = { "X-API-Key": process.env.LUMBOX_API_KEY! };
const AUTOMATED = /^(no-?reply|do-?not-?reply|mailer-daemon|postmaster|bounces?)([@+.-]|$)/i;
const MAX_AGENT_REPLIES = 4;

export async function shouldReply(email: any): Promise<boolean> {
  if (email.is_spam || AUTOMATED.test(email.from.address)) return false;
  if (!email.thread_id) return true;

  const res = await fetch(`${API}/threads/${email.thread_id}`, { headers });
  if (res.status === 404) return true;
  if (!res.ok) throw new Error(`thread lookup failed: ${res.status}`);

  const thread = await res.json();
  const ours = thread.messages.filter((m: any) => m.parsed.category === "outbound").length;
  return ours < MAX_AGENT_REPLIES;
}

Sent mail is stored with category outbound, so counting it gives the number of times the agent has already spoken in this conversation. Past the cap, the agent stops and hands the conversation to a person. A label such as needs-human, added with POST /v1/emails/:id/labels, makes those messages easy to find later with GET /v1/inboxes/:id/emails?label=needs-human.

When a provider rewrites the Message-ID

All of this assumes the ids stay stable in transit, and they do not always. Amazon SES documents that when you provide a Message-ID header, it overrides it with its own value. Mail from @trylumbox.com addresses is delivered through SES, so the message_id in a send response is the one Lumbox generated and stored, not necessarily the one the recipient's client sees.

Lumbox links an inbound reply by looking up its In-Reply-To, then each References entry, against the Message-IDs it has stored. For a conversation the other person started, that works: your agent's reply references their original message, and their next reply carries that reference forward. For a conversation your agent started with /send, the first reply can arrive with thread_id: null, because the only id it references is one SES assigned. From the second reply on, the chain links again to the thread that begins at that first reply.

The defense is a correlation key the agent owns. When it opens a conversation, record the recipient address and the subject with any Re: or Fwd: prefixes removed. When a message arrives with no thread_id, look it up by sender and normalized subject before treating it as new:

const PREFIX = /^\s*((re|fwd?|aw|sv)\s*:\s*)+/i;

export function conversationKey(address: string, subject: string | null): string {
  return `${address.toLowerCase()}|${(subject ?? "").replace(PREFIX, "").trim().toLowerCase()}`;
}

A header match, when there is one, is still more reliable than this, since two conversations with the same person can share a subject line. Use the key only when thread_id is null.

The threading behaviour behind all four failures is in the threads docs, and the reply endpoint's full body is in the sending docs.