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.
The pipeline
Section titled “The pipeline”Support agent pipeline
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.createdincludes the message'sidandthread_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 /, then classify the transcript.v3/ grants/ {grant_id}/ threads/ {thread_id} - Escalates without classifying when: The agent has already replied 6 times.
- Escalates without classifying when: The thread was quiet for more than 7 days.
- Your LLM: Reclassify the full thread: Fetch every message with
- New conversation
- Ends in one of:
- High confidence, auto-reply category → Your backend: Reply in the thread:
POST /withv3/ grants/ {grant_id}/ messages/ send 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.
- High confidence, auto-reply category → Your backend: Reply in the thread:
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:
- Provision a dedicated support mailbox at
[email protected]. - Classify inbound messages with 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 new 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 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.
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", "name": "Acme Support", "settings": { "email": "[email protected]" } }'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.
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.
Things to know
Section titled “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 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.
Next steps
Section titled “Next steps”- Scheduling agent with dedicated identity — the same Agent Account pattern for meeting coordination
- Handle email replies in an agent loop — the core reply-detection recipe this 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 triage agent — the simpler classify-and-archive pattern for personal inboxes
- 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