# Build a support agent for multi-day email threads

Source: https://developer.nylas.com/docs/cookbook/use-cases/act/support-agent-multi-day-threads/

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 `support@agents.yourcompany.com` 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.

## The pipeline

```
inbound email ─▶ message.created webhook ─▶ check thread_id against ticket store
                                                   │
                                       ┌───────────┴───────────┐
                                       ▼                       ▼
                              new conversation              existing ticket
                                       │                       │
                                       ▼                       ▼
                              classify with LLM        reclassify on full transcript
                                       │                       │
                          ┌────────────┴────────────┐          │
                          ▼                         ▼          │
                  high confidence          low confidence       │
                  + auto-reply category    or out of scope       │
                          │                         │           │
                          ▼                         ▼           │
                  generate + send reply       escalate to human ◀┘
```

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

1. **Provision** a dedicated support mailbox at `support@agents.yourcompany.com`.
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.

## Before you begin


Make sure you have the following before starting this tutorial:

- A [Nylas account](https://dashboard-v3.nylas.com/) 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)

> **Info:** 
> **New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.


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](/docs/v3/agent-accounts/dns-provider-setup/).
- A **publicly accessible HTTPS webhook endpoint**. During development, use [VS Code port forwarding](https://code.visualstudio.com/docs/editor/port-forwarding) or [Hookdeck](https://hookdeck.com/).
- 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.

> **Warn:** 
> **Nylas blocks requests to ngrok URLs** because of throughput limiting concerns. Use VS Code port forwarding or Hookdeck instead.

## Step 1: Provision the support mailbox

From the [Nylas CLI](/docs/v3/getting-started/cli/):

```bash
nylas agent account create support@agents.yourcompany.com
```

Or through the API:

```bash
curl --request POST \
  --url "https://api.us.nylas.com/v3/connect/custom" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "provider": "nylas",
    "name": "Acme Support",
    "settings": {
      "email": "support@agents.yourcompany.com"
    }
  }'
```

Save the `grant_id`. The top-level `name` becomes the default `From` display name, so customers see replies from `Acme Support <support@agents.yourcompany.com>` across the whole thread -- see [Set a display name](/docs/v3/agent-accounts/provisioning/#set-a-display-name). Consider attaching a [policy](/docs/v3/agent-accounts/policies-rules-lists/) that blocks known spam domains at the SMTP level -- support inboxes attract junk.

## Step 2: Subscribe to webhooks

From the [Nylas CLI](/docs/v3/getting-started/cli/):

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

Or through the API:

```bash
curl --request POST \
  --url "https://api.us.nylas.com/v3/webhooks" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "trigger_types": ["message.created", "message.updated"],
    "webhook_url": "https://youragent.example.com/webhooks/support",
    "description": "Support agent"
  }'
```

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

## Step 3: Classify inbound messages

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.

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

```js
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."

## Step 4: Generate and send auto-replies

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

```js
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(),
  });
}
```

## Step 5: Handle follow-ups

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.

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

## Step 6: Escalate with context

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

```js
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](/docs/v3/agent-accounts/mail-clients/), 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.

## Things to know

- **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](/docs/v3/agent-accounts/policies-rules-lists/) 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.
- **The 100-message daily default matters here.** A busy support inbox can exhaust the send cap. Provision a policy with a higher limit, 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.

## Next steps

- [Scheduling agent with dedicated identity](/docs/cookbook/use-cases/act/scheduling-agent-with-dedicated-identity/) -- the same Agent Account pattern for meeting coordination
- [Handle email replies in an agent loop](/docs/cookbook/agent-accounts/handle-replies/) -- the core reply-detection recipe this builds on
- [Build a multi-turn email conversation](/docs/cookbook/agent-accounts/multi-turn-conversations/) -- the general-purpose conversation state machine
- [Prevent duplicate agent replies](/docs/cookbook/agent-accounts/prevent-duplicate-replies/) -- dedup patterns for high-volume inboxes
- [Email triage agent](/docs/cookbook/agents/email-triage-agent/) -- the simpler classify-and-archive pattern for personal inboxes
- [Policies, Rules, and Lists](/docs/v3/agent-accounts/policies-rules-lists/) -- spam filtering and inbound routing for the support inbox
- [Mail client access (IMAP & SMTP)](/docs/v3/agent-accounts/mail-clients/) -- let human operators read the agent's mailbox alongside the API