Inboxes · September 30, 2026
Thread ID vs Message-ID in an Agent Inbox
Agent email thread ID vs message ID vs the Message-ID header: which to store, which a counterparty can forge, and what reply_to_message_id accepts.
An agent sends a quote, stores “the message ID” next to the deal, and waits. The reply arrives, and the lookup misses, because the value stored was one kind of ID and the value on the reply is another. Or the lookup hits, the agent acts, and the email turns out to be from someone who was never party to the deal. Both bugs come from the same place: three different identifiers answer to the name “message ID”, and the choice of agent email thread ID vs message ID vs header decides what your code can prove.
This piece settles which identifier to persist for correlation, which one you must never trust a counterparty to preserve or to be honest about, and what the reply parameter actually accepts. The header-by-header threading algorithm, including the recipient’s-client side of it, is in keeping an agent’s replies in the right thread. This article is about the identifiers themselves and what your correlation layer should do with them.
Agent email thread ID vs message ID: who mints each one
The three identifiers have three different authors, and authorship is what matters.
| Identifier | Format | Minted by | Scope | Returned by the REST API |
|---|---|---|---|---|
| Platform thread ID | thd_ + UUID | This inbox | One inbox | Yes, on every message and send |
| Platform message ID | msg_ + UUID | This inbox | One inbox | Yes, as id and message_id |
RFC 5322 Message-ID | <local@domain> | Whichever system sent the mail | Nominally global | No |
The prefixes come from one table in packages/contracts, alongside ibx_ for inboxes,
evt_ for events, drf_ for drafts and apr_ for approvals. Every platform ID is a
prefix plus a UUID, and the prefix is checked when the ID is decoded, so a thd_ value
in a msg_ slot is rejected rather than looked up. How that rejection surfaces on a
send is less clean than it should be, covered in the reply section below.
The RFC Message-ID is different in kind. For your outbound mail it is assigned by the
sending provider at submission, not by this system. For inbound mail it is whatever the
counterparty’s client wrote. The inbox stores it, and uses it for grouping, but the
message listings and the thread listing do not project it. A message row carries the
platform IDs, direction, subject, preview, state, sender, recipients, reply_to,
attachments, created_at and, on thread pages, sequence. There is no
rfc_message_id, in_reply_to or references field on any response.
That is a real gap and worth stating plainly. Nylas documents
fields=include_basic_headers
on its message reads, returning Message-ID, In-Reply-To and References and
nothing else, and EmailEngine
exposes the header as messageId
on every backend. If you need to join this inbox’s mail against
another system keyed by RFC Message-ID, a CRM or a ticketing tool that logged it, this
product cannot give you that key today.
What the thread ID on an inbound message actually asserts
An inbound thread_id is derived entirely from headers the sender wrote. It is
the inbox’s best reading of a claim, not a fact about who is in the conversation.
When live mail arrives, the inbox builds a candidate list from the In-Reply-To value
followed by the References values newest first, and looks for any message in the
same inbox whose stored Message-ID matches. A hit joins that message’s thread. A miss
opens a new one. The query filters on inbox_id and on Message-ID equality, and on
nothing else. It does not compare the sender with anyone already in the thread.
Follow that through. Every recipient of every message in a conversation holds its
Message-IDs, because they are in the headers of the mail they received. A person Cc’d
once, a colleague who was forwarded the chain, a mailing list archive: any of them can
write a message whose In-Reply-To names one of your messages, and it will land in
your thread with the same thd_ as the legitimate replies. That is the protocol
working as designed. Email threading was built for cooperative clients, not to
authenticate participants.
Two more details from the same lookup:
Message-IDs are not unique in the store. The header-reference table’s primary key includes the platform message ID, so two inbound messages that claim the same RFC Message-ID are both recorded. RFC 5322 asks generators to make the value unique; it has no way to enforce that, and some mailers reuse or omit it.
Ties resolve by trust, then by recency. Each recorded Message-ID is tagged
outbound_confirmed (the provider confirmed it for a send from this inbox) or
inbound_claimed (a sender wrote it). When several stored messages match the same
candidate, a confirmed outbound ID wins over a claimed inbound one. Between two
claimed inbound IDs, the most recently created message wins. So a later message that
reuses a counterparty’s Message-ID can pull future replies to that ID into its own
thread. Once the provider-confirmed ID for one of your sends is recorded, it cannot be
displaced this way; inbound ones always can.
The consequence for an agent is narrow and important. Use thread_id to find context.
Do not use it as authorisation. Before the agent does something with consequences on
the strength of “this is a reply in the deal thread”, approving a payment or releasing
a document, compare the new message’s from.email against the counterparty you
recorded when you sent. A thread match plus the expected sender is evidence. A thread
match alone is a header someone typed.
Which identifier to persist for correlation
Persist the thread ID as the conversation key, the message ID of each outbound message you send, and your own business key. Do not try to persist the RFC Message-ID; you cannot read it, and no endpoint accepts it as input.
The send response gives you both platform IDs at once:
{
"result": "queued",
"message_id": "msg_...",
"thread_id": "thd_...",
"state": "queued",
"request_id": "req_..."
}
The same pair arrives on message.queued, and every inbound message.received
carries message_id, thread_id and inbox_id. A correlation row that survives the
cases below looks like this:
CREATE TABLE agent_conversations (
business_key text PRIMARY KEY, -- e.g. 'quote-8812'
inbox_id text NOT NULL, -- ibx_...
thread_id text, -- thd_..., null while awaiting approval
last_outbound_id text, -- msg_..., the parent for the next reply
counterparty text NOT NULL, -- lower-cased address you wrote to
idempotency_key text NOT NULL, -- what you sent as Idempotency-Key
draft_id text, -- drf_..., only on the approval path
last_seen_seq bigint NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX ON agent_conversations (inbox_id, thread_id);
Why each column earns its place:
thread_idplusinbox_id, because threads never span inboxes. The same conversation reaching two of your inboxes is two threads.last_outbound_id, because the reply parameter takes a message, not a thread.counterparty, for the sender check in the previous section.idempotency_key, because a retried send with the same key and body returns the samemessage_idandthread_idfor 30 days, so a crash between sending and writing this row is recoverable by replaying the call.last_seen_seq, because thread pages return messages insequenceorder and a watermark is cheaper than diffing.
You might expect to stash the business key in the send’s metadata and read it back
later. The send schema accepts metadata, but this build does not carry it onto the
message and no response returns it, so it is not a retrieval path. Keep the mapping on
your side.
The approval path has a correlation gap. If the inbox holds the send for review,
the response is approval_required with a draft_id and an approval_id, and no
message_id or thread_id. For a reply, that is fine: the eventual message joins the
parent’s thread, which you already have. For a brand-new conversation it is not. When
a human approves, the send is committed and message.queued fires carrying
message_id, thread_id and inbox_id, and nothing in that payload names the draft
it came from. approval.approved is declared in the event vocabulary but is not
emitted by this build. The least-bad approach is to treat message.queued events for
threads you do not know as candidates, read the first message of the thread, and bind
it to a pending draft only when recipient and subject match exactly one of them.
Anything else goes to a person.
Replying: reply_to_message_id takes a msg_ ID, never a header
There is one send endpoint for new mail and replies:
curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/messages" \
-H "Authorization: Bearer $EFA_API_KEY" \
-H "Idempotency-Key: quote-8812-followup-1" \
-H "Content-Type: application/json" \
-d '{
"to": [{"email":"buyer@example.com"}],
"subject": "Re: Quote 8812",
"text": "Confirming the revised delivery date of 14 October.",
"reply_to_message_id": "msg_..."
}'
reply_to_message_id is the platform ID of the message you are answering. It is the
only field that attaches an outbound message to an existing thread. Pass the RFC
Message-ID there and it will not be looked up; pass nothing and a new thd_ is
created however closely the subject matches.
You also cannot write the threading headers yourself. The send body is a strict schema
with to, cc, bcc, subject, text, html, attachment_ids, metadata and
reply_to_message_id. There is no headers field, and an unknown field is a 400. This
is the opposite of a sending API such as Resend, whose
reply guide tells you to
set In-Reply-To to the received message’s ID and append earlier IDs to References
yourself. Here the inbox computes both from the parent’s stored headers when the
message is dispatched; the exact rule is in
the threading guide.
Handing that job to the platform removes a class of bug that is easy to ship. On
2026-09-22 a reply sent through an open-source email MCP server went out with
References set to the parent’s internal record ID in angle brackets, because the
tool read a Message-ID field its API never returned and fell back to the record ID
(the fix, anima-labs-ai/mcp-server PR #66).
That header looks plausible and threads nothing. An API that never lets you type the
header cannot produce it.
One thing to guard in your own code. The request schema types reply_to_message_id
as a plain string, so a malformed value passes validation. It is decoded later, inside
the send transaction, and a value without the msg_ prefix and a valid UUID throws an
untyped error there. The API’s catch-all maps any untyped error to 503
temporarily_unavailable, and the error envelope marks that code retryable: true.
The transaction rolls back, so retrying with the same Idempotency-Key reruns the same
decode and gets the same 503. A retry loop that trusts the retryable flag will keep
retrying a request that can never succeed. Check the shape before you send:
const MSG_ID = /^msg_[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function assertReplyTarget(id: string) {
if (!MSG_ID.test(id)) {
throw new Error(`reply_to_message_id must be a msg_ ID, got ${JSON.stringify(id.slice(0, 40))}`);
}
}
A well-formed msg_ ID from another inbox, another project, or a deleted message
returns not_found, which is not retryable and is the correct signal. Replying also
needs messages:read alongside the sending scope.
Broken References chains are normal, so plan for them
Expect some share of genuine replies to arrive with no usable threading headers. The inbox cannot recover them, and neither can your code by looking harder at the headers.
On intake, the inbox keeps the first <...> token of In-Reply-To and the last 50 of
References, and discards any value that fails a strict <local@domain> check (the
rules are in the threading guide). What that means
for which thread a reply lands in:
| What arrives | Result |
|---|---|
In-Reply-To present, References stripped | Joins the thread; In-Reply-To is the first candidate |
In-Reply-To missing, References intact | Joins the thread; every reference is tried, newest first |
References truncated, one surviving entry is yours | Joins the thread; any single match is enough |
| Both headers missing or malformed | New thd_ |
| Headers reference a message sent from a different inbox | New thd_ in this inbox |
| Subject kept, headers gone | New thd_; the subject is never an input |
The second and third rows are where implementations differ. AgentMail’s own
threading guide, dated
6 August 2026, says it “uses In-Reply-To when the field is present and
checks the newest References value only when In-Reply-To is absent.” This inbox
walks the whole candidate list, so a reply whose newest reference points somewhere
unknown can still match an older one. That is more forgiving with mangled chains. It
is also why the trust point above matters: more candidates means more ways for a
message to join a thread.
Breakage travels in the other direction too. Your reply’s References is built from
the parent’s stored chain. If the counterparty’s client stripped References from the
message you are answering, the parent’s stored chain is empty and your reply carries a
single reference, the parent itself. If an inbound parent has no valid Message-ID at
all, your reply goes out with neither header. It still joins your thread, because
outbound grouping uses reply_to_message_id, but the recipient’s client may show it
as new. An outbound parent is handled differently: a live reply waits for the parent’s
confirmed Message-ID, retrying about once a minute, and after eight attempts fails with
message.failed and reason reply_parent_headers_not_available.
For your correlation layer, the rule is: an unknown thread_id on
message.received is an input to a resolver, not a new conversation. Park it,
look for exactly one open conversation in that inbox with the same counterparty, and
bind it only on a unique match. The full resolver, with the crash-safe write order, is
in resuming an agent workflow when a reply arrives.
Finding a conversation when all you have is text
Sometimes a human hands the agent a Message-ID copied from their own mail client and asks it to “reply to this one”. The inbox cannot look that up, because no endpoint filters by RFC Message-ID.
What you can do is search. Both listing endpoints accept q:
GET /v1/projects/{project_id}/inboxes/{inbox_id}/messages?q=quote%208812
GET /v1/projects/{project_id}/inboxes/{inbox_id}/threads?q=quote%208812
q matches stored subjects, previews and sender addresses and names, which makes it
a way to find a conversation, not a way to identify one. Once you have a candidate,
read it in order with
GET /v1/projects/{project_id}/inboxes/{inbox_id}/threads/{thread_id}/messages, which
returns messages by ascending sequence, 50 to a page by default. The full body of any
one message is at
GET /v1/projects/{project_id}/inboxes/{inbox_id}/messages/{message_id}/body. There
is no separate single-message metadata route; the metadata comes from the listings.
Query syntax and matching rules are in the search documentation.
Which identifier for which job
| Job | Use | Not this |
|---|---|---|
| Key a workflow to a conversation | thread_id (with inbox_id) | Subject, RFC Message-ID |
| Reply into a conversation | The parent’s msg_ ID | thread_id, RFC Message-ID |
| Read one message’s body | msg_ in .../messages/{id}/body | |
| Decide the agent may act on a reply | Thread match plus expected from | Thread match alone |
| Deduplicate event processing | Event id (evt_) | message_id, which repeats across events |
| Make a send safe to retry | Your Idempotency-Key | message_id, which you do not have yet |
| Join against a system that logged RFC Message-IDs | Not available in this build | |
| Ask support about one request | x-request-id / request_id |
Two rows reward a second look. message_id repeats across message.queued,
message.accepted and later delivery events for the same send, so deduplicating on it
drops real state changes; dedupe on the event ID, as covered in
handling webhook duplicates. And the RFC
Message-ID row is a concession, not a design recommendation. If cross-system header
correlation is a hard requirement, choose a provider that exposes the header.
Everything else follows from who minted the value. Platform IDs are minted by the inbox, scoped to it, and safe to store. The RFC Message-ID is minted by whoever sent the mail, and the inbox treats it as a claim, which is how your agent should treat the thread it produces. The request shapes behind all of this are on the developer surface, and the inbox model itself, one address per agent with its own threads, is described on the inboxes page.
Keep reading
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.
Resume an Agent Workflow When a Reply Arrives
How to resume an agent workflow when an email reply arrives: key on thread_id, dedupe message.received by event ID, and catch replies that break the thread.
Handle Email Webhooks Without Processing Events Twice
Verify the signature against the raw body, deduplicate by event ID, enqueue durably, and keep a webhook retry from re-sending the email that produced it.