Build a support agent that owns its inbox

Build a support agent that owns its inbox

5 min read

If you are building a support agent, closing a ticket in one turn is the easy part. The hard part is building an agent that holds state across a thread that spans days, reclassifies when the customer comes back with a different problem, and hands off to a human with the full context. That only works if the mailbox belongs to the application. Nylas Agent Accounts give you the inbox, the webhook triggers, and the thread model. Here is how to build the loop.

Provision the support mailbox

Create a dedicated support inbox by provisioning a Nylas Agent Account. Your backend makes one API call and gets back a grant ID. That grant ID works with every Nylas endpoint, MCP server, and CLI immediately.

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",
    "settings": {
      "email": "[email protected]"
    }
  }'

The response returns the grant ID at data.id. Save it. That is what you pass on every subsequent API call. The mailbox is live immediately.

To test the flow locally before wiring it into your backend, the CLI runs the same provisioning:

nylas agent account create [email protected]

A policy is required on either the grant or the connector. If neither has one, the API returns 400 Policy ID is missing. The simplest path is to create a default policy on the connector once in your Nylas dashboard so every Agent Account you provision inherits it. From there, you can layer per-account rules on top: block known junk senders at the SMTP stage to keep the classification loop from running on garbage, or auto-archive out-of-office auto-replies before they wake the agent.

Subscribe to webhooks

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

Every webhook POST from Nylas carries an X-Nylas-Signature header. Your handler must verify it before processing, or anyone who knows your webhook URL can forge payloads and trigger the agent to send email on your behalf.

message.created fires when a customer writes in or sends a follow-up. message.updated fires when a human operator reads or moves a message, which lets you track when a ticket gets human attention alongside the agent.

Build the ticket state model first

The most important architectural decision: your state store is keyed by thread_id, not message_id. When the customer replies five days later, Nylas groups that reply under the same thread_id as the original. Your handler looks up the existing ticket by thread ID and picks up exactly where it left off.

A minimal ticket record needs the thread ID, the customer’s email address, the current classification category, the urgency level, the agent status (open, awaiting customer, escalated, closed), a turn counter, and timestamps for creation and last activity. In-memory storage does not work here. Use Postgres, Redis, or any persistent store.

app.post("/webhooks/support", async (req, res) => {
  // Verify the webhook is actually from Nylas before doing anything
  const isValid = Nylas.validateWebhookSignature(
    req.headers["x-nylas-signature"],
    JSON.stringify(req.body),
    process.env.NYLAS_WEBHOOK_SECRET
  );
  if (!isValid) return res.status(401).end();

  res.status(200).end(); // Acknowledge immediately

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

  const ticket = await db.tickets.findByThreadId(msg.thread_id);

  if (ticket) {
    await handleFollowUp(msg, ticket);
  } else {
    await handleNewTicket(msg);
  }
});

Classify with a confidence threshold

For new tickets, fetch the full message body and hand it to the LLM for classification:

async function handleNewTicket(msg) {
  const full = await nylas.messages.find({
    identifier: SUPPORT_GRANT_ID,
    messageId: msg.id,
  });

  const classification = await llm.classify({
    system: `Classify this support ticket.
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,
  });

  const ticket = await db.tickets.create({
    threadId: msg.thread_id,
    customerEmail: msg.from[0].email,
    category: classification.category,
    urgency: classification.urgency,
    status: "open",
    turnCount: 0,
    createdAt: new Date().toISOString(),
    lastActivityAt: new Date().toISOString(),
  });

  if (classification.confidence >= 0.85 && isAutoReplyCategory(classification.category)) {
    await generateAutoReply(full.data, ticket, classification);
  } else {
    await escalateToHuman(ticket, "low confidence or complex category");
  }
}

Start the confidence threshold at 0.85 or higher, and start with a narrow set of auto-reply categories: password resets, status page checks, FAQ questions with clear documented answers. Widen from there once you have accuracy data. An agent that escalates too often is annoying, but an agent that confidently answers a billing question incorrectly is the bigger problem.

Auto-reply in-thread

When the classification is confident and the category is one the agent handles, generate a reply and send it using replyToMessageId. This tells Nylas to set the correct In-Reply-To and References headers so the reply lands in the customer’s existing thread in any email client:

async function generateAutoReply(message, ticket, classification) {
  const replyBody = await llm.generateReply({
    system: `You are a support agent for ${COMPANY_NAME}.
Reply concisely and accurately. If unsure, say so and offer a human 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(),
  });
}

Handle follow-ups and reclassify

When the customer replies, message.created fires again. The handler finds the existing ticket and runs through lifecycle checks before deciding what to do:

async function handleFollowUp(msg, ticket) {
  if (ticket.status === "escalated") return;

  if (ticket.turnCount >= 6) {
    await escalateToHuman(ticket, "turn limit reached");
    return;
  }

  const hoursSinceActivity =
    (Date.now() - new Date(ticket.lastActivityAt).getTime()) / 3600000;
  if (hoursSinceActivity > 168) {
    await escalateToHuman(ticket, "dormant thread reopened");
    return;
  }

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

  const reclassification = await llm.classify({
    system: "Reclassify this support conversation based on the full transcript.",
    transcript,
  });

  if (reclassification.confidence >= 0.85 && isAutoReplyCategory(reclassification.category)) {
    const full = await nylas.messages.find({
      identifier: SUPPORT_GRANT_ID,
      messageId: msg.id,
    });
    await generateAutoReply(full.data, ticket, reclassification);
  } else {
    await escalateToHuman(ticket, "follow-up requires human judgment");
  }
}

Reclassifying on the full transcript, not just the latest message, catches the conversation that shifts category on reply three. Your handler should adapt every time.

Escalate with context

When the agent escalates, update the ticket and notify your ops team. The full thread stays accessible to the human who picks it up:

async function escalateToHuman(ticket, reason) {
  await db.tickets.update(ticket.threadId, {
    status: "escalated",
    lastActivityAt: new Date().toISOString(),
    escalationReason: reason,
  });

  await notifyOpsTeam({
    threadId: ticket.threadId,
    customer: ticket.customerEmail,
    category: ticket.category,
    turnCount: ticket.turnCount,
    reason,
  });
}

If the human team connects to the support mailbox over IMAP via an app_password on the Agent Account, they can read and reply from Outlook or Apple Mail. Any reply they send comes back through the API, so if the ticket is de-escalated, the agent picks up with the full context of what the human said.

What to watch in production

Log everything the agent sends. Support emails are auditable communications. The classification result, confidence score, and generated reply should sit alongside the thread ID for every interaction. An agent without an audit trail is a liability when a customer escalates with a screenshot of an incorrect response.

Monitor the escalation rate. If more than 40 to 50 percent of tickets escalate, the agent is not carrying its weight. Tune the knowledge base or narrow the category set, not the confidence threshold.

Use the rule evaluations endpoint to audit what the agent’s spam rules are catching. Important customer messages should not be silently blocked.

What to build next

The scheduling agent is the other Agent Account loop in the Nylas cookbook: same identity model, different loop. It receives meeting requests, checks free/busy on its own calendar, proposes times, and handles RSVPs through event.updated webhooks.

The underlying recipes are in the cookbook: Handle email replies in an agent loop, Multi-turn email conversations, and Prevent duplicate agent replies.

Full Agent Accounts documentation at developer.nylas.com/docs/v3/agent-accounts/.

Build your first support agent

nylas agent account create support@yourdomain.com

Related resources

Give your scheduling agent its own identity

If you are building an AI agent that books meetings for sales, product workflows, or…