Skip to content
Skip to main content

Email threading for agents

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-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: <[email protected]>
Subject: Following up on your demo request
# The recipient's reply
Message-ID: <[email protected]>
In-Reply-To: <[email protected]>
References: <[email protected]>
Subject: Re: Following up on your demo request
# The agent's follow-up
Message-ID: <[email protected]>
In-Reply-To: <[email protected]>
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.

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

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.

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

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

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.

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

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.

  • 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 Message-ID, In-Reply-To, and References values. This is useful for debugging threading issues or for cross-referencing with external systems that track by Message-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.