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.

WhereKey usedWhat you control
Your inbox, outboundreply_to_message_id on the send requestEntirely
Your inbox, inboundIn-Reply-To and References against this inbox’s historyIndirectly, via what you sent earlier
Recipient’s clientIn-Reply-To, References, plus undocumented heuristicsOnly 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.

IdentifierLooks likeAssigned byUse it for
API message IDmsg_ + UUIDThis systemreply_to_message_id, every API call
RFC Message-ID<something@domain>The mail providerThreading in the recipient’s client
Provider email IDprovider-specific opaque stringThe mail providerInternal 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:

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:

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:

  1. Create the new inbox on the verified domain and send from it.
  2. Keep the old inbox active and receiving. Do not delete it — replies to everything you sent before the switch keep arriving there for months.
  3. Accept that the two histories stay separate; point human reviewers at both.
  4. 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

CauseWhat you observeWhat to do
reply_to_message_id omittedNew thread_id, no In-Reply-To on the wirePass the parent’s msg_ ID. Much the most common cause.
Parent is inbound with no usable Message-IDYour thread is right; the sent message carries no threading headersNothing to reference. Expect the recipient to see it as new.
Replying seconds after your own send was acceptedRetries about a minute apart, up to eight attempts, then fails with reply_parent_headers_not_available; message.failed carries that reasonWait for message.accepted on the parent before chaining.
Reply arrives before our own Message-ID is confirmedInbound deferred and retried for up to ten minutes, then stored anywayUsually self-healing. If it split, both threads hold real mail.
Malformed Message-ID (no brackets, CRLF, >998 chars)Treated as absentNothing client-side. A header-injection defence, not a bug.
Reference chain longer than 50 entriesOldest references trimmedLong threads may split in clients anchored on the head.
Parent in another inbox or project, or deletednot_foundResolve the parent inside the same inbox first.
Recipient’s client heuristicsCorrect headers, odd grouping on their screenOut 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.

  1. Simulate an inbound message. Capture message_id and thread_id from the message.received event — it carries simulated: true.
  2. Send a reply with that message_id as reply_to_message_id, and branch on result first: an inbox with an approval rule returns approval_required with a draft_id and no message_id, so an assertion written against thread_id throws on a perfectly correct response.
  3. Compare thread_id on the reply with the parent’s. Equal is the pass condition.
  4. GET /v1/projects/{project_id}/inboxes/{inbox_id}/threads/{thread_id}/messages and assert two messages, sequence 1 and 2, in order.
  5. Run the negative case: the same body without reply_to_message_id, asserting a different thread_id. A happy-path-only test passes against code that ignores the field entirely.
  6. Reply to one of your own outbound test messages. The simulated provider does assign a Message-ID on .mail.invalid, so this path exercises In-Reply-To population that a reply to a simulated inbound message cannot.
  7. Point at a bad parent — a msg_ ID from another inbox — and confirm you handle not_found instead 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


Keep reading

All guides · Documentation · Developers