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.

IdentifierFormatMinted byScopeReturned by the REST API
Platform thread IDthd_ + UUIDThis inboxOne inboxYes, on every message and send
Platform message IDmsg_ + UUIDThis inboxOne inboxYes, as id and message_id
RFC 5322 Message-ID<local@domain>Whichever system sent the mailNominally globalNo

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:

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 arrivesResult
In-Reply-To present, References strippedJoins the thread; In-Reply-To is the first candidate
In-Reply-To missing, References intactJoins the thread; every reference is tried, newest first
References truncated, one surviving entry is yoursJoins the thread; any single match is enough
Both headers missing or malformedNew thd_
Headers reference a message sent from a different inboxNew thd_ in this inbox
Subject kept, headers goneNew 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

JobUseNot this
Key a workflow to a conversationthread_id (with inbox_id)Subject, RFC Message-ID
Reply into a conversationThe parent’s msg_ IDthread_id, RFC Message-ID
Read one message’s bodymsg_ in .../messages/{id}/body
Decide the agent may act on a replyThread match plus expected fromThread match alone
Deduplicate event processingEvent id (evt_)message_id, which repeats across events
Make a send safe to retryYour Idempotency-Keymessage_id, which you do not have yet
Join against a system that logged RFC Message-IDsNot available in this build
Ask support about one requestx-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

All guides · Documentation · Inboxes