cd /news/ai-agents/route-inbound-mail-to-the-right-agen… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-45566] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=↑ positive

Route inbound mail to the right agent automatically

Nylas introduces a rule-based email routing system that fans inbound mail into per-specialty folders before any agent processes it, avoiding the need for a monolithic classifier. By using deterministic rules on sender fields, teams can route billing, security, and sales emails to separate agents without LLM overhead. The approach supports both server-side routing via Nylas Rules and app-side classification via webhooks for content-based routing.

read13 min views1 publishedJun 30, 2026

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

nylas email folders create "billing" <NYLAS_GRANT_ID>
nylas email folders create "security" <NYLAS_GRANT_ID>
nylas email folders create "sales" <NYLAS_GRANT_ID>
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:

nylas email folders list <NYLAS_GRANT_ID>
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:

nylas agent rule create \
  --name "Stripe to billing" \
  --trigger inbound \
  --condition from.domain,is,stripe.com \
  --action assign_to_folder=<BILLING_FOLDER_ID>
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:

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

nylas workspace update <WORKSPACE_ID> \
  --rules-ids <BILLING_RULE_ID>,<SECURITY_RULE_ID>,<SALES_RULE_ID>
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.

nylas email list <NYLAS_GRANT_ID> --folder <SECURITY_FOLDER_ID> --unread
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:

nylas email read <MESSAGE_ID> <NYLAS_GRANT_ID> --mark-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

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

nylas email move <MESSAGE_ID> <NYLAS_GRANT_ID> --folder <SECURITY_FOLDER_ID>
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

matchingnylas agent rule

, nylas workspace

, and nylas email folders

── more in #ai-agents 4 stories Β· sorted by recency
── more on @nylas 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/route-inbound-mail-t…] indexed:0 read:13min 2026-06-30 Β· β€”