Build a support triage agent that owns its own inbox

Most “AI support bot” demos bolt an LLM onto a shared Gmail or hang a webhook off a helpdesk and call it a day. That works right up until you want the agent to actually be a participant in the conversation — to receive mail at a real address, decide what to do with it, and reply as itself instead of as a script poking at someone else’s mailbox.

So let’s not do that. Let’s give support@yourcompany.com its own first-class identity: an Agent Account that receives every inbound email, classifies it with a model, routes and filters it with server-side rules, and replies in the right thread — once, never twice. No shared inbox, no scraping a human’s account, no helpdesk middleman.

I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I’m setting one of these up. Every step gets the two-angle tour: the raw curl call and the nylas command that does the same thing.

What you actually get

An Agent Account is, underneath, just a Nylas grant with a grant_id. That’s the whole trick, and it’s worth sitting with for a second: there’s nothing new to learn on the data plane. Every grant-scoped endpoint you already know — Messages, Drafts, Threads, Folders, Attachments, Contacts, Calendars, Events — works against this grant exactly the way it works against a Gmail or Microsoft grant you obtained through OAuth. The provider happens to be nylas instead of google, and that’s the only difference your application code sees.

Concretely, the account ships with:

  • A real, send-and-receive mailbox on a domain you control (or a *.nylas.email trial subdomain).
  • Six reserved system folders out of the box — inbox, sent, drafts, trash, junk, and archive.
  • Standard webhook triggers: you subscribe once at the application level (POST /v3/webhooks), and message.created then fires on inbound mail. A subscription isn’t scoped to one grant — each event’s payload carries a grant_id, so you filter for your Agent Account’s events on the way in. Agent Accounts also emit deliverability triggers: message.delivered, message.bounced, message.complaint, and message.rejected.
  • No OAuth dance and no refresh token to babysit. You create it with one API call.

The part I like as an SRE: because it’s a normal grant, it slots into whatever observability, retry, and webhook plumbing you already built for human accounts. You’re not maintaining a special-case code path.

Before you begin

Two things:

  1. An API key. All requests authenticate with Authorization: Bearer , and the key identifies your application. Examples here hit https://api.us.nylas.com.
  2. A verified domain. Agent Accounts live on a domain — either a custom one you register and publish DNS records for, or a Nylas trial subdomain like yourapp.nylas.email. New domains warm up over roughly four weeks, so if you’re going to production, register the real one early. The full DNS walkthrough is in the provisioning docs.

If you’ve run nylas init already, the CLI is pointed at your application and you’re ready.

Provision support@

You create the account with a single POST /v3/connect/custom using "provider": "nylas" and the email address in settings.email. The optional top-level name becomes the default From display name on everything the account sends.

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

The response hands you back data.id. Save it — that’s the grant_id you’ll use on every subsequent call.

From the CLI it’s one line:

nylas agent account create support@yourcompany.com --name "Acme Support"

That provisions the grant and prints its id, status, and connector details. If the underlying nylas connector doesn’t exist on your application yet, the CLI creates it first — you don’t have to think about it. The API also auto-creates a default workspace and a default policy for the account, which matters in a minute when we get to filtering.

One honest gotcha worth flagging now: there is no --workspace flag on agent account create. Workspaces (which carry your policies and rules) get attached separately. We’ll do that below.

If you want a human to be able to peek at the inbox over IMAP later, pass --app-password at creation time (18–40 ASCII chars, mixed case and a digit). Skip it and protocol access stays off, which for a fully automated agent is usually what you want.

Receive every inbound message

The whole agent runs off one webhook. Inbound mail to support@ fires the standard message.created notification — the same shape you’d get for any other grant.

curl --request POST 
  --url "https://api.us.nylas.com/v3/webhooks" 
  --header "Authorization: Bearer " 
  --header "Content-Type: application/json" 
  --data '{
    "trigger_types": ["message.created"],
    "webhook_url": "https://support-agent.yourcompany.com/webhooks/nylas",
    "description": "Support triage agent"
  }'

Same thing from the terminal:

nylas webhook create 
  --url https://support-agent.yourcompany.com/webhooks/nylas 
  --triggers message.created 
  --description "Support triage agent"

A message.created payload looks roughly like this — note that it carries summary fields, not the full body:

{
  "type": "message.created",
  "data": {
    "object": {
      "id": "msg-abc123",
      "grant_id": "b1c2d3e4-5678-4abc-9def-0123456789ab",
      "thread_id": "thread-xyz789",
      "subject": "Can't log in after password reset",
      "from": [{ "name": "Dana Reed", "email": "dana@customer.example" }],
      "snippet": "I reset my password an hour ago and now...",
      "date": 1742932766
    }
  }
}

Your handler should return 200 immediately, verify the X-Nylas-Signature header, and then do its work asynchronously. The first thing it does is skip messages the agent itself sent — because message.created fires for outbound mail too, and an agent that replies to its own replies is a special kind of broken:

app.post("/webhooks/nylas", 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;
  if (msg.from?.[0]?.email === "support@yourcompany.com") return; // skip our own sends

  await triage(msg);
});

Because the webhook only carries the snippet, you’ll fetch the full message before you hand anything to the model:

curl --request GET 
  --url "https://api.us.nylas.com/v3/grants//messages/msg-abc123" 
  --header "Authorization: Bearer "
nylas email read msg-abc123

There’s a hard edge here: if the body is larger than ~1 MB, the webhook type becomes message.created.truncated and the body is omitted — so don’t rely on the payload ever carrying the full text.

Classify it with an LLM

Triage is a classification problem, and classification is exactly what models are good at. Pull the full message, then feed the model the three fields that actually decide intent: subject, sender, and body. Ask for a category back.

This part is deliberately provider-agnostic — any chat-completions-style API works, and the prompt is the whole design:

async function triage(msg) {
  const full = await getFullMessage(msg.grant_id, msg.id); // GET .../messages/{id}

  const category = await llm.classify({
    instruction:
      "Classify this support email into exactly one of: " +
      "billing, bug_report, how_to, account_access, feedback, spam. " +
      "Reply with only the category.",
    subject: full.subject,
    from: full.from[0].email,
    body: full.body,
  });

  await route(full, category); // -> assign folder, draft reply, or escalate
}

Keep the label set small and closed. A model asked to pick from six categories is reliable; a model asked to “summarize the customer’s needs” is a liability you’ll be debugging at 2 a.m. The category is what drives everything downstream — which folder it lands in, whether the agent auto-replies, and whether a human gets pulled in.

Route and filter with policies, rules, and lists

Here’s the move a lot of “AI inbox” builds miss: not every message should reach your application at all. Nylas Agent Accounts give you three server-side primitives that filter and sort mail before your webhook ever fires, so the LLM only spends tokens on things worth classifying.

  • Policies bundle limits (send quotas, storage, retention) and spam detection. One policy can govern many accounts.
  • Rules match inbound or outbound mail on sender/recipient fields and run actions like block, mark_as_spam, assign_to_folder, archive, or trash.
  • Lists are typed collections of domains, TLDs, or addresses that rules reference via the in_list operator — so non-engineers can update an allowlist or blocklist without a deploy.

They form a chain: lists hold values, rules reference lists and describe conditions plus actions, policies bundle limits, and a workspace carries one policy_id and an array of rule_ids. Every Agent Account in the workspace inherits both. You don’t attach anything to an individual grant.

Start by blocking a known-bad sender at the SMTP layer, so it never hits the mailbox:

curl --request POST 
  --url "https://api.us.nylas.com/v3/rules" 
  --header "Authorization: Bearer " 
  --header "Content-Type: application/json" 
  --data '{
    "name": "Block spam-domain.com",
    "priority": 1,
    "trigger": "inbound",
    "match": {
      "conditions": [
        { "field": "from.domain", "operator": "is", "value": "spam-domain.com" }
      ]
    },
    "actions": [{ "type": "block" }]
  }'

The CLI builds the same rule and attaches it to the default workspace in one shot:

nylas agent rule create 
  --name "Block spam-domain.com" 
  --trigger inbound 
  --priority 1 
  --condition from.domain,is,spam-domain.com 
  --action block

For the allowlist/blocklist pattern, create a list and reference it from a rule. API first:

curl --request POST 
  --url "https://api.us.nylas.com/v3/lists" 
  --header "Authorization: Bearer " 
  --header "Content-Type: application/json" 
  --data '{ "name": "Blocked domains", "type": "domain" }'

That call returns the list id. Seed it with items in a second call — list items are their own sub-resource:

curl --request POST 
  --url "https://api.us.nylas.com/v3/lists//items" 
  --header "Authorization: Bearer " 
  --header "Content-Type: application/json" 
  --data '{ "items": ["spam.com"] }'

Then the rule that matches against it with in_list:

curl --request POST 
  --url "https://api.us.nylas.com/v3/rules" 
  --header "Authorization: Bearer " 
  --header "Content-Type: application/json" 
  --data '{
    "name": "Block anything on our blocklist",
    "trigger": "inbound",
    "match": {
      "conditions": [
        { "field": "from.domain", "operator": "in_list", "value": [""] }
      ]
    },
    "actions": [{ "type": "block" }]
  }'

The CLI collapses the list creation and seeding into one command, and the rule reference into another:

nylas agent list create --name "Blocked domains" --type domain --item spam.com
nylas agent rule create 
  --name "Block anything on our blocklist" 
  --trigger inbound 
  --condition from.domain,in_list, 
  --action block

Routing is the same shape with a different action. Push automated notifications into their own folder so the agent doesn’t waste a classification on them — assign_to_folder paired with mark_as_read. The curl form:

curl --request POST 
  --url "https://api.us.nylas.com/v3/rules" 
  --header "Authorization: Bearer " 
  --header "Content-Type: application/json" 
  --data '{
    "name": "Route notifications to a folder",
    "trigger": "inbound",
    "match": {
      "conditions": [
        { "field": "from.domain", "operator": "is", "value": "noreply.example.com" }
      ]
    },
    "actions": [
      { "type": "assign_to_folder", "value": "" },
      { "type": "mark_as_read" }
    ]
  }'

And the CLI equivalent, which creates the rule and attaches it to the default workspace:

nylas agent rule create 
  --name "Route notifications to a folder" 
  --trigger inbound 
  --condition from.domain,is,noreply.example.com 
  --action assign_to_folder= 
  --action mark_as_read

If you created a custom policy and want it on your accounts, attach it to the workspace — that’s where the no---workspace-flag detail from earlier resolves. Create the policy, then PATCH the workspace to point at it. The curl form:

# Create the policy
curl --request POST 
  --url "https://api.us.nylas.com/v3/policies" 
  --header "Authorization: Bearer " 
  --header "Content-Type: application/json" 
  --data '{ "name": "Support Agent Policy" }'

# Attach it to the workspace (returns the policy id from the call above)
curl --request PATCH 
  --url "https://api.us.nylas.com/v3/workspaces/" 
  --header "Authorization: Bearer " 
  --header "Content-Type: application/json" 
  --data '{ "policy_id": "" }'

The CLI collapses both steps:

nylas agent policy create --name "Support Agent Policy"
nylas workspace update  --policy-id 

One thing to internalize: inbound and outbound rules are isolated. An inbound rule never runs on a send, and an outbound rule never runs on receipt. So if you also want to, say, star every reply the agent sends, that’s an outbound rule matching outbound.type equal to reply — it won’t interfere with your inbound triage at all.

Reply in the correct thread

When the agent decides to respond, threading is non-negotiable. The reply has to land inside the customer’s existing conversation, not as a fresh disconnected email. Nylas handles this through reply_to_message_id: pass it on the send and Nylas sets the In-Reply-To and References headers for you, so the customer’s mail client groups the reply correctly.

curl --request POST 
  --url "https://api.us.nylas.com/v3/grants//messages/send" 
  --header "Authorization: Bearer " 
  --header "Content-Type: application/json" 
  --data '{
    "reply_to_message_id": "msg-abc123",
    "to": [{ "email": "dana@customer.example" }],
    "subject": "Re: Can'''t log in after password reset",
    "body": "Hi Dana — I see the reset went through an hour ago..."
  }'

From the CLI, nylas email reply does the bookkeeping for you. Point it at the message ID and it fetches the original to populate the recipient and subject, then preserves threading via reply_to_message_id automatically:

nylas email reply msg-abc123 --body "Hi Dana — I see the reset went through an hour ago..."

By default the reply goes only to the original sender; add --all if you need to keep the rest of the To/Cc on the thread. This is the command I lean on most when I’m testing an agent by hand — it’s the shortest path from “a message landed” to “a threaded reply went out.”

Don’t reply twice

This is where naive builds fall over in production. Webhooks are delivered at least once — if your endpoint is slow to 200 or there’s a network blip, you’ll get the same message.created again. Add concurrent workers and two of them can grab the same notification at the same millisecond. Either way: two replies, one angry customer.

The fix is idempotency, and it lives entirely in your own store — not in Nylas. Custom metadata tagging on Agent Account resources isn’t supported yet, so you can’t stash dedup state on the message or grant; keep it in Redis or Postgres. Track the message IDs you’ve already processed and refuse to handle one twice, using an atomic check-and-set:

const messageId = event.data.object.id;

// Atomic insert. Returns truthy ONLY when the key was newly inserted:
//   Redis    -> SET messageId 1 NX EX 86400   (truthy if it didn't exist)
//   Postgres -> INSERT ... ON CONFLICT DO NOTHING, then check rowCount === 1
const wasInserted = await db.processedMessages.setIfAbsent(messageId, {
  receivedAt: Date.now(),
});

if (!wasInserted) return; // insert did nothing -> already seen, bail

Watch the polarity here — it’s the easiest bug to ship. setIfAbsent succeeds (returns truthy) only the first time it sees a message ID; every redelivery finds the key already present and returns falsy. So you proceed when wasInserted is true and exit when it’s false. Invert that test and you’d drop every genuine first message while happily processing the duplicates.

Give the dedup record a TTL of about 24 hours — long enough that a webhook redelivered hours later still gets caught, short enough that the table doesn’t grow forever. For the concurrent-worker race, layer a per-thread lock on top, and inside it double-check the thread’s latest message: if the most recent message is already from the agent, a prior worker beat you to it and you skip.

Dedup catches redelivered events; locking catches simultaneous ones. You want both. The full pattern, including an outbound rate limit as a backstop against reply storms, is in the prevent-duplicate-replies recipe.

Carry context across the thread

A support thread is rarely one message. The customer replies, you reply, they clarify — and the agent needs the whole exchange to answer the third message sensibly. Because every message in a conversation shares a thread_id, fetching context is one call.

nylas email threads show thread-xyz789

Or the same call over the API:

curl "https://api.us.nylas.com/v3/grants//threads/" 
  --header "Authorization: Bearer "

GET /v3/grants/{grant_id}/threads/{thread_id} returns the thread with its message_ids; fetch those for the full transcript, sort by date, and hand the model the running conversation rather than the latest message in isolation. For long threads, summarize the early messages and pass only the last few in full — same answer quality, far fewer tokens. The multi-turn conversations recipe builds the full state machine around this if you need conversations that span days.

Guardrails before you ship

A support agent that emails customers autonomously needs brakes:

  • Human-in-the-loop for sensitive categories. Route billing and account_access to a draft-and-escalate path instead of auto-sending. The agent prepares the reply; a person approves it.
  • Cap the conversation. Track a turn count per thread and escalate to a human when it crosses a limit. An unbounded loop is a token sink and a reputation risk.
  • Outbound rate limit. Even with dedup and locking, a logic bug can cascade. A per-thread send cap (escalate instead of sending after N replies in a few minutes) is the backstop you’ll be glad you wrote.
  • Mind retention and limits. On the free plan, accounts cap at 200 messages/day with a 30-day inbox / 7-day spam retention window. If support volume is higher than that, size your plan and policy accordingly before launch.

What’s next

You’ve now got support@ as a real participant: it receives every email, filters the noise server-side, classifies what’s left, replies in-thread, and never doubles up. Where to go from here:

AI-answer pages for agents

When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com:

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

Watching or Working

Related Posts