# How to reply to an email thread

Source: https://developer.nylas.com/docs/cookbook/email/threads/reply-to-a-thread/

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](/docs/v3/agent-accounts/) — the endpoint is the same, only the grant changes.

## 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-ID`s 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](/docs/v3/agent-accounts/email-threading/) — same protocol, written from the agent perspective but applies to any send path.

## Before you begin

You'll need:

- A [Nylas application](/docs/v3/getting-started/) with a valid API key.
- A [grant](/docs/v3/auth/) 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.send` plus `gmail.readonly`. For Microsoft, `Mail.Send` plus `Mail.Read`.


> **Info:** 
> **New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.


## 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.

```bash
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'

```

```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",
            "email": "nyla@example.com"
          }
        ],
        "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": [
          {
            "email": "nyla@example.com"
          }
        ],
        "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": [
        {
          "email": "nylas@nylas.com"
        }
      ],
      "snippet": "Send Email with Nylas APIs",
      "subject": "Learn how to Send Email with Nylas APIs",
      "message_ids": ["<MESSAGE_ID>"]
    }
  ],
  "next_cursor": "123"
}


```

```js [listThreads-Node.js SDK]

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();


```

```python [listThreads-Python SDK]

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)

```


```ruby [listThreads-Ruby SDK]
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(', ')}"
}
```

```java [listThreads-Java SDK]
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());
    }
  }
}
```

```kt [listThreads-Kotlin SDK]
import com.nylas.NylasClient
import 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 `any_email=customer@example.com` 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=true` to show only threads with new messages, or use `received_after` / `received_before` (Unix epoch seconds) to constrain by date.

```bash
curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/threads?any_email=customer@example.com&unread=true&limit=25' \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
```

```js [filterPicker-Node.js SDK]
const threads = await nylas.threads.list({
  identifier: "<NYLAS_GRANT_ID>",
  queryParams: {
    anyEmail: ["customer@example.com"],
    unread: true,
    limit: 25,
  },
});
```

```python [filterPicker-Python SDK]
threads = nylas.threads.list(
    "<NYLAS_GRANT_ID>",
    query_params={
        "any_email": ["customer@example.com"],
        "unread": True,
        "limit": 25,
    },
)
```

```ruby [filterPicker-Ruby SDK]
require 'nylas'

nylas = Nylas::Client.new(api_key: '<NYLAS_API_KEY>')
query_params = {
  any_email: ['customer@example.com'],
  unread: true,
  limit: 25,
}
threads, _ = nylas.threads.list(identifier: '<NYLAS_GRANT_ID>', query_params: query_params)
```

```java [filterPicker-Java SDK]


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()
        .anyEmail(List.of("customer@example.com"))
        .unread(true)
        .limit(25)
        .build();

    ListResponse<Thread> threads = nylas.threads().list("<NYLAS_GRANT_ID>", queryParams);
  }
}
```

```kt [filterPicker-Kotlin SDK]


fun main() {
  val nylas = NylasClient(apiKey = "<NYLAS_API_KEY>")

  val queryParams = ListThreadsQueryParams(
    anyEmail = listOf("customer@example.com"),
    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](/docs/cookbook/email/threads/list-threads-google/) for the full operator list.

## 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:

```js
// From the thread you got in step 1
const 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 sender
```

If 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:

```bash
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'

```

```js [getThread-Node.js SDK]

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();


```

```python [getThread-Python SDK]

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)

```

```ruby [getThread-Ruby SDK]

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]}"
    )
}


```

```java [getThread-Java SDK]

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);
    }
}


```

```kt [getThread-Kotlin SDK]

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.

> **Info:** 
> **Why does the message ID matter and not just the thread ID?** Email threading at the protocol level uses `In-Reply-To` and `References` headers, which reference a specific message's `Message-ID`. When you pass `reply_to_message_id`, Nylas reads the original message's `Message-ID` and stamps the right headers on the outbound. That's what makes the reply nest correctly in every mail client — Gmail, Outlook, Apple Mail, Thunderbird. See [Email threading for agents](/docs/v3/agent-accounts/email-threading/) for the full mechanics.

## 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.

```bash
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": "customer@example.com", "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>"
  }'
```

```js [sendThreadedReply-Node.js SDK]


const nylas = new Nylas({ apiKey: "<NYLAS_API_KEY>" });

const sent = await nylas.messages.send({
  identifier: "<NYLAS_GRANT_ID>",
  requestBody: {
    replyToMessageId: "<MESSAGE_ID>",
    to: [{ email: "customer@example.com", 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>",
  },
});
```

```python [sendThreadedReply-Python SDK]
from nylas import Client

nylas = Client("<NYLAS_API_KEY>")

sent = nylas.messages.send(
    "<NYLAS_GRANT_ID>",
    request_body={
        "reply_to_message_id": "<MESSAGE_ID>",
        "to": [{"email": "customer@example.com", "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>",
    },
)
```

```ruby [sendThreadedReply-Ruby SDK]
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>',
    to: [{ email: 'customer@example.com', 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>',
  }
)
```

```java [sendThreadedReply-Java SDK]


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<>();
    to.add(new EmailName("customer@example.com", "Alex Customer"));

    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());
  }
}
```

```kt [sendThreadedReply-Kotlin SDK]


fun main() {
  val nylas = NylasClient(apiKey = "<NYLAS_API_KEY>")

  val to = listOf(EmailName("customer@example.com", "Alex Customer"))

  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 with `Re:` if it doesn't already start with one.
- **Recipients.** Nylas does not auto-fill `to` either. Use `original_message.from` for a normal reply, or build the recipient list yourself for a reply-all that includes the original `to` and `cc`.
- **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)

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`.

```bash
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": "customer@example.com" }],
    "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>"
    }]
  }'
```

```js [replyWithAttachment-Node.js SDK]


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>",
    to: [{ email: "customer@example.com" }],
    subject: "Re: Q3 proposal — next steps",
    body: "<p>Signed proposal attached.</p>",
    attachments: [
      {
        filename: "proposal-signed.pdf",
        contentType: "application/pdf",
        content: fileBytes.toString("base64"),
      },
    ],
  },
});
```

```python [replyWithAttachment-Python SDK]
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>",
        "to": [{"email": "customer@example.com"}],
        "subject": "Re: Q3 proposal — next steps",
        "body": "<p>Signed proposal attached.</p>",
        "attachments": [attachment],
    },
)
```

```ruby [replyWithAttachment-Ruby SDK]
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>',
    to: [{ email: 'customer@example.com' }],
    subject: 'Re: Q3 proposal — next steps',
    body: '<p>Signed proposal attached.</p>',
    attachments: [attachment],
  }
)
```

```java [replyWithAttachment-Java SDK]


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<>();
    to.add(new EmailName("customer@example.com", null));

    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());
  }
}
```

```kt [replyWithAttachment-Kotlin SDK]


fun main() {
  val nylas = NylasClient(apiKey = "<NYLAS_API_KEY>")

  val attachment = FileUtils.attachFileRequestBuilder("proposal-signed.pdf")

  val to = listOf(EmailName("customer@example.com", null))

  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](/docs/cookbook/email/attachments/send-attachments/) walks through the multipart pattern in detail.

## 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](/docs/v3/agent-accounts/) 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_id` and `message_id` from when it sent the original outbound. For inbound conversations the agent is responding to, the `message.created` webhook payload includes both.
- **Sending fires `message.created` for the outbound too.** If you have a webhook handler watching the agent mailbox, filter on `msg.from` so the agent doesn't trigger itself. See [Handle email replies in an agent loop](/docs/cookbook/agent-accounts/handle-replies/).
- **Thread state mapping lives in your app.** Agent Accounts hold conversation context across long-running threads using a mapping from `thread_id` to internal state — a task, a workflow step, a session. See [Email threading for agents](/docs/v3/agent-accounts/email-threading/) for the durable pattern.

## 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_id` is the right answer.
- **Use the message's `from` for the reply destination, not the thread's `participants`.** The thread's `participants` array 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_date` in 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

- [Using the Threads API](/docs/v3/email/threads/) — the full Threads endpoint reference, including the non-linear thread structure
- [Email threading for agents](/docs/v3/agent-accounts/email-threading/) — how `Message-ID`, `In-Reply-To`, and `References` headers work
- [Send messages with attachments](/docs/cookbook/email/attachments/send-attachments/) — adding files to outbound messages
- [Handle email replies in an agent loop](/docs/cookbook/agent-accounts/handle-replies/) — the inverse flow: detecting and routing inbound replies
- [Messages API reference](/docs/reference/api/messages/) — full reference for `/messages/send`