# Build a support triage agent that owns its own inbox

> Source: <https://dev.to/mqasimca/build-a-support-triage-agent-that-owns-its-own-inbox-2aml>
> Published: 2026-07-19 01:08:05+00:00

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.

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:

`*.nylas.email`

trial subdomain).`inbox`

, `sent`

, `drafts`

, `trash`

, `junk`

, and `archive`

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

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

Two things:

`Authorization: Bearer <NYLAS_API_KEY>`

, and the key identifies your application. Examples here hit `https://api.us.nylas.com`

.`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 If you've run `nylas init`

already, the CLI is pointed at your application and you're ready.

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 <NYLAS_API_KEY>" \
  --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.

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 <NYLAS_API_KEY>" \
  --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:

``` js
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/<NYLAS_GRANT_ID>/messages/msg-abc123" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
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.

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:

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

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.

`block`

, `mark_as_spam`

, `assign_to_folder`

, `archive`

, or `trash`

.`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 <NYLAS_API_KEY>" \
  --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 <NYLAS_API_KEY>" \
  --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/<LIST_ID>/items" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --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 <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Block anything on our blocklist",
    "trigger": "inbound",
    "match": {
      "conditions": [
        { "field": "from.domain", "operator": "in_list", "value": ["<LIST_ID>"] }
      ]
    },
    "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,<LIST_ID> \
  --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 <NYLAS_API_KEY>" \
  --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": "<FOLDER_ID>" },
      { "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=<FOLDER_ID> \
  --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 <NYLAS_API_KEY>" \
  --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/<WORKSPACE_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{ "policy_id": "<POLICY_ID>" }'
```

The CLI collapses both steps:

```
nylas agent policy create --name "Support Agent Policy"
nylas workspace update <workspace-id> --policy-id <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.

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/<NYLAS_GRANT_ID>/messages/send" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --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."

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:

``` js
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](https://developer.nylas.com/docs/cookbook/agent-accounts/prevent-duplicate-replies/).

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/<NYLAS_GRANT_ID>/threads/<THREAD_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
```

`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](https://developer.nylas.com/docs/cookbook/agent-accounts/multi-turn-conversations/) builds the full state machine around this if you need conversations that span days.

A support agent that emails customers autonomously needs brakes:

`billing`

and `account_access`

to a draft-and-escalate path instead of auto-sending. The agent prepares the reply; a person approves it.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:

`nylas agent`

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

:
