A support inbox is one of the more demanding patterns for an email agent. Messages arrive unpredictably, threads can 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 tutorial builds a support agent that lives on its own [email protected] Agent Account. It receives inbound emails, classifies them with an LLM, replies to straightforward questions, and escalates the rest — all through webhooks, the Threads API, and a persistent state machine.
What you’ll build
Section titled “What you’ll build”- Provision a dedicated support mailbox at
[email protected]. - Classify inbound messages using an LLM to determine intent and urgency.
- Auto-reply to common questions (password resets, status checks, FAQs) in-thread.
- Track open tickets with a per-thread state model that survives across days.
- Escalate when the agent doesn’t have a confident answer, hits a turn limit, or detects frustration.
- Handle follow-ups when the customer replies days later with additional context.
Before you begin
Section titled “Before you begin”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.emailtrial subdomain or your own domain with MX + TXT records. See Provisioning and 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.
Step 1: Provision the support mailbox
Section titled “Step 1: Provision the support mailbox”From the Nylas CLI:
Or through the API:
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]" } }'Save the grant_id. Consider attaching a policy that blocks known spam domains at the SMTP level — support inboxes attract junk.
Step 2: Subscribe to webhooks
Section titled “Step 2: Subscribe to webhooks”From the Nylas CLI:
nylas webhook create \ --url https://youragent.example.com/webhooks/support \ --triggers "message.created,message.updated"Or through the API:
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
Section titled “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.
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.”
Step 4: Generate and send auto-replies
Section titled “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.
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
Section titled “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.
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
Section titled “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.
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.
Keep in mind
Section titled “Keep in mind”- Start conservative. Set the confidence threshold high (0.85+) and the auto-reply categories narrow. You can widen them once you have data on the agent’s accuracy. A support agent that sends wrong answers erodes trust faster than one that escalates too often.
- Use rules to block noise. Spam, bounce-back notifications, 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.
- 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.
- 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.
What’s next
Section titled “What’s next”- Handle email replies in an agent loop — the core reply-detection recipe this tutorial builds on
- Build a multi-turn email conversation — the general-purpose conversation state machine
- Prevent duplicate agent replies — dedup patterns for high-volume inboxes
- Email threading for agents — how threading headers work under the hood
- Policies, Rules, and Lists — spam filtering and inbound routing for the support inbox
- Mail client access (IMAP & SMTP) — let human operators read the agent’s mailbox alongside the API