{"slug": "route-inbound-mail-to-the-right-agent-automatically", "title": "Route inbound mail to the right agent automatically", "summary": "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.", "body_md": "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`\n\nwithin the same minute, each wanting a different specialist.\n\nThe 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.\n\nHere'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`\n\nwebhook 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.\n\nThis 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.\n\n`billing`\n\nor `security`\n\nor `sales`\n\n. No classifier service to deploy, scale, or pay an LLM bill for.`grant_id`\n\n. 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`\n\n.) 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.\n\nRule-based fan-out moves the routing decision to the platform, where it's deterministic, auditable, and free. `from.domain is stripe.com`\n\nis 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.\n\nThe honest tradeoff, stated plainly because it shapes the whole design: inbound rules match on **sender fields only** — `from.address`\n\n, `from.domain`\n\n, `from.tld`\n\n, with operators `is`\n\n, `is_not`\n\n, `contains`\n\n, and `in_list`\n\n. They do not read the subject line or the body. (Recipient fields and `outbound.type`\n\nexist, but only for *outbound* rules — they're useless for inbound triage.) So you get two routing modes:\n\n`message.created`\n\nwebhook, 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.\n\nYou'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`\n\n(and `nylas agent account create <email>`\n\n). For the routing logic below, the only conceptual primitives you need are **folders**, **inbound Rules**, and the **workspace** that activates those rules.\n\nI 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`\n\nand a `Bearer <NYLAS_API_KEY>`\n\nheader throughout. Where you see `<NYLAS_GRANT_ID>`\n\n, that's your Agent Account's grant.\n\nOne 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.\n\nEach 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.\n\n```\n# CLI\nnylas email folders create \"billing\" <NYLAS_GRANT_ID>\nnylas email folders create \"security\" <NYLAS_GRANT_ID>\nnylas email folders create \"sales\" <NYLAS_GRANT_ID>\n# API\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/folders\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{ \"name\": \"billing\" }'\n```\n\nEach call returns the new folder with an `id`\n\n. **Save those IDs** — `<BILLING_FOLDER_ID>`\n\n, `<SECURITY_FOLDER_ID>`\n\n, `<SALES_FOLDER_ID>`\n\n. 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.\n\nTo confirm what you created, list the folders back:\n\n```\n# CLI\nnylas email folders list <NYLAS_GRANT_ID>\n# API\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/folders\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\nNow the triage logic. An inbound **Rule** matches mail on receipt and runs an action. The action you want here is `assign_to_folder`\n\n, which drops the message straight into a specialist's queue. Match conditions for inbound rules use **sender fields only** — `from.address`\n\n, `from.domain`\n\n, and `from.tld`\n\n, with operators `is`\n\n, `is_not`\n\n, `contains`\n\n, and `in_list`\n\n. (Recipient fields and `outbound.type`\n\nare outbound-only; don't reach for them here.)\n\nHere's a rule that routes everything from your payment processor into the billing folder:\n\n```\n# CLI\nnylas agent rule create \\\n  --name \"Stripe to billing\" \\\n  --trigger inbound \\\n  --condition from.domain,is,stripe.com \\\n  --action assign_to_folder=<BILLING_FOLDER_ID>\n# API\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/rules\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"name\": \"Stripe to billing\",\n    \"trigger\": \"inbound\",\n    \"match\": {\n      \"conditions\": [\n        { \"field\": \"from.domain\", \"operator\": \"is\", \"value\": \"stripe.com\" }\n      ]\n    },\n    \"actions\": [\n      { \"type\": \"assign_to_folder\", \"value\": \"<BILLING_FOLDER_ID>\" }\n    ]\n  }'\n```\n\nA rule can OR several conditions together with `operator: \"any\"`\n\n. Security reports rarely come from a single domain, so match a couple of patterns at once — anything containing `abuse@`\n\nin the sender address, plus your bug-bounty platform's domain:\n\n```\n# CLI — repeat --condition; set the match operator to \"any\" for OR\nnylas agent rule create \\\n  --name \"Security reports to security folder\" \\\n  --trigger inbound \\\n  --match-operator any \\\n  --condition from.address,contains,abuse@ \\\n  --condition from.domain,contains,hackerone.com \\\n  --action assign_to_folder=<SECURITY_FOLDER_ID>\n# API\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/rules\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"name\": \"Security reports to security folder\",\n    \"trigger\": \"inbound\",\n    \"match\": {\n      \"operator\": \"any\",\n      \"conditions\": [\n        { \"field\": \"from.address\", \"operator\": \"contains\", \"value\": \"abuse@\" },\n        { \"field\": \"from.domain\", \"operator\": \"contains\", \"value\": \"hackerone.com\" }\n      ]\n    },\n    \"actions\": [\n      { \"type\": \"assign_to_folder\", \"value\": \"<SECURITY_FOLDER_ID>\" }\n    ]\n  }'\n```\n\nEach `create`\n\nreturns the rule with an `id`\n\n. **Save every rule ID** — you attach all of them to the workspace in the next step.\n\nTwo details that bite people:\n\n`priority`\n\norder, lowest number first (range 0–1000, default 10). Put your specific rules (`is`\n\n, or `in_list`\n\nagainst a tight list) ahead of broad `contains`\n\nrules so a precise match wins. The first `block`\n\naction is terminal, but `assign_to_folder`\n\nis 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`\n\noperator instead of hardcoding values in the rule. Non-engineers can then update the list without touching rule definitions.\n\nThis is the step everyone forgets. **A rule is inert until a workspace references it.** Creating a rule via `POST /v3/rules`\n\nregisters it but wires it to nothing. To make it run, add its ID to the workspace's `rule_ids`\n\narray. Every Agent Account in that workspace then evaluates those rules on receipt.\n\nThe CLI's `nylas agent rule create`\n\nattaches 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`\n\nat evaluation time, so listing them all together is fine.\n\n```\n# CLI — attach all three rule IDs at once (comma-separated)\nnylas workspace update <WORKSPACE_ID> \\\n  --rules-ids <BILLING_RULE_ID>,<SECURITY_RULE_ID>,<SALES_RULE_ID>\n# API\ncurl --request PATCH \\\n  --url \"https://api.us.nylas.com/v3/workspaces/<WORKSPACE_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"rule_ids\": [\"<BILLING_RULE_ID>\", \"<SECURITY_RULE_ID>\", \"<SALES_RULE_ID>\"]\n  }'\n```\n\nThe `rule_ids`\n\narray 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.\n\nThe 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.\n\nEach worker lists messages filtered to its folder, by ID, using the `in`\n\nquery parameter. The billing worker polls `<BILLING_FOLDER_ID>`\n\n, the security worker polls `<SECURITY_FOLDER_ID>`\n\n, and they never see each other's mail.\n\n```\n# CLI — the security worker drains its folder\nnylas email list <NYLAS_GRANT_ID> --folder <SECURITY_FOLDER_ID> --unread\n# API\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages?in=<SECURITY_FOLDER_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\nThe CLI's `--folder`\n\nflag accepts a folder ID directly and maps to that `in`\n\nquery param. Filter on `--unread`\n\nso 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`\n\nto the message, not a side effect of the `GET`\n\nthat fetched it:\n\n```\n# CLI — mark a processed message read\nnylas email read <MESSAGE_ID> <NYLAS_GRANT_ID> --mark-read\n# API — mark a processed message read\ncurl --request PUT \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/<MESSAGE_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{ \"unread\": false }'\n```\n\nPolling 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.\n\nInbound mail also fires the standard `message.created`\n\nwebhook. Critically, **webhooks are application-scoped, not grant-scoped** — you subscribe once at the app level (`POST /v3/webhooks`\n\n) and events for every grant arrive at that one endpoint, each payload carrying a `grant_id`\n\n. 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.\n\nThe 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.\n\nOne 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`\n\nand 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}`\n\nwhenever you need the subject, folder list, or body, and branch on `message.created.truncated`\n\n.\n\n```\n// One app-scoped receiver fans out to specialist workers by folder.\napp.post(\"/webhooks/nylas\", express.raw({ type: \"*/*\" }), async (req, res) => {\n  if (!verifyNylasSignature(req.body, req.get(\"X-Nylas-Signature\"))) {\n    return res.status(401).end();\n  }\n  res.status(200).end(); // acknowledge within 10 seconds, then work\n\n  const evt = JSON.parse(req.body);\n  // Dedupe on the top-level notification id (constant across retries).\n  if (await seen(evt.id)) return;\n\n  const { grant_id, id: messageId } = evt.data.object;\n  // Fetch by id — don't trust the payload body; handles .truncated too.\n  const msg = await getMessage(grant_id, messageId);\n\n  if (msg.folders.includes(BILLING_FOLDER_ID)) billingAgent(msg);\n  else if (msg.folders.includes(SECURITY_FOLDER_ID)) securityAgent(msg);\n  else if (msg.folders.includes(SALES_FOLDER_ID)) salesAgent(msg);\n});\n```\n\nTwo 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\n\n`data.object.id`\n\nidentifies the `X-Nylas-Signature`\n\nheader`crypto.timingSafeEqual`\n\n, confirm both buffers are equal length first, because it throws on a mismatch. The CLI's `nylas webhook verify`\n\nchecks 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.\n\nEverything 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.\n\nThe 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`\n\nthat rewrites its `folders`\n\narray — the same write the rule's `assign_to_folder`\n\nperforms, just driven by your code instead of a server-side condition.\n\n```\n# CLI — move a classified message into the security folder\nnylas email move <MESSAGE_ID> <NYLAS_GRANT_ID> --folder <SECURITY_FOLDER_ID>\n# API — move a classified message into the security folder\ncurl --request PUT \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/<MESSAGE_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{ \"folders\": [\"<SECURITY_FOLDER_ID>\"] }'\n```\n\nOnce 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.\n\nA 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.\n\n`rule_ids`\n\non the workspace before anything else. A rule that exists but isn't attached looks fine in `nylas agent rule list`\n\nand does absolutely nothing.`rule_ids`\n\nreplaces, it doesn't append.`in`\n\nfilter 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`\n\nlists 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`\n\nmatching`nylas agent rule`\n\n, `nylas workspace`\n\n, and `nylas email folders`", "url": "https://wpnews.pro/news/route-inbound-mail-to-the-right-agent-automatically", "canonical_source": "https://dev.to/mqasimca/route-inbound-mail-to-the-right-agent-automatically-36m", "published_at": "2026-06-30 21:09:55+00:00", "updated_at": "2026-06-30 21:18:52.887565+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "natural-language-processing"], "entities": ["Nylas", "Nylas Agent Account", "Nylas Rules", "Nylas API"], "alternates": {"html": "https://wpnews.pro/news/route-inbound-mail-to-the-right-agent-automatically", "markdown": "https://wpnews.pro/news/route-inbound-mail-to-the-right-agent-automatically.md", "text": "https://wpnews.pro/news/route-inbound-mail-to-the-right-agent-automatically.txt", "jsonld": "https://wpnews.pro/news/route-inbound-mail-to-the-right-agent-automatically.jsonld"}}