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.
So let's not do that. Let's give support@yourcompany.com
its 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.
I 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
call and the nylas
command that does the same thing.
An Agent Account is, underneath, just a Nylas grant with a grant_id
. 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
instead of google
, and that's the only difference your application code sees.
Concretely, the account ships with:
*.nylas.email
trial subdomain).inbox
, sent
, drafts
, trash
, junk
, and archive
.POST /v3/webhooks
), and message.created
then fires on inbound mail. A subscription isn't scoped to one grant β each event's payload carries a grant_id
, so you filter for your Agent Account's events on the way in. Agent Accounts also emit deliverability triggers: message.delivered
, message.bounced
, message.complaint
, and message.rejected
.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.
Two things:
Authorization: Bearer <NYLAS_API_KEY>
, and the key identifies your application. Examples here hit https://api.us.nylas.com
.yourapp.nylas.email
. 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
already, the CLI is pointed at your application and you're ready.
You create the account with a single POST /v3/connect/custom
using "provider": "nylas"
and the email address in settings.email
. The optional top-level name
becomes the default From
display name on everything the account sends.
curl --request POST \
--url "https://api.us.nylas.com/v3/connect/custom" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"provider": "nylas",
"name": "Acme Support",
"settings": {
"email": "support@yourcompany.com"
}
}'
The response hands you back data.id
. Save it β that's the grant_id
you'll use on every subsequent call.
From the CLI it's one line:
nylas agent account create support@yourcompany.com --name "Acme Support"
That provisions the grant and prints its id
, status, and connector details. If the underlying nylas
connector 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.
One honest gotcha worth flagging now: there is no --workspace
flag on agent account create
. Workspaces (which carry your policies and rules) get attached separately. We'll do that below.
If you want a human to be able to peek at the inbox over IMAP later, pass --app-password
at 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.
The whole agent runs off one webhook. Inbound mail to support@
fires the standard message.created
notification β the same shape you'd get for any other grant.
curl --request POST \
--url "https://api.us.nylas.com/v3/webhooks" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"trigger_types": ["message.created"],
"webhook_url": "https://support-agent.yourcompany.com/webhooks/nylas",
"description": "Support triage agent"
}'
Same thing from the terminal:
nylas webhook create \
--url https://support-agent.yourcompany.com/webhooks/nylas \
--triggers message.created \
--description "Support triage agent"
A message.created
payload looks roughly like this β note that it carries summary fields, not the full body:
{
"type": "message.created",
"data": {
"object": {
"id": "msg-abc123",
"grant_id": "b1c2d3e4-5678-4abc-9def-0123456789ab",
"thread_id": "thread-xyz789",
"subject": "Can't log in after password reset",
"from": [{ "name": "Dana Reed", "email": "dana@customer.example" }],
"snippet": "I reset my password an hour ago and now...",
"date": 1742932766
}
}
}
Your handler should return 200
immediately, verify the X-Nylas-Signature
header, and then do its work asynchronously. The first thing it does is skip messages the agent itself sent β because message.created
fires for outbound mail too, and an agent that replies to its own replies is a special kind of broken:
app.post("/webhooks/nylas", async (req, res) => {
res.status(200).end();
const event = req.body;
if (event.type !== "message.created") return;
const msg = event.data.object;
if (msg.grant_id !== SUPPORT_GRANT_ID) return;
if (msg.from?.[0]?.email === "support@yourcompany.com") return; // skip our own sends
await triage(msg);
});
Because the webhook only carries the snippet, you'll fetch the full message before you hand anything to the model:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/msg-abc123" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
nylas email read msg-abc123
There's a hard edge here: if the body is larger than ~1 MB, the webhook type becomes message.created.truncated
and the body is omitted β so don't rely on the payload ever carrying the full text.
Triage 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.
This part is deliberately provider-agnostic β any chat-completions-style API works, and the prompt is the whole design:
async function triage(msg) {
const full = await getFullMessage(msg.grant_id, msg.id); // GET .../messages/{id}
const category = await llm.classify({
instruction:
"Classify this support email into exactly one of: " +
"billing, bug_report, how_to, account_access, feedback, spam. " +
"Reply with only the category.",
subject: full.subject,
from: full.from[0].email,
body: full.body,
});
await route(full, category); // -> assign folder, draft reply, or escalate
}
Keep 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.
Here'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.
block
, mark_as_spam
, assign_to_folder
, archive
, or trash
.in_list
operator β 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
and an array of rule_ids
. Every Agent Account in the workspace inherits both. You don't attach anything to an individual grant.
Start by blocking a known-bad sender at the SMTP layer, so it never hits the mailbox:
curl --request POST \
--url "https://api.us.nylas.com/v3/rules" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"name": "Block spam-domain.com",
"priority": 1,
"trigger": "inbound",
"match": {
"conditions": [
{ "field": "from.domain", "operator": "is", "value": "spam-domain.com" }
]
},
"actions": [{ "type": "block" }]
}'
The CLI builds the same rule and attaches it to the default workspace in one shot:
nylas agent rule create \
--name "Block spam-domain.com" \
--trigger inbound \
--priority 1 \
--condition from.domain,is,spam-domain.com \
--action block
For the allowlist/blocklist pattern, create a list and reference it from a rule. API first:
curl --request POST \
--url "https://api.us.nylas.com/v3/lists" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{ "name": "Blocked domains", "type": "domain" }'
That call returns the list id
. Seed it with items in a second call β list items are their own sub-resource:
curl --request POST \
--url "https://api.us.nylas.com/v3/lists/<LIST_ID>/items" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{ "items": ["spam.com"] }'
Then the rule that matches against it with in_list
:
curl --request POST \
--url "https://api.us.nylas.com/v3/rules" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"name": "Block anything on our blocklist",
"trigger": "inbound",
"match": {
"conditions": [
{ "field": "from.domain", "operator": "in_list", "value": ["<LIST_ID>"] }
]
},
"actions": [{ "type": "block" }]
}'
The CLI collapses the list creation and seeding into one command, and the rule reference into another:
nylas agent list create --name "Blocked domains" --type domain --item spam.com
nylas agent rule create \
--name "Block anything on our blocklist" \
--trigger inbound \
--condition from.domain,in_list,<LIST_ID> \
--action block
Routing 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
paired with mark_as_read
. The curl form:
curl --request POST \
--url "https://api.us.nylas.com/v3/rules" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"name": "Route notifications to a folder",
"trigger": "inbound",
"match": {
"conditions": [
{ "field": "from.domain", "operator": "is", "value": "noreply.example.com" }
]
},
"actions": [
{ "type": "assign_to_folder", "value": "<FOLDER_ID>" },
{ "type": "mark_as_read" }
]
}'
And the CLI equivalent, which creates the rule and attaches it to the default workspace:
nylas agent rule create \
--name "Route notifications to a folder" \
--trigger inbound \
--condition from.domain,is,noreply.example.com \
--action assign_to_folder=<FOLDER_ID> \
--action mark_as_read
If you created a custom policy and want it on your accounts, attach it to the workspace β that's where the no---workspace
-flag detail from earlier resolves. Create the policy, then PATCH the workspace to point at it. The curl form:
curl --request POST \
--url "https://api.us.nylas.com/v3/policies" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{ "name": "Support Agent Policy" }'
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 '{ "policy_id": "<POLICY_ID>" }'
The CLI collapses both steps:
nylas agent policy create --name "Support Agent Policy"
nylas workspace update <workspace-id> --policy-id <policy-id>
One 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
rule matching outbound.type
equal to reply
β it won't interfere with your inbound triage at all.
When 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
: pass it on the send and Nylas sets the In-Reply-To
and References
headers for you, so the customer's mail client groups the reply correctly.
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"reply_to_message_id": "msg-abc123",
"to": [{ "email": "dana@customer.example" }],
"subject": "Re: Can'\''t log in after password reset",
"body": "Hi Dana β I see the reset went through an hour ago..."
}'
From the CLI, nylas email reply
does 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
automatically:
nylas email reply msg-abc123 --body "Hi Dana β I see the reset went through an hour ago..."
By default the reply goes only to the original sender; add --all
if 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."
This is where naive builds fall over in production. Webhooks are delivered at least once β if your endpoint is slow to 200
or there's a network blip, you'll get the same message.created
again. Add concurrent workers and two of them can grab the same notification at the same millisecond. Either way: two replies, one angry customer.
The 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:
const messageId = event.data.object.id;
// Atomic insert. Returns truthy ONLY when the key was newly inserted:
// Redis -> SET messageId 1 NX EX 86400 (truthy if it didn't exist)
// Postgres -> INSERT ... ON CONFLICT DO NOTHING, then check rowCount === 1
const wasInserted = await db.processedMessages.setIfAbsent(messageId, {
receivedAt: Date.now(),
});
if (!wasInserted) return; // insert did nothing -> already seen, bail
Watch the polarity here β it's the easiest bug to ship. setIfAbsent
succeeds (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
is true and exit when it's false. Invert that test and you'd drop every genuine first message while happily processing the duplicates.
Give 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.
Dedup 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.
A 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
, fetching context is one call.
nylas email threads show thread-xyz789
Or the same call over the API:
curl "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/threads/<THREAD_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
GET /v3/grants/{grant_id}/threads/{thread_id}
returns the thread with its message_ids
; 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 builds the full state machine around this if you need conversations that span days.
A support agent that emails customers autonomously needs brakes:
billing
and account_access
to a draft-and-escalate path instead of auto-sending. The agent prepares the reply; a person approves it.You've now got support@
as 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:
nylas agent
subcommands.When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com
: