Skip to content
Skip to main content

Build a support agent for multi-day email threads

Last updated:

A support inbox is one of the more demanding patterns for an email agent. Messages arrive unpredictably, threads go dormant for days and then revive, and the agent needs to balance speed with accuracy. A wrong auto-reply to a billing question is worse than a slow one.

This recipe builds a support agent on its own [email protected] Agent Account. It classifies inbound messages with an LLM, auto-replies to the easy stuff, escalates the rest, and tracks per-thread state in a database so a customer’s reply five days later picks up where the conversation left off.

Support agent pipeline

Every inbound message is matched to a ticket by thread_id. New and returning conversations are classified separately, then the agent either replies in the thread or hands the ticket to a human.
Diagram as text
  • Customer: Customer sends an email: A new question, or a reply to an open ticket days later.
  • Nylas: Notify your webhook: message.created includes the message's id and thread_id. (Arrives in the support Agent Account)
  • Your backend: Look up the ticket: Find a ticket by thread_id. Skip duplicates and the agent's own messages.
  • One of these paths:
    • New conversation
      • Your LLM: Classify the message: Category, urgency, and confidence from the message body. Creates the ticket.
    • Existing ticket
      • Your LLM: Reclassify the full thread: Fetch every message with GET /v3/grants/{grant_id}/threads/{thread_id}, then classify the transcript.
        • Escalates without classifying when: The agent has already replied 6 times.
        • Escalates without classifying when: The thread was quiet for more than 7 days.
  • Ends in one of:
    • High confidence, auto-reply category → Your backend: Reply in the thread: POST /v3/grants/{grant_id}/messages/send with reply_to_message_id.
    • Low confidence or out of scope → Your backend: Escalate to a human: Mark the ticket escalated and send your team the reason.

Support threads rarely stay linear. Customers often reply to an older message instead of the latest one, so the agent keys every ticket on thread_id, not on the message it replied to. See Threads are non-linear for how replies branch within a thread.

Six things you need to build, end-to-end:

  1. Provision a dedicated support mailbox at [email protected].
  2. Classify inbound messages with an LLM to determine intent and urgency.
  3. Auto-reply to common questions (password resets, status checks, FAQs) in-thread.
  4. Track open tickets with a per-thread state model that survives across days.
  5. Escalate when the agent doesn’t have a confident answer, hits a turn limit, or detects frustration.
  6. Handle follow-ups when the customer replies days later with new context.

Make sure you have the following before starting this tutorial:

  • A Nylas account with an active application
  • A valid API key from your Nylas Dashboard
  • At least one connected grant (an authenticated user account) for the provider you want to work with
  • Node.js 18+ or Python 3.8+ installed (depending on which code samples you follow)

You also need:

  • A domain registered with Nylas — either a *.nylas.email trial subdomain or your own domain with MX + TXT records. See Setup domains.
  • A publicly accessible HTTPS webhook endpoint. During development, use VS Code port forwarding or Hookdeck.
  • Access to an LLM for classification and reply generation. Any OpenAI-compatible API works.
  • A persistent data store (Postgres, Redis, DynamoDB) for ticket state. Support threads span days — in-memory won’t work.

From the Nylas CLI:

nylas agent account create [email protected]

Or through the API:

Save the grant_id. The top-level name becomes the default From display name, so customers see replies from Acme Support <[email protected]> across the whole thread — see Set a display name. Consider attaching a policy that blocks known spam domains at the SMTP level — support inboxes attract junk.

From the Nylas CLI:

nylas webhook create \
--url https://youragent.example.com/webhooks/support \
--triggers "message.created,message.updated"

Or through the API:

message.created fires when a customer writes in or replies. message.updated tells you when a human operator marks a message as read or moves it to a folder (useful if humans triage alongside the agent).

When a new message arrives, the agent needs to decide what to do with it. Fetch the full body and hand it to the LLM for classification.

app.post("/webhooks/support", async (req, res) => {
res.status(200).end();
const event = req.body;
if (event.type !== "message.created") return;
const msg = event.data.object;
if (msg.grant_id !== SUPPORT_GRANT_ID) return;
// Skip messages the agent sent.
if (msg.from?.[0]?.email === SUPPORT_EMAIL) return;
// Deduplicate (webhooks are at-least-once).
if (await db.alreadyProcessed(msg.id)) return;
await db.markProcessed(msg.id);
// Is this a reply to an existing ticket or a new conversation?
const ticket = await db.tickets.findByThreadId(msg.thread_id);
if (ticket) {
await handleFollowUp(msg, ticket);
} else {
await handleNewTicket(msg);
}
});

For new tickets, classify the message to determine how the agent should respond:

async function handleNewTicket(msg) {
const full = await nylas.messages.find({
identifier: SUPPORT_GRANT_ID,
messageId: msg.id,
});
const classification = await llm.classify({
system: `You are a support ticket classifier. Categorize the email and assess urgency.
Return JSON: { "category": "password_reset|billing|bug_report|feature_request|general",
"urgency": "low|medium|high",
"confidence": 0.0-1.0,
"summary": "one-line summary" }`,
message: full.data.body,
});
// Create the ticket record.
const ticket = await db.tickets.create({
threadId: msg.thread_id,
customerEmail: msg.from[0].email,
customerName: msg.from[0].name,
category: classification.category,
urgency: classification.urgency,
status: "open",
turnCount: 0,
createdAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
});
// Route based on confidence and category.
if (classification.confidence >= 0.85 && isAutoReplyCategory(classification.category)) {
await generateAutoReply(full.data, ticket, classification);
} else {
await escalateToHuman(ticket, "low confidence or complex category");
}
}

The confidence threshold is important. A support agent that confidently gives the wrong answer to a billing question is worse than one that says “let me get a human for you.”

For categories the agent can handle (password resets, status checks, common FAQs), generate a reply and send it in-thread.

async function generateAutoReply(message, ticket, classification) {
const replyBody = await llm.generateReply({
system: `You are a helpful support agent for ${COMPANY_NAME}.
Reply to the customer's question. Be concise, accurate, and friendly.
If you're not sure about something, say so and offer to connect them with a specialist.`,
category: classification.category,
customerMessage: message.body,
knowledgeBase: await getRelevantDocs(classification.category),
});
await nylas.messages.send({
identifier: SUPPORT_GRANT_ID,
requestBody: {
replyToMessageId: message.id,
to: message.from,
subject: `Re: ${message.subject}`,
body: replyBody,
},
});
await db.tickets.update(ticket.threadId, {
status: "awaiting_customer",
turnCount: ticket.turnCount + 1,
lastActivityAt: new Date().toISOString(),
});
}

When the customer replies — maybe the same day, maybe a week later — the webhook fires again. The agent restores the ticket context and decides what to do next.

async function handleFollowUp(msg, ticket) {
// Skip if the ticket was escalated to a human.
if (ticket.status === "escalated") return;
const full = await nylas.messages.find({
identifier: SUPPORT_GRANT_ID,
messageId: msg.id,
});
// Fetch the full thread for conversation history.
const thread = await nylas.threads.find({
identifier: SUPPORT_GRANT_ID,
threadId: ticket.threadId,
});
const allMessages = await Promise.all(
thread.data.messageIds.map((id) =>
nylas.messages.find({ identifier: SUPPORT_GRANT_ID, messageId: id }),
),
);
const transcript = allMessages
.map((m) => m.data)
.sort((a, b) => a.date - b.date)
.map((m) => ({
role: m.from[0].email === SUPPORT_EMAIL ? "agent" : "customer",
body: m.body,
date: new Date(m.date * 1000).toISOString(),
}));
// Check lifecycle constraints.
if (ticket.turnCount >= 6) {
await escalateToHuman(ticket, "turn limit reached");
return;
}
// Check for dormancy -- if the thread went quiet for 7+ days, escalate.
const hoursSinceLastActivity =
(Date.now() - new Date(ticket.lastActivityAt).getTime()) / 3600000;
if (hoursSinceLastActivity > 168) {
await escalateToHuman(ticket, "dormant thread reopened");
return;
}
// Reclassify -- the customer's follow-up might shift the conversation.
const reclassification = await llm.classify({
system: "Reclassify this support conversation based on the full transcript.",
transcript,
});
if (reclassification.confidence >= 0.85 && isAutoReplyCategory(reclassification.category)) {
await generateAutoReply(full.data, ticket, reclassification);
} else {
await escalateToHuman(ticket, "follow-up requires human judgment");
}
}

Reclassifying on follow-up matters. A conversation that started as a “general” question might turn into a billing dispute on the second message. The agent’s routing should adapt.

When the agent escalates, it should pass along everything the human needs so they don’t have to re-read the entire thread.

async function escalateToHuman(ticket, reason) {
await db.tickets.update(ticket.threadId, {
status: "escalated",
lastActivityAt: new Date().toISOString(),
escalationReason: reason,
});
// Notify the human team with ticket context.
await notifyOpsTeam({
threadId: ticket.threadId,
customer: ticket.customerEmail,
category: ticket.category,
turnCount: ticket.turnCount,
reason,
// Include a link to the thread in the Nylas Dashboard or IMAP client
// so the human can read the full history.
});
}

If the human team connects to the Agent Account over IMAP, they can read and reply to the thread from Outlook or Apple Mail. The API and IMAP share the same mailbox, so the human’s reply is visible to the agent if the ticket gets de-escalated.

  • Start conservative. Set the confidence threshold high (0.85+) and the auto-reply categories narrow. Widen them once you have accuracy data. A support agent that confidently sends wrong answers erodes trust faster than one that escalates too often.
  • Use rules to block noise. Spam, bounce-backs, and out-of-office auto-replies shouldn’t trigger the agent. Configure rules to block known junk senders at the SMTP level and auto-archive auto-replies before they wake the loop.
  • Monitor the escalation rate. If more than 40-50% of tickets escalate, the agent isn’t pulling its weight. Tune the knowledge base, adjust the LLM prompt, or narrow the categories the agent handles.
  • Log everything the agent sends. Support emails are auditable communications. Log the full thread, the classification result, the confidence score, and the generated reply for every interaction. Don’t ship an agent that talks to customers without an audit trail.
  • Send limits matter here. On the Free plan, each Agent Account can send 200 messages per day. Paid plans have no per-account daily cap (see send limits). A busy support inbox on Free can exhaust that cap, so move to a paid plan or split the load across multiple Agent Accounts by category.
  • Reclassify on every reply. A conversation that started as a “general” question often turns into a billing dispute on the second message. Don’t lock the ticket category at first contact.