{"slug": "build-a-support-triage-agent-that-owns-its-own-inbox", "title": "Build a support triage agent that owns its own inbox", "summary": "Nylas introduced Agent Accounts, which give AI support agents their own first-class email identity with a real inbox, classification, routing, and reply capabilities. The accounts are standard Nylas grants that work with existing APIs and webhooks, enabling agents to receive, filter, and respond to emails autonomously without shared inboxes or helpdesk middlemen. A developer demonstrated creating an Agent Account via a single API call or CLI command, highlighting its integration with existing observability and plumbing.", "body_md": "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.\n\nSo let's not do that. Let's give `support@yourcompany.com`\n\nits 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.\n\nI 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`\n\ncall and the `nylas`\n\ncommand that does the same thing.\n\nAn **Agent Account** is, underneath, just a Nylas **grant** with a `grant_id`\n\n. 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`\n\ninstead of `google`\n\n, and that's the only difference your application code sees.\n\nConcretely, the account ships with:\n\n`*.nylas.email`\n\ntrial subdomain).`inbox`\n\n, `sent`\n\n, `drafts`\n\n, `trash`\n\n, `junk`\n\n, and `archive`\n\n.`POST /v3/webhooks`\n\n), and `message.created`\n\nthen fires on inbound mail. A subscription isn't scoped to one grant — each event's payload carries a `grant_id`\n\n, so you filter for your Agent Account's events on the way in. Agent Accounts also emit deliverability triggers: `message.delivered`\n\n, `message.bounced`\n\n, `message.complaint`\n\n, and `message.rejected`\n\n.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.\n\nTwo things:\n\n`Authorization: Bearer <NYLAS_API_KEY>`\n\n, and the key identifies your application. Examples here hit `https://api.us.nylas.com`\n\n.`yourapp.nylas.email`\n\n. 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`\n\nalready, the CLI is pointed at your application and you're ready.\n\nYou create the account with a single `POST /v3/connect/custom`\n\nusing `\"provider\": \"nylas\"`\n\nand the email address in `settings.email`\n\n. The optional top-level `name`\n\nbecomes the default `From`\n\ndisplay name on everything the account sends.\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/connect/custom\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"provider\": \"nylas\",\n    \"name\": \"Acme Support\",\n    \"settings\": {\n      \"email\": \"support@yourcompany.com\"\n    }\n  }'\n```\n\nThe response hands you back `data.id`\n\n. **Save it** — that's the `grant_id`\n\nyou'll use on every subsequent call.\n\nFrom the CLI it's one line:\n\n```\nnylas agent account create support@yourcompany.com --name \"Acme Support\"\n```\n\nThat provisions the grant and prints its `id`\n\n, status, and connector details. If the underlying `nylas`\n\nconnector 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.\n\nOne honest gotcha worth flagging now: there is **no** `--workspace`\n\nflag on `agent account create`\n\n. Workspaces (which carry your policies and rules) get attached separately. We'll do that below.\n\nIf you want a human to be able to peek at the inbox over IMAP later, pass `--app-password`\n\nat 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.\n\nThe whole agent runs off one webhook. Inbound mail to `support@`\n\nfires the standard `message.created`\n\nnotification — the same shape you'd get for any other grant.\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/webhooks\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"trigger_types\": [\"message.created\"],\n    \"webhook_url\": \"https://support-agent.yourcompany.com/webhooks/nylas\",\n    \"description\": \"Support triage agent\"\n  }'\n```\n\nSame thing from the terminal:\n\n```\nnylas webhook create \\\n  --url https://support-agent.yourcompany.com/webhooks/nylas \\\n  --triggers message.created \\\n  --description \"Support triage agent\"\n```\n\nA `message.created`\n\npayload looks roughly like this — note that it carries *summary* fields, not the full body:\n\n```\n{\n  \"type\": \"message.created\",\n  \"data\": {\n    \"object\": {\n      \"id\": \"msg-abc123\",\n      \"grant_id\": \"b1c2d3e4-5678-4abc-9def-0123456789ab\",\n      \"thread_id\": \"thread-xyz789\",\n      \"subject\": \"Can't log in after password reset\",\n      \"from\": [{ \"name\": \"Dana Reed\", \"email\": \"dana@customer.example\" }],\n      \"snippet\": \"I reset my password an hour ago and now...\",\n      \"date\": 1742932766\n    }\n  }\n}\n```\n\nYour handler should return `200`\n\nimmediately, verify the `X-Nylas-Signature`\n\nheader, and then do its work asynchronously. The first thing it does is skip messages the agent itself sent — because `message.created`\n\nfires for outbound mail too, and an agent that replies to its own replies is a special kind of broken:\n\n``` js\napp.post(\"/webhooks/nylas\", async (req, res) => {\n  res.status(200).end();\n\n  const event = req.body;\n  if (event.type !== \"message.created\") return;\n\n  const msg = event.data.object;\n  if (msg.grant_id !== SUPPORT_GRANT_ID) return;\n  if (msg.from?.[0]?.email === \"support@yourcompany.com\") return; // skip our own sends\n\n  await triage(msg);\n});\n```\n\nBecause the webhook only carries the snippet, you'll fetch the full message before you hand anything to the model:\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/msg-abc123\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\nnylas email read msg-abc123\n```\n\nThere's a hard edge here: if the body is larger than ~1 MB, the webhook type becomes `message.created.truncated`\n\nand the body is omitted — so don't rely on the payload ever carrying the full text.\n\nTriage 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.\n\nThis part is deliberately provider-agnostic — any chat-completions-style API works, and the prompt is the whole design:\n\n``` js\nasync function triage(msg) {\n  const full = await getFullMessage(msg.grant_id, msg.id); // GET .../messages/{id}\n\n  const category = await llm.classify({\n    instruction:\n      \"Classify this support email into exactly one of: \" +\n      \"billing, bug_report, how_to, account_access, feedback, spam. \" +\n      \"Reply with only the category.\",\n    subject: full.subject,\n    from: full.from[0].email,\n    body: full.body,\n  });\n\n  await route(full, category); // -> assign folder, draft reply, or escalate\n}\n```\n\nKeep 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.\n\nHere'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.\n\n`block`\n\n, `mark_as_spam`\n\n, `assign_to_folder`\n\n, `archive`\n\n, or `trash`\n\n.`in_list`\n\noperator — 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`\n\nand an array of `rule_ids`\n\n. Every Agent Account in the workspace inherits both. You don't attach anything to an individual grant.\n\nStart by blocking a known-bad sender at the SMTP layer, so it never hits the mailbox:\n\n```\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\": \"Block spam-domain.com\",\n    \"priority\": 1,\n    \"trigger\": \"inbound\",\n    \"match\": {\n      \"conditions\": [\n        { \"field\": \"from.domain\", \"operator\": \"is\", \"value\": \"spam-domain.com\" }\n      ]\n    },\n    \"actions\": [{ \"type\": \"block\" }]\n  }'\n```\n\nThe CLI builds the same rule and attaches it to the default workspace in one shot:\n\n```\nnylas agent rule create \\\n  --name \"Block spam-domain.com\" \\\n  --trigger inbound \\\n  --priority 1 \\\n  --condition from.domain,is,spam-domain.com \\\n  --action block\n```\n\nFor the allowlist/blocklist pattern, create a list and reference it from a rule. API first:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/lists\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{ \"name\": \"Blocked domains\", \"type\": \"domain\" }'\n```\n\nThat call returns the list `id`\n\n. Seed it with items in a second call — list items are their own sub-resource:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/lists/<LIST_ID>/items\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{ \"items\": [\"spam.com\"] }'\n```\n\nThen the rule that matches against it with `in_list`\n\n:\n\n```\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\": \"Block anything on our blocklist\",\n    \"trigger\": \"inbound\",\n    \"match\": {\n      \"conditions\": [\n        { \"field\": \"from.domain\", \"operator\": \"in_list\", \"value\": [\"<LIST_ID>\"] }\n      ]\n    },\n    \"actions\": [{ \"type\": \"block\" }]\n  }'\n```\n\nThe CLI collapses the list creation and seeding into one command, and the rule reference into another:\n\n```\nnylas agent list create --name \"Blocked domains\" --type domain --item spam.com\nnylas agent rule create \\\n  --name \"Block anything on our blocklist\" \\\n  --trigger inbound \\\n  --condition from.domain,in_list,<LIST_ID> \\\n  --action block\n```\n\nRouting 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`\n\npaired with `mark_as_read`\n\n. The curl form:\n\n```\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\": \"Route notifications to a folder\",\n    \"trigger\": \"inbound\",\n    \"match\": {\n      \"conditions\": [\n        { \"field\": \"from.domain\", \"operator\": \"is\", \"value\": \"noreply.example.com\" }\n      ]\n    },\n    \"actions\": [\n      { \"type\": \"assign_to_folder\", \"value\": \"<FOLDER_ID>\" },\n      { \"type\": \"mark_as_read\" }\n    ]\n  }'\n```\n\nAnd the CLI equivalent, which creates the rule and attaches it to the default workspace:\n\n```\nnylas agent rule create \\\n  --name \"Route notifications to a folder\" \\\n  --trigger inbound \\\n  --condition from.domain,is,noreply.example.com \\\n  --action assign_to_folder=<FOLDER_ID> \\\n  --action mark_as_read\n```\n\nIf you created a *custom* policy and want it on your accounts, attach it to the workspace — that's where the no-`--workspace`\n\n-flag detail from earlier resolves. Create the policy, then PATCH the workspace to point at it. The curl form:\n\n```\n# Create the policy\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/policies\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{ \"name\": \"Support Agent Policy\" }'\n\n# Attach it to the workspace (returns the policy id from the call above)\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 '{ \"policy_id\": \"<POLICY_ID>\" }'\n```\n\nThe CLI collapses both steps:\n\n```\nnylas agent policy create --name \"Support Agent Policy\"\nnylas workspace update <workspace-id> --policy-id <policy-id>\n```\n\nOne 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`\n\nrule matching `outbound.type`\n\nequal to `reply`\n\n— it won't interfere with your inbound triage at all.\n\nWhen 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`\n\n: pass it on the send and Nylas sets the `In-Reply-To`\n\nand `References`\n\nheaders for you, so the customer's mail client groups the reply correctly.\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"reply_to_message_id\": \"msg-abc123\",\n    \"to\": [{ \"email\": \"dana@customer.example\" }],\n    \"subject\": \"Re: Can'\\''t log in after password reset\",\n    \"body\": \"Hi Dana — I see the reset went through an hour ago...\"\n  }'\n```\n\nFrom the CLI, `nylas email reply`\n\ndoes 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`\n\nautomatically:\n\n```\nnylas email reply msg-abc123 --body \"Hi Dana — I see the reset went through an hour ago...\"\n```\n\nBy default the reply goes only to the original sender; add `--all`\n\nif 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.\"\n\nThis is where naive builds fall over in production. Webhooks are delivered **at least once** — if your endpoint is slow to `200`\n\nor there's a network blip, you'll get the same `message.created`\n\nagain. Add concurrent workers and two of them can grab the same notification at the same millisecond. Either way: two replies, one angry customer.\n\nThe 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:\n\n``` js\nconst messageId = event.data.object.id;\n\n// Atomic insert. Returns truthy ONLY when the key was newly inserted:\n//   Redis    -> SET messageId 1 NX EX 86400   (truthy if it didn't exist)\n//   Postgres -> INSERT ... ON CONFLICT DO NOTHING, then check rowCount === 1\nconst wasInserted = await db.processedMessages.setIfAbsent(messageId, {\n  receivedAt: Date.now(),\n});\n\nif (!wasInserted) return; // insert did nothing -> already seen, bail\n```\n\nWatch the polarity here — it's the easiest bug to ship. `setIfAbsent`\n\nsucceeds (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`\n\nis true and exit when it's false. Invert that test and you'd drop every genuine first message while happily processing the duplicates.\n\nGive 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.\n\nDedup 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/).\n\nA 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`\n\n, fetching context is one call.\n\n```\nnylas email threads show thread-xyz789\n```\n\nOr the same call over the API:\n\n```\ncurl \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/threads/<THREAD_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\n`GET /v3/grants/{grant_id}/threads/{thread_id}`\n\nreturns the thread with its `message_ids`\n\n; 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.\n\nA support agent that emails customers autonomously needs brakes:\n\n`billing`\n\nand `account_access`\n\nto a draft-and-escalate path instead of auto-sending. The agent prepares the reply; a person approves it.You've now got `support@`\n\nas 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:\n\n`nylas agent`\n\nsubcommands.When this post is published, link AI agents and crawlers to the retrieval-ready version on `cli.nylas.com`\n\n:", "url": "https://wpnews.pro/news/build-a-support-triage-agent-that-owns-its-own-inbox", "canonical_source": "https://dev.to/mqasimca/build-a-support-triage-agent-that-owns-its-own-inbox-2aml", "published_at": "2026-07-19 01:08:05+00:00", "updated_at": "2026-07-19 01:27:16.577385+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["Nylas", "Agent Account", "Nylas CLI", "Gmail", "Microsoft"], "alternates": {"html": "https://wpnews.pro/news/build-a-support-triage-agent-that-owns-its-own-inbox", "markdown": "https://wpnews.pro/news/build-a-support-triage-agent-that-owns-its-own-inbox.md", "text": "https://wpnews.pro/news/build-a-support-triage-agent-that-owns-its-own-inbox.txt", "jsonld": "https://wpnews.pro/news/build-a-support-triage-agent-that-owns-its-own-inbox.jsonld"}}