# Email threading for agents

Source: https://developer.nylas.com/docs/v3/agent-accounts/email-threading/

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

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-To`** contains the `Message-ID` of the message being replied to directly.
- **`References`** contains the full chain of `Message-ID` values 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 message
Message-ID: <abc123@agents.yourcompany.com>
Subject: Following up on your demo request

# The recipient's reply
Message-ID: <def456@gmail.com>
In-Reply-To: <abc123@agents.yourcompany.com>
References: <abc123@agents.yourcompany.com>
Subject: Re: Following up on your demo request

# The agent's follow-up
Message-ID: <ghi789@agents.yourcompany.com>
In-Reply-To: <def456@gmail.com>
References: <abc123@agents.yourcompany.com> <def456@gmail.com>
Subject: Re: Following up on your demo request
```

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

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

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 (full set) or `fields=include_basic_headers` (just `Message-ID`, `In-Reply-To`, `References` -- much smaller payload), 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

Instead of parsing `In-Reply-To` and `References` headers yourself, use the [Threads API](/docs/v3/email/threads/) to get the full conversation in one call.

```bash
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 thread
- `participants` -- everyone who's been part of the conversation
- `latest_message_received_date` and `latest_message_sent_date` -- when the conversation was last active
- `snippet` -- preview text from the most recent message
- `subject`, `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.

```js
// 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

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:

1. **On outbound**: when the agent sends a message, store the Nylas `message_id` and `thread_id` mapped to whatever internal state the agent needs -- a session ID, a task record, a CRM deal, a support ticket.

2. **On inbound**: when `message.created` fires, look up the `thread_id` in 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.

```js
// Simplified state mapping
const 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

To send a reply that threads correctly, pass `reply_to_message_id` with the ID of the message you're replying to:

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

- **`thread_id` is the primary key for conversation context.** It's more stable than `Message-ID` headers 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_headers` to any message GET request to see the full header set, or `fields=include_basic_headers` to get just `Message-ID`, `In-Reply-To`, and `References`. The basic option is the right choice when you only need threading data -- it skips the full header payload, which is often larger than the message body itself.
- **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

- [Handle email replies in an agent loop](/docs/cookbook/agent-accounts/handle-replies/) -- webhook-driven recipe for detecting and responding to replies
- [Build a multi-turn email conversation](/docs/cookbook/agent-accounts/multi-turn-conversations/) -- full send-receive-respond loop with state management
- [Threads API](/docs/v3/email/threads/) -- the endpoint reference for listing and fetching threads
- [Headers and MIME data](/docs/v3/email/headers-mime-data/) -- accessing raw email headers on any message
- [Supported endpoints for Agent Accounts](/docs/v3/agent-accounts/supported-endpoints/) -- full reference of what works with Agent Account grants