DEV Community

Qasim
Qasim

Posted on

Route inbound mail to the right agent automatically

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.

What you get

  • Triage in the platform, not in your code. Rules evaluate on receipt. By the time your worker polls, the mail is already in billing or security or sales. No classifier service to deploy, scale, or pay an LLM bill for.
  • Independent workers. Each specialist is its own process. Restart the sales agent without touching billing. Give the security agent a tighter SLA. Scale the busy one horizontally.
  • One mailbox, one domain reputation. Customers still write to a single address. The fan-out is invisible to them.
  • Nothing new on the data plane. An Agent Account is just a grant with a 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.

Why this beats one mega-agent

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

  • Sender-based routing → Rules. Deterministic, server-side, evaluated before the mail hits the mailbox, and free. This is Steps 1–3.
  • Subject- or content-based routing → your app. A rule can't classify "is this an angry customer?" or "does the subject say INVOICE?" For that you receive the 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.

Before you begin

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.

Step 1 — Create a folder per specialty

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>
Enter fullscreen mode Exit fullscreen mode
# 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" }'
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
# API
curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/folders" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
Enter fullscreen mode Exit fullscreen mode

Step 2 — Create an inbound Rule per folder

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 onlyfrom.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>
Enter fullscreen mode Exit fullscreen mode
# 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>" }
    ]
  }'
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
# 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>" }
    ]
  }'
Enter fullscreen mode Exit fullscreen mode

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 and order. Rules run in 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.
  • Subject and content are off-limits here. Worth repeating because it's the most common misread: inbound rules match the sender, never the subject line or body. If a customer mails support directly and the only signal is "the subject says INVOICE," a rule can't see it. That case lives in the app-side path in Step 4 ("Routing by subject or content"), where your worker fetches the message, reads the subject itself, and moves it. Route by sender with rules where you can; fall back to content classification only where you must.

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.

Step 3 — Activate the rules on the workspace

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>
Enter fullscreen mode Exit fullscreen mode
# 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>"]
  }'
Enter fullscreen mode Exit fullscreen mode

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.

Step 4 — Each specialist agent drains its 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.

Option A — Poll the folder (simple, the one I'd start with)

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
Enter fullscreen mode Exit fullscreen mode
# 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>"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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 }'
Enter fullscreen mode Exit fullscreen mode

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.

Option B — Share one webhook, branch on the folder

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);
});
Enter fullscreen mode Exit fullscreen mode

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 message; guard on it too if you want to avoid acting twice on the same mail.) And verify the X-Nylas-Signature header — a hex HMAC-SHA256 of the raw request body using your webhook secret — before you trust anything. When you compare with a constant-time check like 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.

Routing by subject or content — the app-side path

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>
Enter fullscreen mode Exit fullscreen mode
# 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>"] }'
Enter fullscreen mode Exit fullscreen mode

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.

Guardrails and gotchas

  • The workspace attachment is the live wire. If routing isn't happening, check 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. Always PATCH the complete set. Forgetting an existing ID silently detaches that rule.
  • Folder IDs, not names. Rules and the in filter both key on folder ID. Renaming a folder doesn't break anything; deleting and recreating one will, because the ID changes.
  • Audit when a route surprises you. 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?"
  • Sender-only matching is a feature, not a bug. It's deterministic and free. The instant you need body or subject understanding, that's a content-classification job for a worker, not a rule — don't try to bend rules into reading content they can't see.

What's next

Top comments (0)