Skip to content
Skip to main content

How to handle email replies in an agent loop

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 shows how to detect replies via webhook, pull thread context, and route the reply to 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.

  1. Receive the message.created webhook when a reply arrives.
  2. Distinguish replies from new conversations using thread_id.
  3. Fetch the thread to reconstruct what the agent last said.
  4. Route the reply to the right handler based on your agent’s internal state.
  5. Reply in-thread so the conversation stays coherent.

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

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.

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

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.

  • The webhook fires for outbound too. When the agent sends a reply via the API, message.created fires for that sent message as well. Filter on msg.from 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.created webhook carries summary fields like subject, from, snippet. For the full body, always fetch the message from the API. If the body exceeds ~1 MB, the webhook type becomes message.created.truncated and 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.