Why Email Threads Break: Message-ID, References and the Headers Agents Lose
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.
| Header | Who sets it | What it points at | What happens when it is missing |
|---|---|---|---|
| Message-ID | The sending host, once per message | Nothing; it is the label other messages point at | Replies to this message have nothing to name, so the standard says they carry no In-Reply-To |
| In-Reply-To | The replying client | The direct parent | The reader falls back to References, if it is there |
| References | The replying client, copied forward from the parent | The chain of earlier messages, oldest first, parent last | Earlier context is lost if the parent is not stored locally |
| Subject | Whoever types it | Nothing | Nothing, 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_8f2c41An 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.




