Build a support triage agent that owns its own inbox
Provision support@
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.
Receive every inbound message
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.
Classify it with an LLM
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.
Route and filter with policies, rules, and lists
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.
- Policies bundle limits (send quotas, storage, retention) and spam detection. One policy can govern many accounts.
- Rules match inbound or outbound mail on sender/recipient fields and run actions like
block,mark_as_spam,assign_to_folder,archive, ortrash. - Lists are typed collections of domains, TLDs, or addresses that rules reference via the
in_listoperator - 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:
# Create the policy
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"
}'
# Attach it to the workspace (returns the policy id from the call above)
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.
Reply in the correct thread
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",
"subject": "Re: Can'\''t log in after password reset",
"body": "Hi Dana, let me look into that reset issue for you...",
"to": [{ "name": "Dana Reed", "email": "dana@customer.example" }]
}'
From the CLI:
nylas email send \
--grant-id <NYLAS_GRANT_ID> \
--reply-to msg-abc123 \
--subject "Re: Can't log in after password reset" \
--body "Hi Dana, let me look into that reset issue for you..." \
--to dana@customer.example
The reply_to_message_id parameter ensures your agent's response stays in the same thread, maintaining conversation continuity for the customer.
Comments
No comments yet. Start the discussion.