Skip to content
Blog / Engineering

Why Email Threads Break: Message-ID, References and the Headers Agents Lose

Binoy Perera

Email threads live in three headers, not on a server. How Message-ID, In-Reply-To and References rebuild a conversation, what breaks them, and how agents recover.

  • engineering
  • threading
  • email-headers
  • inbound-email
  • ai-agents

TL;DR

Email has no thread object on the wire. Every client and every API rebuilds the conversation from a few headers each time it reads the mail. A thread holds together only if every sender and relay kept those headers intact. This article explains how the rebuild works and which senders break it. It also covers why the subject line is a bad fallback and what to do with a reply that arrives alone.

Suppose your agent sends a quote, the customer answers two days later, and the answer lands as a brand new conversation. The agent has no idea what "yes, the second option" refers to, so it asks a question the customer already answered. From the customer's side, the agent forgot them.

Nothing in the agent's logic caused that. Email never stored the conversation in the first place. Each message carries pointers to earlier messages. The conversation is rebuilt from those pointers every time anyone reads it. Lose a pointer and the rebuild fails quietly.

Developers usually meet this after send and receive already work, which is why it feels like a bug in their own code. It's mostly a property of email itself. The sections below start with where a thread lives and how its pointers are built. Then they cover the senders that drop them, and what your agent should do with an orphaned reply.

Where does an email thread actually live?

An email thread lives in three headers, not on any server. Each message carries a Message-ID, a unique label its sending host makes up. A reply carries In-Reply-To, naming the message it answers, and References, the chain of messages before it. RFC 5322, the standard for the email message format, defines all three.

None of them is required. The standard says every message SHOULD have a Message-ID and replies SHOULD have the other two, which leaves room for software that skips them. It also leaves uniqueness to the host that generates the ID, so an ID is only as unique as that host's software makes it.

So a thread is an inference. Your mail client, Gmail, or an email API reads the headers, follows the pointers, and draws the conversation. Two programs can read the same messages and draw different threads.

How does a client rebuild the conversation from headers?

A client rebuilds the conversation by following each reply's pointers back to messages it already has. The rules for writing those pointers are mechanical. In-Reply-To copies the parent's Message-ID. References copies the parent's References and appends the parent's Message-ID, so the chain grows by one with each reply.

HeaderWho sets itWhat it points atWhat happens when it is missing
Message-IDThe sending host, once per messageNothing; it is the label other messages point atReplies to this message have nothing to name, so the standard says they carry no In-Reply-To
In-Reply-ToThe replying clientThe direct parentThe reader falls back to References, if it is there
ReferencesThe replying client, copied forward from the parentThe chain of earlier messages, oldest first, parent lastEarlier context is lost if the parent is not stored locally
SubjectWhoever types itNothingNothing, which is the problem when it is used to join threads

The last column is where agents get hurt. If both pointer headers are gone, there is nothing left to follow. Every reader treats the reply as a new conversation.

In AgentMail, threading runs on exactly these three headers, and the parsed result is on every message. Each one carries a thread_id, its own message_id, the in_reply_to value, a references list, and a headers map with the rest. A reply that joined its conversation looks like this:

JSON

{  "inbox_id": "quotes@yourdomain.com",  "thread_id": "thd_8f2c41",  "message_id": "<CAF3x9Lq2mP7@mail.gmail.com>",  "subject": "Re: Quote for 40 seats",  "in_reply_to": "<0100019a7c2e@email.yourdomain.com>",  "references": [    "<0100019a7b11@email.yourdomain.com>",    "<0100019a7c2e@email.yourdomain.com>"  ]}

The last entry in references matches in_reply_to, which is what a well-behaved client produces. When the two disagree, or one is empty, the message came from software that cut a corner.

Which senders and relays break the chain?

Most broken threads start with software that writes a reply without reading the original's headers. Suppose a script sends follow-ups through a plain SMTP library and only adds "Re:" to the subject. The recipient sees a reply, but no reader can attach it to anything.

Forwarding breaks the chain on purpose. A forward is a new message with a new Message-ID, and the standard says any revised message gets a new identifier. The forwarded content rides inside the body, so the pointers to the old conversation usually don't come along.

Relays and tools that rebuild messages are the third source. Ticketing systems and mailing lists resend mail through their own software, and that software can rewrite or regenerate headers on the way through. Whether a given product keeps References varies, so test the ones your customers use before trusting them.

Then there is the sender who starts fresh. A customer who opens a new email instead of hitting reply has written a new message, even if it's about the same quote. Headers can't fix that, and neither can any API.

Why is subject matching a trap?

Subject matching is a trap because subjects aren't unique and people edit them. "Invoice", "Quick question" and "Re: Meeting" show up in thousands of unrelated conversations. An agent that merges on subject will eventually paste one customer's history into another customer's reply.

Even Gmail doesn't trust the subject alone. Its API only adds a message to an existing thread when References and In-Reply-To are set correctly and the subject headers match. The subject there is an extra condition, never a replacement for the pointers.

The opposite failure is just as common. A customer changes "Re: Quote for 40 seats" to "Re: Quote, now 60 seats", and a subject-based joiner splits a real conversation in two. Pointers survive an edited subject. Subjects don't.

What does a webhook event carry, and what does it leave out?

A webhook event carries enough to route a message, not always enough to act on it. AgentMail's message.received event includes the thread_id, message_id, in_reply_to and references. The agent knows which conversation a reply belongs to without another call.

The body is the part that can go missing. An event carries the body inline only when the stored body is 64 KiB or smaller. Above that, text, html, extracted_text and extracted_html are left out, and the event carries a body_url that works for one hour. Inline images in the HTML count toward the limit, so an ordinary reply with a pasted screenshot can cross it.

When fields are missing, fetch the message. Get Message returns the full body, the extracted reply and the headers map. Get Raw Message returns a signed link to the original MIME, the exact bytes the sender's server delivered. Keep it for the day a thread looks wrong. It shows what really arrived.

A parse-only inbound webhook is a different situation. It POSTs the parsed email to your endpoint and keeps nothing, so In-Reply-To points at an ID only your own database might know. If you didn't store every message you sent with its Message-ID, there is nothing to resolve the pointer against.

How do you handle a reply that arrives without its thread?

Handle an orphaned reply by classifying it, then routing it to review instead of guessing. Order matters. Trust in_reply_to first, then the newest entry in references. Look at the subject last, and only to flag the message. The function below reads one message and says which case it is in.

Python

# classify how an inbound message joins a conversation (agentmail 2.0.18)import osfrom agentmail import AgentMailclient = AgentMail(api_key=os.environ["AGENTMAIL_API_KEY"])REPLY_PREFIXES = ("re:", "aw:", "sv:")def classify(inbox_id: str, message_id: str) -> str:    msg = client.inboxes.messages.get(inbox_id=inbox_id, message_id=message_id)    if msg.in_reply_to:        return f"reply to {msg.in_reply_to} in {msg.thread_id}"    if msg.references:        return f"reply to {msg.references[-1]} in {msg.thread_id}"    if (msg.subject or "").strip().lower().startswith(REPLY_PREFIXES):        return f"orphaned reply in {msg.thread_id}: route to review"    return f"new conversation {msg.thread_id}"print(classify("quotes@yourdomain.com", "<CAF3x9Lq2mP7@mail.gmail.com>"))# reply to <0100019a7c2e@email.yourdomain.com> in thd_8f2c41

An orphaned reply is the one case worth a human or a clarifying question. The agent can ask "Is this about the quote from Tuesday?" and the customer's answer will carry proper headers. That costs one message, while a wrong merge can leak a stranger's data into the conversation.

When the agent answers, reply to the latest message in the thread rather than composing a new email. A reply sent from the message keeps the pointers correct, and the response comes back with the same thread_id. Setup for that loop, from the first send to the webhook handler, is in how to implement email threading in an AI agent.

If you are wiring up the inbound side for the first time, start with triggering an agent when email arrives and testing inbound webhooks locally. The broader case for why receiving is harder than sending is in inbound is the hard part.

What AgentMail does not do

We thread on the headers that arrive. We can't reconnect a reply whose sender dropped both pointers, and we don't stitch a forward back into its original conversation. Our reply extraction can also drop forwarded content and keep only the note above it, so read text or html when your agent handles forwards. Deciding what to do with an orphaned reply stays with your code.

FAQ

Questions, answered.

No. Every new email your agent sends starts a new thread. To continue a conversation, reply to the latest message in it, and the response returns the thread_id the reply joined.

Long threads come back in pages, and the first page holds the newest messages. Within each page, messages run oldest to newest. Follow next_page_token to fetch older pages when the agent needs the full history.

Read extracted_text first. It strips quoted history and trailing boilerplate, so the model sees only what the sender just wrote. Fall back to text when it is absent, and when the sender answered inline between quoted lines, extracted_text keeps the whole message.

No. It arrives as a separate message in each inbox, with the same message ID in both. Key any side effect, such as a reply or a ticket, on the inbox ID plus the message ID rather than the message ID alone.

Blog

Suggested reading

View all posts

Engineering ยท October 10, 2026

Inbound is the hard part

Sending email from an AI agent is solved. Receiving it is not: duplicate deliveries, broken threading, spoofed senders and no memory. Why a mailbox fixes all four.

Engineering ยท September 4, 2026

How to Trigger an Agent When Email Arrives

Trigger an AI agent when new mail arrives with AgentMail message.received, Svix verification, hydrate oversized bodies, then reply in thread.

Prompt

Give yourself access to AgentMail by following the guide at:

https://docs.agentmail.to/agent-onboarding.md

and refer to:

https://docs.agentmail.to/llms.txt

for the documentation index.

Start integrating AgentMail into your AI agents today.