Threading replies correctly looks trivial — until you do it wrong and your message lands as a brand-new email in the recipient’s inbox instead of nesting under the original conversation. This recipe shows how to send a reply that threads in every major mail client, what reply_to_message_id actually does, and how to pick the right message to reply to when a thread has more than one.
The same flow works whether the reply is composed by a human in your UI, generated by an LLM, or pushed in from another part of your platform (a signed contract, a deal update, a templated follow-up). It also works for Agent Accounts — the endpoint is the same, only the grant changes.
How threading actually works (and why subject matching breaks)
Section titled “How threading actually works (and why subject matching breaks)”Email clients group messages into conversations using two headers: In-Reply-To (the Message-ID of the message being replied to) and References (the full chain of Message-IDs in the conversation). When those headers are missing, mail clients fall back to subject-line matching, but that’s unreliable — recipients edit subjects, forwarded messages reuse them, and two unrelated conversations can share the same subject.
You don’t manage those headers yourself. When you call /messages/send with reply_to_message_id, Nylas reads the original message, populates In-Reply-To and References on the outbound, and the reply nests correctly in the recipient’s mail client. The deeper mechanics are in Email threading for agents — same protocol, written from the agent perspective but applies to any send path.
Before you begin
Section titled “Before you begin”You’ll need:
- A Nylas application with a valid API key.
- A grant for the user whose mailbox the reply is being sent from. For most products this is the end user’s connected Google, Microsoft, or IMAP account.
- Scopes that include both reading messages and sending. For Google that’s typically
gmail.sendplusgmail.readonly. For Microsoft,Mail.SendplusMail.Read.
Step 1: List threads for the user to pick from
Section titled “Step 1: List threads for the user to pick from”Fetch the user’s recent threads so your UI can render a picker. The response includes latest_draft_or_message, so you can show a sender, subject, snippet, and timestamp without a separate Messages call.
curl --compressed --request GET \ --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/threads?limit=5" \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --header 'Content-Type: application/json'{ "request_id": "1", "data": [ { "starred": false, "unread": true, "folders": ["CATEGORY_PERSONAL", "INBOX", "UNREAD"], "grant_id": "<NYLAS_GRANT_ID>", "id": "<THREAD_ID>", "object": "thread", "latest_draft_or_message": { "starred": false, "unread": true, "folders": ["UNREAD", "CATEGORY_PERSONAL", "INBOX"], "grant_id": "<NYLAS_GRANT_ID>", "date": 1707836711, "from": [ { "name": "Nyla", } ], "id": "<MESSAGE_ID>", "object": "message", "snippet": "Send Email with Nylas APIs", "subject": "Learn how to Send Email with Nylas APIs", "thread_id": "<THREAD_ID>", "to": [ { } ], "created_at": 1707836711, "body": "Learn how to send emails using the Nylas APIs!" }, "has_attachments": false, "has_drafts": false, "earliest_message_date": 1707836711, "latest_message_received_date": 1707836711, "participants": [ { } ], "snippet": "Send Email with Nylas APIs", "subject": "Learn how to Send Email with Nylas APIs", "message_ids": ["<MESSAGE_ID>"] } ], "next_cursor": "123"}import Nylas from "nylas";
const nylas = new Nylas({ apiKey: "<NYLAS_API_KEY>", apiUri: "<NYLAS_API_URI>",});
async function fetchRecentThreads() { try { const identifier = "<NYLAS_GRANT_ID>"; const threads = await nylas.threads.list({ identifier: identifier, queryParams: { limit: 5, }, });
console.log("Recent Threads:", threads); } catch (error) { console.error("Error fetching threads:", error); }}
fetchRecentThreads();from nylas import Client
nylas = Client( "<NYLAS_API_KEY>", "<NYLAS_API_URI>")
grant_id = "<NYLAS_GRANT_ID>"
threads = nylas.threads.list( grant_id, query_params={ "limit": 5 })
print(threads)require 'nylas'
nylas = Nylas::Client.new(api_key: '<NYLAS_API_KEY>')query_params = { limit: 5 }threads, _ = nylas.threads.list(identifier: '<NYLAS_GRANT_ID>', query_params: query_params)
threads.each {|thread| puts "#{thread[:subject]} | Participants: #{thread[:participants].map { |p| p[:email] }.join(', ')}"}import com.nylas.NylasClient;import com.nylas.models.*;import com.nylas.models.Thread;import java.util.List;
public class ListThreads { public static void main(String[] args) throws NylasSdkTimeoutError, NylasApiError { NylasClient nylas = new NylasClient.Builder("<NYLAS_API_KEY>").build(); ListThreadsQueryParams queryParams = new ListThreadsQueryParams.Builder().limit(5).build(); ListResponse<Thread> threads = nylas.threads().list("<NYLAS_GRANT_ID>", queryParams);
for(Thread thread : threads.getData()) { System.out.println(thread.getSubject()); } }}import com.nylas.NylasClientimport com.nylas.models.*
fun main(args: Array<String>) { val nylas = NylasClient(apiKey = "<NYLAS_API_KEY>") val queryParams = ListThreadsQueryParams(limit = 5) val threads = nylas.threads().list("<NYLAS_GRANT_ID>", queryParams).data
for (thread in threads) { println(thread.subject) }}Each thread carries id, subject, participants, snippet, latest_message_received_date, and latest_draft_or_message — enough to render a useful picker without any further calls.
For a more focused picker, filter to threads the user actually corresponds with. Two common refinements:
- Filter by participant. Pass
[email protected]to scope to threads with a specific person. Handy when your product already knows which contact the user is working with. - Restrict to unread or recent. Pass
unread=trueto show only threads with new messages, or usereceived_after/received_before(Unix epoch seconds) to constrain by date.
curl --request GET \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/[email protected]&unread=true&limit=25' \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>'const threads = await nylas.threads.list({ identifier: "<NYLAS_GRANT_ID>", queryParams: { unread: true, limit: 25, },});threads = nylas.threads.list( "<NYLAS_GRANT_ID>", query_params={ "unread": True, "limit": 25, },)require 'nylas'
nylas = Nylas::Client.new(api_key: '<NYLAS_API_KEY>')query_params = { unread: true, limit: 25,}threads, _ = nylas.threads.list(identifier: '<NYLAS_GRANT_ID>', query_params: query_params)import com.nylas.NylasClient;import com.nylas.models.*;import com.nylas.models.Thread;import java.util.List;
public class FilterThreads { public static void main(String[] args) throws NylasSdkTimeoutError, NylasApiError { NylasClient nylas = new NylasClient.Builder("<NYLAS_API_KEY>").build();
ListThreadsQueryParams queryParams = new ListThreadsQueryParams.Builder() .unread(true) .limit(25) .build();
ListResponse<Thread> threads = nylas.threads().list("<NYLAS_GRANT_ID>", queryParams); }}import com.nylas.NylasClientimport com.nylas.models.*
fun main() { val nylas = NylasClient(apiKey = "<NYLAS_API_KEY>")
val queryParams = ListThreadsQueryParams( unread = true, limit = 25, )
val threads: List<Thread> = nylas.threads().list("<NYLAS_GRANT_ID>", queryParams).data}For Google accounts you can also use Gmail search operators through search_query_native — see the Gmail threads recipe for the full operator list.
Step 2: Pick the message to reply to
Section titled “Step 2: Pick the message to reply to”A thread is a non-linear collection of messages. When the user picks a thread in your UI, your code needs to pick a specific message to reply to — that’s what creates the threading link, not the thread itself.
Most of the time you want the most recent message. The thread response gives you that directly:
// From the thread you got in step 1const threadId = thread.id;const replyToMessageId = thread.latest_draft_or_message.id;const subject = thread.latest_draft_or_message.subject;const to = thread.latest_draft_or_message.from; // reply goes back to the senderIf you need a specific earlier message — for example, replying to the message that contained the original request — pull the thread’s message_ids array and let the user pick:
curl --compressed --request GET \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/threads/<THREAD_ID>' \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --header 'Content-Type: application/json'import Nylas from "nylas";
const nylas = new Nylas({ apiKey: "<NYLAS_API_KEY>", apiUri: "<NYLAS_API_URI>",});
async function fetchThreadById() { try { const thread = await nylas.threads.find({ identifier: "<NYLAS_GRANT_ID>", threadId: "<THREAD_ID>", });
console.log("Thread:", thread); } catch (error) { console.error("Error fetching thread:", error); }}
fetchThreadById();from nylas import Client
nylas = Client( "<NYLAS_API_KEY>", "<NYLAS_API_URI>")
grant_id = "<NYLAS_GRANT_ID>"thread_id = "<THREAD_ID>"
thread = nylas.threads.find( grant_id, thread_id,)
print(thread)require 'nylas'
nylas = Nylas::Client.new( api_key: "<NYLAS_API_KEY>")
thread, _ = nylas.threads.find(identifier: "<NYLAS_GRANT_ID>", thread_id: "<THREAD_ID>")
participants = thread[:participants]
participants.each{ |participant| puts("Id: #{thread[:id]} | "\ "Subject: #{thread[:subject]} | "\ "Participant: #{participant[:name]} | "\ "Email: #{participant[:email]}" )}import com.nylas.NylasClient;import com.nylas.models.*;import com.nylas.models.Thread;
public class ReturnThread { public static void main(String[] args) throws NylasSdkTimeoutError, NylasApiError {
NylasClient nylas = new NylasClient.Builder("<NYLAS_API_KEY>").build();
Response<Thread> thread = nylas.threads().find("<NYLAS_GRANT_ID>", "<THREAD_ID>"); System.out.println(thread); }}import com.nylas.NylasClient
fun main(args: Array<String>) { val nylas: NylasClient = NylasClient( apiKey = "<NYLAS_API_KEY>" )
val thread = nylas.threads().find("<NYLAS_GRANT_ID>", "<THREAD_ID>").data
print(thread)}Then fetch any specific message by its ID to show the user what they’d be replying to.
Step 3: Send the reply
Section titled “Step 3: Send the reply”Send the message with reply_to_message_id set to the ID you picked in step 2. Nylas adds In-Reply-To and References headers automatically, and the reply threads correctly in the recipient’s mail client.
curl --request POST \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_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]", "name": "Alex Customer" }], "subject": "Re: Q3 proposal — next steps", "body": "<p>Hi Alex,</p><p>Thanks for getting back to me. Let me know if anything else looks off.</p>" }'import Nylas from "nylas";
const nylas = new Nylas({ apiKey: "<NYLAS_API_KEY>" });
const sent = await nylas.messages.send({ identifier: "<NYLAS_GRANT_ID>", requestBody: { replyToMessageId: "<MESSAGE_ID>", subject: "Re: Q3 proposal — next steps", body: "<p>Hi Alex,</p><p>Thanks for getting back to me. Let me know if anything else looks off.</p>", },});from nylas import Client
nylas = Client("<NYLAS_API_KEY>")
sent = nylas.messages.send( "<NYLAS_GRANT_ID>", request_body={ "reply_to_message_id": "<MESSAGE_ID>", "subject": "Re: Q3 proposal — next steps", "body": "<p>Hi Alex,</p><p>Thanks for getting back to me. Let me know if anything else looks off.</p>", },)require 'nylas'
nylas = Nylas::Client.new(api_key: '<NYLAS_API_KEY>')
sent, _ = nylas.messages.send( identifier: '<NYLAS_GRANT_ID>', request_body: { reply_to_message_id: '<MESSAGE_ID>', subject: 'Re: Q3 proposal — next steps', body: '<p>Hi Alex,</p><p>Thanks for getting back to me. Let me know if anything else looks off.</p>', })import com.nylas.NylasClient;import com.nylas.models.*;import java.util.ArrayList;import java.util.List;
public class SendThreadedReply { public static void main(String[] args) throws NylasSdkTimeoutError, NylasApiError { NylasClient nylas = new NylasClient.Builder("<NYLAS_API_KEY>").build();
List<EmailName> to = new ArrayList<>();
SendMessageRequest requestBody = new SendMessageRequest.Builder(to) .replyToMessageId("<MESSAGE_ID>") .subject("Re: Q3 proposal — next steps") .body("<p>Hi Alex,</p><p>Thanks for getting back to me. Let me know if anything else looks off.</p>") .build();
Response<Message> sent = nylas.messages().send("<NYLAS_GRANT_ID>", requestBody); System.out.println(sent.getData()); }}import com.nylas.NylasClientimport com.nylas.models.*
fun main() { val nylas = NylasClient(apiKey = "<NYLAS_API_KEY>")
val requestBody = SendMessageRequest.Builder(to) .replyToMessageId("<MESSAGE_ID>") .subject("Re: Q3 proposal — next steps") .body("<p>Hi Alex,</p><p>Thanks for getting back to me. Let me know if anything else looks off.</p>") .build()
val sent = nylas.messages().send("<NYLAS_GRANT_ID>", requestBody) println(sent.data)}A few details worth knowing:
- Subject convention. Nylas does not auto-prefix
Re:. Set the subject yourself. Most products either reuse the original subject verbatim or prefix it withRe:if it doesn’t already start with one. - Recipients. Nylas does not auto-fill
toeither. Useoriginal_message.fromfor a normal reply, or build the recipient list yourself for a reply-all that includes the originaltoandcc. - The sent message lands in the same thread on the sender’s side too. It shows up in the user’s Sent folder and is grouped into the thread when they open it. No extra work needed for that.
Step 4: Attach files to the reply (optional)
Section titled “Step 4: Attach files to the reply (optional)”To attach a file to the reply — a signed contract, a generated PDF, a CSV export — add an attachments array to the send request. The Nylas SDKs ship a FileUtils / file_utils helper that builds the attachment object from a file path; for raw bytes (a PDF generated in memory, for example) you can build the attachment object directly with filename, content_type, and base64 content.
curl --request POST \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_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: Q3 proposal — next steps", "body": "<p>Signed proposal attached.</p>", "attachments": [{ "filename": "proposal-signed.pdf", "content_type": "application/pdf", "content": "<BASE64_ENCODED_PDF>" }] }'import fs from "fs";import Nylas from "nylas";
const nylas = new Nylas({ apiKey: "<NYLAS_API_KEY>" });
const fileBytes = fs.readFileSync("./proposal-signed.pdf");
const sent = await nylas.messages.send({ identifier: "<NYLAS_GRANT_ID>", requestBody: { replyToMessageId: "<MESSAGE_ID>", subject: "Re: Q3 proposal — next steps", body: "<p>Signed proposal attached.</p>", attachments: [ { filename: "proposal-signed.pdf", contentType: "application/pdf", content: fileBytes.toString("base64"), }, ], },});from nylas import Client, utils
nylas = Client("<NYLAS_API_KEY>")
attachment = utils.file_utils.attach_file_request_builder("proposal-signed.pdf")
sent = nylas.messages.send( "<NYLAS_GRANT_ID>", request_body={ "reply_to_message_id": "<MESSAGE_ID>", "subject": "Re: Q3 proposal — next steps", "body": "<p>Signed proposal attached.</p>", "attachments": [attachment], },)require 'nylas'
nylas = Nylas::Client.new(api_key: '<NYLAS_API_KEY>')
attachment = Nylas::FileUtils.attach_file_request_builder('proposal-signed.pdf')
sent, _ = nylas.messages.send( identifier: '<NYLAS_GRANT_ID>', request_body: { reply_to_message_id: '<MESSAGE_ID>', subject: 'Re: Q3 proposal — next steps', body: '<p>Signed proposal attached.</p>', attachments: [attachment], })import com.nylas.NylasClient;import com.nylas.models.*;import com.nylas.util.FileUtils;import java.util.ArrayList;import java.util.List;
public class ReplyWithAttachment { public static void main(String[] args) throws NylasSdkTimeoutError, NylasApiError { NylasClient nylas = new NylasClient.Builder("<NYLAS_API_KEY>").build();
CreateAttachmentRequest attachment = FileUtils.attachFileRequestBuilder("proposal-signed.pdf"); List<CreateAttachmentRequest> attachments = new ArrayList<>(); attachments.add(attachment);
List<EmailName> to = new ArrayList<>();
SendMessageRequest requestBody = new SendMessageRequest.Builder(to) .replyToMessageId("<MESSAGE_ID>") .subject("Re: Q3 proposal — next steps") .body("<p>Signed proposal attached.</p>") .attachments(attachments) .build();
Response<Message> sent = nylas.messages().send("<NYLAS_GRANT_ID>", requestBody); System.out.println(sent.getData()); }}import com.nylas.NylasClientimport com.nylas.models.*import com.nylas.util.FileUtils
fun main() { val nylas = NylasClient(apiKey = "<NYLAS_API_KEY>")
val attachment = FileUtils.attachFileRequestBuilder("proposal-signed.pdf")
val requestBody = SendMessageRequest.Builder(to) .replyToMessageId("<MESSAGE_ID>") .subject("Re: Q3 proposal — next steps") .body("<p>Signed proposal attached.</p>") .attachments(listOf(attachment)) .build()
val sent = nylas.messages().send("<NYLAS_GRANT_ID>", requestBody) println(sent.data)}For files larger than 3MB and up to 25MB, switch to multipart/form-data. The send attachments recipe walks through the multipart pattern in detail.
The Agent Accounts variant
Section titled “The Agent Accounts variant”When the mailbox is owned by your platform — an automated assistant, an AI agent, a system-of-record inbox — swap the user’s grant ID for an Agent Account grant. The send endpoint and reply_to_message_id work the same way.
The differences sit on either side of the send call:
- The agent needs the message ID from somewhere. For a thread the agent started, it already has the
thread_idandmessage_idfrom when it sent the original outbound. For inbound conversations the agent is responding to, themessage.createdwebhook payload includes both. - Sending fires
message.createdfor the outbound too. If you have a webhook handler watching the agent mailbox, filter onmsg.fromso the agent doesn’t trigger itself. See Handle email replies in an agent loop. - Thread state mapping lives in your app. Agent Accounts hold conversation context across long-running threads using a mapping from
thread_idto internal state — a task, a workflow step, a session. See Email threading for agents for the durable pattern.
Things to watch for
Section titled “Things to watch for”- Don’t fall back to subject-line matching. Some integrations match conversations by subject because it “usually works.” It doesn’t, reliably — recipients edit subjects, multiple threads share subjects, and forwarded messages keep the subject but change the conversation.
reply_to_message_idis the right answer. - Use the message’s
fromfor the reply destination, not the thread’sparticipants. The thread’sparticipantsarray is the union of everyone in the conversation. If you reply to the participants list directly, you’ll end up sending the reply to yourself among others. Pull the recipient list from the specific message you’re replying to. - Check that the thread is still active. A user might pick a six-month-old thread from the picker. Surface the
latest_message_received_datein the UI so it’s clear when the conversation last moved. - One outbound, one reply. Don’t send the same payload twice if the user clicks “Send” twice — debounce on the client and dedupe by
reply_to_message_id+ content hash on the server. Email is fire-and-forget on the wire, and the recipient will see both copies. - The reply shows up in your mailbox too. The message you sent and any future replies to it land in the same thread. If your product surfaces the thread again later, the reply is already there.
What’s next
Section titled “What’s next”- Using the Threads API — the full Threads endpoint reference, including the non-linear thread structure
- Email threading for agents — how
Message-ID,In-Reply-To, andReferencesheaders work - Send messages with attachments — adding files to outbound messages
- Handle email replies in an agent loop — the inverse flow: detecting and routing inbound replies
- Messages API reference — full reference for
/messages/send