The agent sends an email. Hours later, the recipient replies. If the agent doesn’t connect that reply to the original conversation, it either ignores it or treats it as a brand-new message — both wrong.
This recipe detects replies via webhook, pulls the thread context, and routes the reply into the right part of your agent’s logic. It assumes you have an Agent Account with a message.created webhook already subscribed. If not, start with Give your agent its own email.
Detect the reply
Section titled “Detect the reply”Every message.created webhook payload includes a thread_id. If your agent sent the original outbound message, that thread already exists in your state. The check is straightforward: look up the thread ID in your mapping.
// Node.js / Express handlerapp.post("/webhooks/nylas", async (req, res) => { // Verify X-Nylas-Signature here -- see /docs/v3/notifications/. res.status(200).end();
const event = req.body; if (event.type !== "message.created") return;
const msg = event.data.object; if (msg.grant_id !== AGENT_GRANT_ID) return;
// Is this a reply to a conversation we're tracking? const context = await db.getThreadContext(msg.thread_id);
if (context) { // Reply to an active conversation. await handleReply(msg, context); } else { // New inbound message -- triage it. await handleNewMessage(msg); }});The thread_id approach works because Nylas groups messages by their In-Reply-To and References headers. When the recipient’s mail client sets those headers (which it always does on a reply), Nylas adds the inbound message to the same thread as the original outbound.
You don’t need to parse In-Reply-To headers yourself. The Threads API already did the work.
Fetch the conversation history
Section titled “Fetch the conversation history”Before the agent decides how to respond, it needs to know what was said. Fetch the full thread and the latest message body.
async function handleReply(msg, context) { // Get the full message body (the webhook only carries summary fields). const fullMessage = await nylas.messages.find({ identifier: AGENT_GRANT_ID, messageId: msg.id, });
// Get the thread for the full conversation chain. const thread = await nylas.threads.find({ identifier: AGENT_GRANT_ID, threadId: msg.thread_id, });
// Build the conversation history the agent needs. const history = await buildConversationHistory(thread.data.messageIds);
// Route based on the agent's internal state. await routeReply(fullMessage.data, history, context);}Fetching the full thread gives the agent conversation context — what it said, what the recipient said back, how many exchanges have happened. An LLM deciding how to reply to “sounds good, let’s do Thursday” needs to know what was proposed.
Route based on agent state
Section titled “Route based on agent state”The context you stored when the agent sent the original message tells you where in the workflow this reply lands. Different states need different handling.
async function routeReply(message, history, context) { switch (context.step) { case "awaiting_confirmation": // The agent proposed something and is waiting for a yes/no. await handleConfirmation(message, history, context); break;
case "awaiting_info": // The agent asked a question and needs the answer. await handleInfoResponse(message, history, context); break;
case "closed": // The conversation was resolved but the person wrote back. await handleReopenedThread(message, history, context); break;
default: // Unknown state -- log and escalate. await escalateToHuman(message, context); }}Reply in-thread
Section titled “Reply in-thread”When the agent responds, pass reply_to_message_id so the reply threads correctly in the recipient’s mail client.
async function sendReply(originalMessage, body, context) { const sent = await nylas.messages.send({ identifier: AGENT_GRANT_ID, requestBody: { replyToMessageId: originalMessage.id, to: originalMessage.from, subject: `Re: ${originalMessage.subject}`, body: body, }, });
// Update the conversation state. await db.updateThreadContext(originalMessage.threadId, { ...context, step: "awaiting_reply", lastSentAt: Date.now(), lastSentMessageId: sent.data.id, });}By passing reply_to_message_id, Nylas sets the In-Reply-To and References headers on the outbound message. The recipient sees a threaded reply, not a disconnected new email.
Things to know
Section titled “Things to know”- The webhook fires for outbound too. When the agent sends a reply via the API,
message.createdfires for that sent message as well. Filter onmsg.fromat the top of the handler to avoid the agent responding to its own messages. - Multiple replies can arrive on the same thread. The recipient might send two quick messages, or two people in a CC’d thread might both reply. Process each one independently and check whether the agent has already responded since the last inbound.
- Fetch the body — don’t rely on the webhook payload. The
message.createdwebhook carries summary fields likesubject,from, andsnippet. For the full body, always fetch the message from the API. If the body exceeds ~1 MB, the webhook type becomesmessage.created.truncatedand the body is omitted entirely. - Consider a cooldown before replying. In fast-moving threads, the recipient might send a correction seconds after their first reply. A short delay (30–60 seconds) before the agent responds lets you batch consecutive messages into one response instead of replying to each one individually.
- Don’t ship a reply loop without dedup. Webhook redelivery and concurrent workers will both re-trigger your handler. See the duplicate-replies recipe linked below.
Next steps
Section titled “Next steps”- Build a multi-turn email conversation — the full state machine for conversations that span days
- Prevent duplicate agent replies — dedup patterns for high-volume agent inboxes
- Migrate from transactional email — context if you’re moving from SendGrid/Resend/Postmark
- Email threading for agents — how Message-ID, In-Reply-To, and References headers work
- Using webhooks with Nylas — signature verification, retries, and delivery guarantees