# Route inbound mail to the right agent automatically

> Source: <https://dev.to/mqasimca/route-inbound-mail-to-the-right-agent-automatically-36m>
> Published: 2026-06-30 21:09:55+00:00

Most "AI email agent" demos run one model against one mailbox: every message that lands gets the same prompt, the same context, the same handler. That's fine for a toy. It falls apart the moment your inbox is actually doing several different jobs — a billing question, a security report, and a sales lead all arriving at `support@yourcompany.com`

within the same minute, each wanting a different specialist.

The naive fix is to make the one agent smarter: a giant system prompt that classifies, then branches into sub-behaviors. You end up with a monolith that's hard to test, hard to rate-limit per concern, and impossible to scale independently.

Here's the thing most people miss: *one mailbox can feed several agents if you route mail into per-specialty queues before the agent ever sees it.* You don't need a smarter agent. You need a smarter inbox. With a Nylas **Agent Account** the triage splits along one clean line. When you can route by *sender* — who the mail is from — inbound **Rules** do it server-side, sorting mail into per-specialty **folders** before it even reaches the mailbox. When you need to route by *subject or content*, a Rule can't see that, so your own worker classifies the message after a `message.created`

webhook fires and moves it. Either way, a *different* worker drains each folder. The billing agent only ever sees billing mail. The security agent only ever sees abuse reports. Clean separation, no central classifier to babysit.

This is deliberately different from agent-to-agent handoff, where one agent emails another to delegate. This is *fan-out*: one inbound stream, split by rule into parallel queues, each consumed by a specialist. Mail arrives once and lands in exactly the right place.

`billing`

or `security`

or `sales`

. No classifier service to deploy, scale, or pay an LLM bill for.`grant_id`

. Folders and messages are the same grant-scoped endpoints you'd use against any connected mailbox. (Webhooks are the one exception — they're application-scoped: one subscription for every grant, filtered by `grant_id`

.) If you've listed messages before, you already know the API.A single agent that classifies-then-branches couples three things that should be separate: the routing decision, the specialist logic, and the scaling unit. When sales volume spikes, you scale the whole thing. When the billing prompt regresses, you redeploy everyone. And every message pays the classification tax — an LLM call just to decide *which* LLM call to make next.

Rule-based fan-out moves the routing decision to the platform, where it's deterministic, auditable, and free. `from.domain is stripe.com`

is not a judgment call. It either matches or it doesn't, every time, and Nylas records *why* it matched so you can answer "where did this go?" without log spelunking. Your agents shrink to one job each, which is exactly the size that's easy to test.

The honest tradeoff, stated plainly because it shapes the whole design: inbound rules match on **sender fields only** — `from.address`

, `from.domain`

, `from.tld`

, with operators `is`

, `is_not`

, `contains`

, and `in_list`

. They do not read the subject line or the body. (Recipient fields and `outbound.type`

exist, but only for *outbound* rules — they're useless for inbound triage.) So you get two routing modes:

`message.created`

webhook, fetch and classify the message yourself, then move it into the right folder (or dispatch it to a worker directly). This is Step 4's content path.Most teams use both. Lean on rules for the cheap, unambiguous cases — your payment processor, your bug-bounty platform — and reserve app-side classification for the genuinely fuzzy ones.

You'll need a Nylas application, an API key, and an Agent Account on a registered domain. If you haven't provisioned one yet, the [Agent Accounts docs](https://developer.nylas.com/docs/v3/agent-accounts/) walk through `POST /v3/connect/custom`

(and `nylas agent account create <email>`

). For the routing logic below, the only conceptual primitives you need are **folders**, **inbound Rules**, and the **workspace** that activates those rules.

I work on the CLI, so the terminal commands below are the exact ones I reach for. Every one is paired with its raw HTTP call — pick whichever fits your stack. Examples use `https://api.us.nylas.com`

and a `Bearer <NYLAS_API_KEY>`

header throughout. Where you see `<NYLAS_GRANT_ID>`

, that's your Agent Account's grant.

One thing worth saying up front: Rules and Folders sit at two different scopes. Folders are **grant-scoped** — they live inside the Agent Account's mailbox. Rules are **application-scoped** — they have no grant in the path, and a rule does nothing until a **workspace** references it. Keep that split in your head and the three steps below make sense.

Each specialist agent gets its own folder. Create one for billing, one for security, one for sales — whatever your specialties are. The folder lives in the Agent Account's mailbox, so this is a grant-scoped call.

```
# CLI
nylas email folders create "billing" <NYLAS_GRANT_ID>
nylas email folders create "security" <NYLAS_GRANT_ID>
nylas email folders create "sales" <NYLAS_GRANT_ID>
# API
curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/folders" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{ "name": "billing" }'
```

Each call returns the new folder with an `id`

. **Save those IDs** — `<BILLING_FOLDER_ID>`

, `<SECURITY_FOLDER_ID>`

, `<SALES_FOLDER_ID>`

. The rules in the next step reference folders by ID, not by name, and so do the worker queries at the end. Don't hardcode folder *names* anywhere downstream; the ID is the stable handle.

To confirm what you created, list the folders back:

```
# CLI
nylas email folders list <NYLAS_GRANT_ID>
# API
curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/folders" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
```

Now the triage logic. An inbound **Rule** matches mail on receipt and runs an action. The action you want here is `assign_to_folder`

, which drops the message straight into a specialist's queue. Match conditions for inbound rules use **sender fields only** — `from.address`

, `from.domain`

, and `from.tld`

, with operators `is`

, `is_not`

, `contains`

, and `in_list`

. (Recipient fields and `outbound.type`

are outbound-only; don't reach for them here.)

Here's a rule that routes everything from your payment processor into the billing folder:

```
# CLI
nylas agent rule create \
  --name "Stripe to billing" \
  --trigger inbound \
  --condition from.domain,is,stripe.com \
  --action assign_to_folder=<BILLING_FOLDER_ID>
# API
curl --request POST \
  --url "https://api.us.nylas.com/v3/rules" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Stripe to billing",
    "trigger": "inbound",
    "match": {
      "conditions": [
        { "field": "from.domain", "operator": "is", "value": "stripe.com" }
      ]
    },
    "actions": [
      { "type": "assign_to_folder", "value": "<BILLING_FOLDER_ID>" }
    ]
  }'
```

A rule can OR several conditions together with `operator: "any"`

. Security reports rarely come from a single domain, so match a couple of patterns at once — anything containing `abuse@`

in the sender address, plus your bug-bounty platform's domain:

```
# CLI — repeat --condition; set the match operator to "any" for OR
nylas agent rule create \
  --name "Security reports to security folder" \
  --trigger inbound \
  --match-operator any \
  --condition from.address,contains,abuse@ \
  --condition from.domain,contains,hackerone.com \
  --action assign_to_folder=<SECURITY_FOLDER_ID>
# API
curl --request POST \
  --url "https://api.us.nylas.com/v3/rules" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Security reports to security folder",
    "trigger": "inbound",
    "match": {
      "operator": "any",
      "conditions": [
        { "field": "from.address", "operator": "contains", "value": "abuse@" },
        { "field": "from.domain", "operator": "contains", "value": "hackerone.com" }
      ]
    },
    "actions": [
      { "type": "assign_to_folder", "value": "<SECURITY_FOLDER_ID>" }
    ]
  }'
```

Each `create`

returns the rule with an `id`

. **Save every rule ID** — you attach all of them to the workspace in the next step.

Two details that bite people:

`priority`

order, lowest number first (range 0–1000, default 10). Put your specific rules (`is`

, or `in_list`

against a tight list) ahead of broad `contains`

rules so a precise match wins. The first `block`

action is terminal, but `assign_to_folder`

is not — so if two folder rules could both match, ordering decides which folder wins.If your block/route lists change often — say, a roster of vendor domains that all go to billing — put them in a **List** and reference it with the `in_list`

operator instead of hardcoding values in the rule. Non-engineers can then update the list without touching rule definitions.

This is the step everyone forgets. **A rule is inert until a workspace references it.** Creating a rule via `POST /v3/rules`

registers it but wires it to nothing. To make it run, add its ID to the workspace's `rule_ids`

array. Every Agent Account in that workspace then evaluates those rules on receipt.

The CLI's `nylas agent rule create`

attaches the new rule to your default agent workspace for you, which is convenient — but if you created rules through the raw API, or you're managing a custom workspace, you attach them explicitly. One array carries both inbound and outbound rules; Nylas filters by `trigger`

at evaluation time, so listing them all together is fine.

```
# CLI — attach all three rule IDs at once (comma-separated)
nylas workspace update <WORKSPACE_ID> \
  --rules-ids <BILLING_RULE_ID>,<SECURITY_RULE_ID>,<SALES_RULE_ID>
# API
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 '{
    "rule_ids": ["<BILLING_RULE_ID>", "<SECURITY_RULE_ID>", "<SALES_RULE_ID>"]
  }'
```

The `rule_ids`

array is a full replacement, not an append — send the complete set you want active every time. After this PATCH, inbound mail to the Agent Account gets sorted on arrival. Send yourself a test message from a matching sender and watch it land in the right folder.

The triage is done in the platform. Now each specialist worker only has to read its own folder. There are two honest ways to do this, and the right choice depends on how fast you need to react.

Each worker lists messages filtered to its folder, by ID, using the `in`

query parameter. The billing worker polls `<BILLING_FOLDER_ID>`

, the security worker polls `<SECURITY_FOLDER_ID>`

, and they never see each other's mail.

```
# CLI — the security worker drains its folder
nylas email list <NYLAS_GRANT_ID> --folder <SECURITY_FOLDER_ID> --unread
# API
curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages?in=<SECURITY_FOLDER_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
```

The CLI's `--folder`

flag accepts a folder ID directly and maps to that `in`

query param. Filter on `--unread`

so you only pick up messages you haven't handled yet, then mark each one read once you've processed it so the next poll skips it. Marking read is a separate write — a `PUT`

to the message, not a side effect of the `GET`

that fetched it:

```
# CLI — mark a processed message read
nylas email read <MESSAGE_ID> <NYLAS_GRANT_ID> --mark-read
# API — mark a processed message read
curl --request PUT \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/<MESSAGE_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{ "unread": false }'
```

Polling is dead simple, easy to reason about, and survives a worker restart with zero coordination — the folder *is* the queue. The cost is latency: you react on your poll interval, not the instant mail arrives.

Inbound mail also fires the standard `message.created`

webhook. Critically, **webhooks are application-scoped, not grant-scoped** — you subscribe once at the app level (`POST /v3/webhooks`

) and events for every grant arrive at that one endpoint, each payload carrying a `grant_id`

. So your workers don't each get their own webhook. You run one receiver that fans out internally based on which folder the message landed in.

The flow: the rule assigns the message to a folder *before* the webhook fires, so the notification's message already carries its folder assignment. Your receiver reads which folder the message is in and dispatches to the matching specialist.

One important caveat on the payload: **don't rely on the webhook body for the message content.** Nylas's docs disagree on whether the body arrives inline — and for a large message the event type becomes `message.created.truncated`

and you have to re-fetch regardless. So write the safe path: fetch the full message by ID with `GET /v3/grants/{grant_id}/messages/{message_id}`

whenever you need the subject, folder list, or body, and branch on `message.created.truncated`

.

```
// One app-scoped receiver fans out to specialist workers by folder.
app.post("/webhooks/nylas", express.raw({ type: "*/*" }), async (req, res) => {
  if (!verifyNylasSignature(req.body, req.get("X-Nylas-Signature"))) {
    return res.status(401).end();
  }
  res.status(200).end(); // acknowledge within 10 seconds, then work

  const evt = JSON.parse(req.body);
  // Dedupe on the top-level notification id (constant across retries).
  if (await seen(evt.id)) return;

  const { grant_id, id: messageId } = evt.data.object;
  // Fetch by id — don't trust the payload body; handles .truncated too.
  const msg = await getMessage(grant_id, messageId);

  if (msg.folders.includes(BILLING_FOLDER_ID)) billingAgent(msg);
  else if (msg.folders.includes(SECURITY_FOLDER_ID)) securityAgent(msg);
  else if (msg.folders.includes(SALES_FOLDER_ID)) salesAgent(msg);
});
```

Two reliability notes the SRE in me won't let me skip. Nylas guarantees **at-least-once** delivery — the same event can arrive up to three times — so **dedupe on the top-level notification id**, which stays constant across all retries of one event. (The inner

`data.object.id`

identifies the `X-Nylas-Signature`

header`crypto.timingSafeEqual`

, confirm both buffers are equal length first, because it throws on a mismatch. The CLI's `nylas webhook verify`

checks this locally while you're building.Option B reacts in real time and scales to one receiver, but you own the dispatch and the dedup. Option A trades latency for simplicity. I'd ship Option A first, then move to B only if poll latency becomes a real complaint.

Everything above routes by sender, which a rule handles for you. But sometimes the routing signal lives in the *subject or body* — a mailbox where everything arrives from a single relay address, or a "support" stream where the topic, not the sender, decides the specialist. A rule can't see that. This is where your app does the work a rule can't.

The shape is the webhook receiver from Option B, with one addition: after you fetch the message, *you* classify it (a keyword check, a regex on the subject, or an LLM call for the genuinely fuzzy cases), then **move** it into the right folder. Moving a message is a `PUT`

that rewrites its `folders`

array — the same write the rule's `assign_to_folder`

performs, just driven by your code instead of a server-side condition.

```
# CLI — move a classified message into the security folder
nylas email move <MESSAGE_ID> <NYLAS_GRANT_ID> --folder <SECURITY_FOLDER_ID>
# API — move a classified message into the security folder
curl --request PUT \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/<MESSAGE_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{ "folders": ["<SECURITY_FOLDER_ID>"] }'
```

Once it's moved, the polling worker from Option A picks it up exactly as if a rule had placed it there — the folder is a uniform queue regardless of who filled it. If you'd rather skip the move entirely and just hand the message straight to the right worker in-process, that works too; the folder move is what gives you a durable, restartable queue and an audit trail. The two patterns compose cleanly: rules pre-sort the easy mail, and your classifier mops up whatever needs a human-grade read of the content.

A note on what *isn't* available: there's no custom-metadata channel to stamp a routing decision onto a message for your workers to read back later. Custom metadata isn't supported here. The folder assignment *is* your routing signal — that's the whole point of pushing triage into rules. Lean on the folder, not a side channel.

`rule_ids`

on the workspace before anything else. A rule that exists but isn't attached looks fine in `nylas agent rule list`

and does absolutely nothing.`rule_ids`

replaces, it doesn't append.`in`

filter both key on folder ID. Renaming a folder doesn't break anything; deleting and recreating one will, because the ID changes.`GET /v3/grants/{grant_id}/rule-evaluations`

lists every evaluation, newest first, with the matched rule IDs and the actions applied. It's the fastest answer to "why did this land in sales?"`in_list`

matching`nylas agent rule`

, `nylas workspace`

, and `nylas email folders`
