When an agent sends an email and gets a reply three hours later, it needs to know which conversation the reply belongs to, what the agent last said, and what to do next. That context lives in a handful of email headers that most developers have never had to think about, and in the Nylas Threads API that groups messages into conversations.
This page explains how threading works at the protocol level, how Nylas preserves it across every send path, and how your agent should use it.
The three headers that make threading work
Section titled “The three headers that make threading work”Every email carries a Message-ID header — a globally unique identifier the sending server stamps on the message when it leaves. When someone replies, their mail client adds two more headers that point back to the original:
In-Reply-Tocontains theMessage-IDof the message being replied to directly.Referencescontains the full chain ofMessage-IDvalues from the conversation, oldest to newest.
These three headers are how every mail client in the world — Gmail, Outlook, Apple Mail, Thunderbird — decides which messages belong to the same thread. Subject-line matching is a fallback, not the primary mechanism.
Here’s what the headers look like in practice:
# The agent's outbound messageMessage-ID: <[email protected]>Subject: Following up on your demo request
# The recipient's replyMessage-ID: <[email protected]>In-Reply-To: <[email protected]>References: <[email protected]>Subject: Re: Following up on your demo request
# The agent's follow-upMessage-ID: <[email protected]>In-Reply-To: <[email protected]>References: <[email protected]> <[email protected]>Subject: Re: Following up on your demo requestThe References chain grows with every exchange. By the time a thread is five messages deep, the header contains five Message-ID values in order — a complete audit trail of the conversation.
Why subject-line matching breaks
Section titled “Why subject-line matching breaks”Some agent implementations match replies by subject line: if the subject starts with Re: and contains the original subject, it must be a reply. This works until it doesn’t:
- Recipients edit the subject. A reply to “Q3 budget review” might come back as “Re: Q3 budget review — updated numbers attached”.
- Multiple threads share a subject. Two different prospects both receive “Following up on your demo request”. A reply to either one matches both.
- Forwarded messages. A recipient forwards the thread to a colleague, who replies. The subject might stay the same but the conversation context is completely different.
The In-Reply-To and References headers don’t have these problems because they reference specific Message-ID values, not human-readable text. Always match on headers first, and fall back to subject only as a last resort when headers are missing (which is rare — it only happens with very old or broken mail clients).
How Nylas preserves threading
Section titled “How Nylas preserves threading”Nylas handles threading consistently regardless of how the message is sent:
API sends (POST /v3/grants/{grant_id}/messages/send): When you pass reply_to_message_id, Nylas fetches the original message’s Message-ID and populates In-Reply-To and References on the outbound message automatically. The reply threads correctly in every recipient’s mail client.
SMTP submission (port 465 or 587): If you send through a standard mail client connected over SMTP, Nylas preserves the original Message-ID, In-Reply-To, and References headers exactly as the client set them.
Inbound messages: When a reply arrives at the Agent Account, Nylas stores the full headers. You can access them via fields=include_headers on the message endpoint, or rely on the Threads API to do the grouping for you.
In all three cases, the threading chain stays intact. Your agent doesn’t need to manage Message-ID generation or header manipulation — Nylas does it.
The Threads API
Section titled “The Threads API”Instead of parsing In-Reply-To and References headers yourself, use the Threads API to get the full conversation in one call.
curl --request GET \ --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/threads?limit=10" \ --header "Authorization: Bearer <NYLAS_API_KEY>"Each thread object groups every message in the conversation and gives you:
message_ids— ordered list of every message in the threadparticipants— everyone who’s been part of the conversationlatest_message_received_dateandlatest_message_sent_date— when the conversation was last activesnippet— preview text from the most recent messagesubject,unread,starred,folders— metadata your agent can use for routing
When a reply arrives and fires message.created, the webhook payload includes thread_id. Fetch that thread to get the full conversation history before the agent decides how to respond.
// After receiving a message.created webhook:const thread = await nylas.threads.find({ identifier: AGENT_GRANT_ID, threadId: message.thread_id,});
// thread.data.message_ids has the full conversation chain.// Fetch each message to reconstruct what was said.const messages = await Promise.all( thread.data.messageIds.map((id) => nylas.messages.find({ identifier: AGENT_GRANT_ID, messageId: id, }), ),);Mapping threads to agent state
Section titled “Mapping threads to agent state”The Threads API tells your agent which messages belong together. But the agent also needs to know what it was doing when the conversation started — which task, which workflow step, which session. That mapping lives in your application, not in email.
The reliable pattern is:
-
On outbound: when the agent sends a message, store the Nylas
message_idandthread_idmapped to whatever internal state the agent needs — a session ID, a task record, a CRM deal, a support ticket. -
On inbound: when
message.createdfires, look up thethread_idin your mapping. If it exists, you know the conversation context. If it doesn’t, it’s a new inbound conversation the agent hasn’t seen before.
// Simplified state mappingconst threadState = new Map(); // thread_id -> { sessionId, taskId, step, ... }
// After sending:threadState.set(sentMessage.threadId, { sessionId: currentSession.id, taskId: currentTask.id, step: "awaiting_reply", sentAt: Date.now(),});
// On webhook:const context = threadState.get(inboundMessage.threadId);if (context) { // This is a reply to something the agent sent. // Restore context and continue the workflow. await resumeTask(context.taskId, inboundMessage);} else { // New conversation -- classify and route. await triageNewMessage(inboundMessage);}In production, this map should be in a database or a durable store, not in-memory. Email conversations span hours and days — an in-memory map doesn’t survive process restarts.
Replying in-thread
Section titled “Replying in-thread”To send a reply that threads correctly, pass reply_to_message_id with the ID of the message you’re replying to:
curl --request POST \ --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \ --header "Authorization: Bearer <NYLAS_API_KEY>" \ --header "Content-Type: application/json" \ --data '{ "reply_to_message_id": "<MESSAGE_ID>", "to": [{ "email": "[email protected]" }], "subject": "Re: Following up on your demo request", "body": "Thanks for getting back to me, Alice. Here are the next steps..." }'Nylas sets In-Reply-To and References on the outbound message so it threads correctly in the recipient’s client. The reply also appears in the same thread in the Agent Account’s mailbox.
Keep in mind
Section titled “Keep in mind”thread_idis the primary key for conversation context. It’s more stable thanMessage-IDheaders for your application logic because Nylas assigns it and it covers the whole conversation, not just one message.- Don’t assume one reply per outbound. A prospect might reply twice, or two different people in a thread might both respond. Your agent should handle multiple inbound messages on the same thread without sending duplicate replies.
- Threads can go dormant and come back. Someone might reply to a three-week-old thread. If your state mapping has a TTL, decide what the agent should do when context has expired — re-read the thread history, escalate to a human, or start fresh.
- Raw headers are there when you need them. Pass
fields=include_headersto any message GET request to see the fullMessage-ID,In-Reply-To, andReferencesvalues. This is useful for debugging threading issues or for cross-referencing with external systems that track byMessage-ID. - Threads work across send paths. If the agent sends via the API and a human later replies via IMAP, everything stays in the same thread. The Threads API groups messages by their header chain, not by how they were sent.
What’s next
Section titled “What’s next”- Handle email replies in an agent loop — webhook-driven recipe for detecting and responding to replies
- Build a multi-turn email conversation — full send-receive-respond loop with state management
- Threads API — the endpoint reference for listing and fetching threads
- Headers and MIME data — accessing raw email headers on any message
- Supported endpoints for Agent Accounts — full reference of what works with Agent Account grants