Developers · September 21, 2026
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.
Your agent answers a support email, and the answer arrives as a brand new conversation three rows below the original — no quoted history, no connection to what it answers. The recipient replies to the old message instead, the agent never sees it, and the exchange forks.
Nothing failed. The send returned 202, the provider accepted it, the recipient got it. What broke is threading, and threading is not one mechanism. It is two, running in different places with different keys: one is yours, the other belongs to whatever mail client the recipient uses and you influence it only through three headers.
What actually threads an email
Inside your inbox, grouping is by thread_id. An outbound message joins an existing
thread if, and only if, the send carried reply_to_message_id. Without it a new
threads row is inserted and the message becomes sequence 1 of a conversation of one.
No inference step, no fuzzy match, no second chance.
Inbound live mail is grouped differently: the In-Reply-To and References values on
the arriving message are looked up against the RFC Message-IDs already recorded for
that inbox. A hit reuses the parent’s thread and takes the next sequence number; a miss
opens a new thread.
In the recipient’s client, none of that exists. Gmail, Outlook and Apple Mail group by the RFC 5322 headers the message carries, plus vendor heuristics nobody publishes. You cannot read their state, and provider acceptance tells you nothing about it.
| Where | Key used | What you control |
|---|---|---|
| Your inbox, outbound | reply_to_message_id on the send request | Entirely |
| Your inbox, inbound | In-Reply-To and References against this inbox’s history | Indirectly, via what you sent earlier |
| Recipient’s client | In-Reply-To, References, plus undocumented heuristics | Only the headers |
The two are independent. A reply can sit correctly in your thread and still open a new conversation on the recipient’s screen, because the header that would have linked them was not available when the message went out. Most of this article is about that gap.
Message-ID: who assigns it, and why it must stay distinct from provider IDs
Three identifiers get called “the message ID”, and conflating them causes most threading bugs.
| Identifier | Looks like | Assigned by | Use it for |
|---|---|---|---|
| API message ID | msg_ + UUID | This system | reply_to_message_id, every API call |
| RFC Message-ID | <something@domain> | The mail provider | Threading in the recipient’s client |
| Provider email ID | provider-specific opaque string | The mail provider | Internal reconciliation; never exposed to you |
The first one is the one your code uses. reply_to_message_id takes a msg_ ID and
nothing else. The RFC Message-ID is not accepted there and is not returned by the REST
API at all — neither GET .../messages nor GET .../threads/{thread_id}/messages
includes rfc_message_id, in_reply_to or references. They are internal plumbing.
They are internal because we do not mint the Message-ID. The provider does, at
submission time. When a send is accepted its response may carry the RFC Message-ID,
which is then stored on the message; when it does not, a background
send.reconcile_headers job retrieves it afterwards. Until one of those happens the
message genuinely has no Message-ID, and there is nothing to put in a child’s
In-Reply-To.
So the send response cannot contain it. A successful send returns this and only this:
{
"result": "queued",
"message_id": "msg_...",
"thread_id": "thd_...",
"state": "queued",
"request_id": "req_..."
}
What a candidate Message-ID must survive before it is stored is strict: it has to match
<local@domain> exactly — one pair of angle brackets, exactly one @, no whitespace
inside — be at most 998 characters, and contain no CR or LF. Anything else is discarded
and treated as absent, because a header value that can smuggle a newline can smuggle a
second header. The same normalisation runs on inbound values, so a malformed
In-Reply-To on a hostile message cannot inject anything downstream.
In-Reply-To and References in practice
When a send carries reply_to_message_id, the parent is read at dispatch time and two
headers are computed:
In-Reply-To— exactly one Message-ID: the parent’s.References— the parent’s own storedReferences, then the parent’s Message-ID appended, deduplicated, and trimmed to the last 50 entries. Emitted as a single space-separated header.
That is the RFC 5322 rule implemented literally: a reply’s References is its parent’s
References plus its parent’s Message-ID. Chains longer than 50 lose their oldest
entries, which is fine for clients that walk backwards from the newest reference and
imperfect for ones that anchor on the head of the chain. Each outbound message also
carries X-EFA-Correlation: efa_intent:<send intent id> — a correlation tag for
support and audit, not a threading input.
Incoming mail is parsed the other way. In-Reply-To is read as the first <...> token
in the header, References as every <...> token in it, keeping the last 50. The
parent search builds a candidate list of the In-Reply-To value followed by the
references in reverse order — nearest ancestor first — and takes the first message in
the same inbox whose Message-ID matches any of them.
Two details in that lookup are worth knowing.
Threading is scoped to one inbox. The parent query filters on inbox_id, so two
inboxes receiving the same broadcast each get their own message in their own thread.
There is no cross-inbox conversation object, by design — inboxes are permission
boundaries, and a shared thread would be a hole in one.
A message you sent that comes back is not a new conversation. If an arriving message’s own Message-ID matches one already confirmed for an outbound send from that inbox — a self-copy, a list reflector, the agent on its own Cc line — it attaches to that outbound message’s thread instead of opening a fresh one.
There is a race underneath this. A fast recipient can reply before the background job has learned the Message-ID they replied to, leaving the reference pointing at something the inbox has no record of. Inbound processing tries to resolve it and otherwise defers and retries — but only while the message is under ten minutes old by the provider’s received timestamp. After that grace period the mail is stored regardless, possibly as a new thread. Preserving someone’s email beats grouping it perfectly.
Why subject-line matching fails
The subject is never consulted, on the way in or out. The inbound parent lookup joins
on Message-IDs; outbound grouping reads reply_to_message_id. Neither query mentions
the subject column.
This is not an omission. Re: is locale-dependent — AW:, SV:, VS:, RE : — and
strippers disagree on how many prefixes to remove. Subjects run up to 998 bytes and
change mid-conversation. Worse, subject matching over-merges: in an accounts-payable
inbox, “Invoice” as a grouping key welds forty unrelated vendors into one thread.
A thread’s subject_display is written once, at thread creation, from the first
message’s subject, and never rewritten. When someone renames a drifting conversation,
the thread keeps its original label while each message keeps its own. The label is for
humans skimming a list; it carries no grouping authority.
If your agent is tempted to reconstruct threading from subjects — because it cannot see
rfc_message_id through the API — it does not need to. Every message already carries
thread_id, and the thread endpoint returns the
conversation in order. The search parameter q covers stored subjects, senders and
previews, which makes it a way to find a conversation, not a way to define one.
That distinction is spelled out in the search documentation.
Reply, reply-all and forward are three different operations
There is one send endpoint. POST /v1/projects/{project_id}/inboxes/{inbox_id}/messages
handles all three, and reply_to_message_id is the only thing distinguishing a reply
from a fresh message. It changes which thread the message joins and whether threading
headers are emitted. It composes nothing for you.
On a reply you still supply:
to— at least one recipient. It is not derived from the parent.subject— at least one character. NoRe:is prepended for you.
That is deliberate: deriving recipients is a judgement call, and a naive one gets it wrong in expensive ways.
Reply goes to the sender — but check the message object’s reply_to field first,
which carries the parent’s Reply-To addresses if it had any. Nothing in the send path
applies them automatically; the agent decides.
Reply-all is a list you build: the parent’s from, plus its to and cc, minus
addresses you do not want. Recipients are deduplicated case-insensitively across to,
cc and bcc at submission, but the cap of 25 unique recipients is evaluated first
and a larger list is rejected with invalid_request (“Too many recipients”) before
anything is stored. And if the inbox’s own address survives into the list, the agent
emails itself; that copy arrives back and, by the self-copy rule above, lands in the
same thread. It costs an email unit and produces a message the agent will read as new
input.
Forward has no operation at all; a forward is a new send, and the choice is real.
Pass reply_to_message_id and it stays in the original thread carrying the original
References chain — handing the new recipient the ancestry of a conversation they were
not part of. Omit it and you get a clean thread with no inherited headers. For anything
leaving your organisation, omit it. Attachments can be reused by ID: up to 10
already-clean att_ IDs from the same inbox, which is how a forward keeps its files
without re-uploading, and over MCP it is the only option, because MCP cannot upload
files.
One honest note on the surface: the canonical payload type includes an action field
whose values are send, reply, reply_all, forward and draft_submit, but every
code path today records send. It is reserved, not wired up — do not build logic that
reads it back.
Two access details bite people. Replying requires messages:read in addition to the
sending scope — it is a read of the thread as well as a write to it — enforced twice,
at request time and again against the stored grant when the send executes. And the
parent must be in the same inbox and project and not deleted; anything else is
not_found. Because reply_to_message_id sits inside the hashed canonical payload,
two replies to different parents can never collide on one idempotency key even with
identical bodies, which the idempotency article
covers in full.
Threading when you move to a custom domain
The Message-ID’s domain part comes from the provider and follows the sending domain, so
messages sent after a move get IDs on the new domain. That part is harmless: references
are opaque strings, and a client will thread a <...@new.example.com> reply onto a
<...@agents.emailforagents.ai> parent because it matches on the string, not the host.
The part that costs you is on your side. An inbox’s address and domain are fixed when
it is created — there is no PATCH on the inbox resource and no route that moves an
existing inbox to a different domain. Moving means a new inbox, and because the inbound
parent lookup is scoped by inbox_id, the new inbox starts with an empty history. A
reply to a six-month-old conversation, arriving at the new address, finds no parent and
opens a new thread.
So plan the move rather than performing it:
- Create the new inbox on the verified domain and send from it.
- Keep the old inbox active and receiving. Do not delete it — replies to everything you sent before the switch keep arriving there for months.
- Accept that the two histories stay separate; point human reviewers at both.
- Re-run the threading checks below against the new inbox. A new sending domain is new configuration, and the old evidence does not transfer.
Timing caveat: attaching a custom domain needs a live project on an eligible paid
subscription, and paid checkout is currently disabled — Builder and Team are proposed
plans that cannot be bought yet, so this is a decision to plan for rather than execute
today. Test projects cannot attach domains at all; the attempt returns
test_mode_unsupported, and test addresses stay on non-routable .mail.invalid hosts.
The domains documentation has the DNS side.
What breaks threading
| Cause | What you observe | What to do |
|---|---|---|
reply_to_message_id omitted | New thread_id, no In-Reply-To on the wire | Pass the parent’s msg_ ID. Much the most common cause. |
| Parent is inbound with no usable Message-ID | Your thread is right; the sent message carries no threading headers | Nothing to reference. Expect the recipient to see it as new. |
| Replying seconds after your own send was accepted | Retries about a minute apart, up to eight attempts, then fails with reply_parent_headers_not_available; message.failed carries that reason | Wait for message.accepted on the parent before chaining. |
| Reply arrives before our own Message-ID is confirmed | Inbound deferred and retried for up to ten minutes, then stored anyway | Usually self-healing. If it split, both threads hold real mail. |
| Malformed Message-ID (no brackets, CRLF, >998 chars) | Treated as absent | Nothing client-side. A header-injection defence, not a bug. |
| Reference chain longer than 50 entries | Oldest references trimmed | Long threads may split in clients anchored on the head. |
| Parent in another inbox or project, or deleted | not_found | Resolve the parent inside the same inbox first. |
| Recipient’s client heuristics | Correct headers, odd grouping on their screen | Out of scope for any provider. Check a mailbox you control. |
Notice what is not in that table: a delivery failure. Threading is decided by what the message carries, and a message can thread perfectly and still bounce. Provider acceptance is not proof of delivery, and it is not proof of correct threading either.
Verifying threading in a test inbox before you trust it
A test project sends nowhere: its addresses live on .mail.invalid, a domain reserved
so that it can never resolve. That makes it the right place to exercise the grouping
logic, as long as you know its limits.
The first one matters: a simulated inbound message always creates a new thread and is
stored with no Message-ID. POST /v1/projects/{project_id}/test/inbound does not
accept In-Reply-To or References, so it cannot test inbound header matching. It
tests your outbound grouping — the half you actually control.
- Simulate an inbound message. Capture
message_idandthread_idfrom themessage.receivedevent — it carriessimulated: true. - Send a reply with that
message_idasreply_to_message_id, and branch onresultfirst: an inbox with an approval rule returnsapproval_requiredwith adraft_idand nomessage_id, so an assertion written againstthread_idthrows on a perfectly correct response. - Compare
thread_idon the reply with the parent’s. Equal is the pass condition. GET /v1/projects/{project_id}/inboxes/{inbox_id}/threads/{thread_id}/messagesand assert two messages,sequence1 and 2, in order.- Run the negative case: the same body without
reply_to_message_id, asserting a differentthread_id. A happy-path-only test passes against code that ignores the field entirely. - Reply to one of your own outbound test messages. The simulated provider does assign
a Message-ID on
.mail.invalid, so this path exercisesIn-Reply-Topopulation that a reply to a simulated inbound message cannot. - Point at a bad parent — a
msg_ID from another inbox — and confirm you handlenot_foundinstead of falling through to an untracked new thread.
Test-mode caps are enforced, not advisory: 3 active test inboxes, 100 retained
messages, 10 MiB of retained content and a 7-day content expiry, with
test_limit_exceeded on HTTP 403 when you cross one. Budget for it — the sequence
above burns five or six messages per run.
What test mode cannot tell you is how Gmail or Outlook will draw the conversation. That
needs one real send to a mailbox you own, opened in the client your recipients use. Do
it once per sending domain, and again after any change to the From address.
Testing an agent’s email without sending real mail
covers the rest of the pre-live checklist.
Where to go next
- Request and response shapes for sends, threads and events: the agent email API loop and the quickstart.
- Why
reply_to_message_idbelongs to the idempotency hash: idempotency keys for agent email. - Permissions, the approval queue and how the pieces fit: how agent inboxes actually work.
- What lands on the event stream meanwhile: events and webhooks.
Keep reading
Agent Email API: Send, Receive and Reply in One Loop
Build the full two-way loop over REST: scoped keys, idempotent sends, the states a message moves through, inbound events, and replies that thread correctly.
Agent Email on a Subdomain Without Breaking Company Mail
Run agent mail on agents.yourcompany.com: inspect existing MX, publish ownership, SPF, DKIM and receiving records, and leave employee routing untouched.
Test an Agent's Email Without Sending Real Mail
Use non-routable test inboxes to simulate inbound mail, exercise drafts, approvals and webhooks, and verify agent behavior before a live message goes out.