Internal IT email is mostly two things: password resets and "VPN won't connect." A help@ alias fills up with the same handful of requests, a human triages them by hand, copies the gist into a ticketing tool, replies with the same canned fix they sent yesterday, and the genuinely hard ones — the SSO outage, the laptop that won't enroll — get buried under the noise. The naive automation here is a script that polls a shared mailbox over IMAP and tries to guess. That works until you want the helpdesk to be a real participant: to own its own address, send replies that thread correctly, and quietly hand the messy cases to a person without dropping context.
So let's give it an inbox. Not a human's inbox the agent borrows — its own. An Agent Account is a Nylas mailbox you provision for software: it has an address, it receives mail, it sends mail, and it works against the exact same grant-scoped API as any Gmail or Microsoft account you connected through OAuth. The helpdesk agent reads helpdesk@yourcompany.com, files a ticket in your system, answers the routine stuff, and escalates the rest into a folder a human watches.
I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for. Every operation gets the two-angle tour — the raw curl call and the nylas command that does the same thing — because when you're wiring this up you'll want both in front of you.
What the helpdesk agent actually does
Strip it down and the loop is four moves, all of them plain grant-scoped operations you half-know already:
-
Receive an inbound request. Inbound mail fires the standard
message.createdwebhook — the same one every grant uses. - Read the full message, classify it, and open a ticket in your own system. Ticket creation is not a Nylas feature. Nylas is the mailbox; your ITSM tool (Jira Service Management, a Postgres table, whatever you run) is the ticket store.
- Reply with a fix for the routine cases — password reset steps, the VPN reconnect checklist — preserving the thread so the reply lands in the same conversation.
-
Escalate the hard cases by moving them into a
Needs humanfolder that a real engineer watches in their mail client.
The spine of all four is the grant. An Agent Account is a grant with a grant_id — there's nothing new to learn on the data plane. Messages, Folders, Threads, Webhooks: same endpoints, same shapes, same SDK calls. The only new ideas are where the account comes from and which webhooks it can emit.
One thing to set expectations on up front, because it shapes the whole design: the ticket-to-thread mapping is your state, not Nylas's. Custom metadata isn't supported on Agent Account grants, so there's no server-side field where you can stash a ticket ID on a message. You keep a row in your own database keyed by thread_id — and Nylas hands you that thread_id on every inbound webhook, so the join is cheap. More on that below.
Why an Agent Account beats polling a shared alias
A shared help@ alias connected over IMAP gets you read access and not much else. The Agent Account approach gives you a few things that matter for a helpdesk:
-
The agent has its own identity. Replies come from
helpdesk@yourcompany.com, not from some engineer's personal account. Threading is clean and the audit trail is obvious. -
Inbound is event-driven. You subscribe to
message.createdonce at the app level and every new request pings your handler. No polling loop, no "did I already see this one" bookkeeping beyond a dedup check. -
A human can pick up escalations in a normal mail client. Folders you create over the API show up as real IMAP mailboxes, so a person watches
Needs humanin Outlook or Apple Mail without touching your app. - It's still just a grant. Every integration trick you know — listing, reading, replying, moving messages — works unchanged.
The honest tradeoff: if all you ever need is to read a mailbox and never send or organize anything, a plain IMAP connection is simpler. The moment the agent needs to act — reply as itself, file things into folders, be a participant — the Agent Account earns its place.
Before you begin
You need an Agent Account and its grant_id. If you don't have one, it's a single call. POST /v3/connect/custom with "provider": "nylas" and the address in settings.email (on a domain you've registered with Nylas):
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": "IT Helpdesk",
"settings": {
"email": "helpdesk@yourcompany.com"
}
}'
The response hands back data.id — that's your grant_id. From the CLI it's one line:
nylas agent account create helpdesk@yourcompany.com --name "IT Helpdesk"
The API auto-creates a default workspace and policy for the account, so there's nothing else to wire up to start receiving mail. The provisioning docs cover domains and DNS. Everything below assumes you've run nylas init, so the CLI already points at your application.
Receive the request
Inbound mail fires the standard message.created webhook. Here's the part that trips people up: webhooks are application-scoped, not grant-scoped. You subscribe once at the app level, and events for every grant in the app arrive at the same endpoint, each payload carrying a grant_id you filter on. There's no per-mailbox subscription. Create the subscription with POST /v3/webhooks:
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://yourapp.com/webhooks/nylas"
}'
Or from the CLI:
nylas webhook create \
--url https://yourapp.com/webhooks/nylas \
--triggers message.created
Now the dedup. The API guarantees at-least-once delivery — the same event can arrive up to three times. Dedupe on the top-level notification id, which stays constant across all retries of one event. That's your delivery-dedup key. The inner data.object.id is the message ID and identifies the message itself; you can guard on it too if you want belt-and-suspenders protection against acting twice on the same request.
app.post("/webhooks/nylas", async (req, res) => {
res.status(200).end(); // ack fast — Nylas retries on non-2xx
const event = req.body;
// Delivery dedup: the notification id is constant across retries.
if (await seen(event.id)) return;
await markSeen(event.id);
if (event.type !== "message.created") return;
const msg = event.data.object;
if (msg.grant_id !== HELPDESK_GRANT_ID) return; // filter by grant
await handleRequest(msg);
});
A word on the webhook body. The Nylas docs disagree on whether the message body is inline in the message.created payload, so write the version that's correct either way: don't rely on the payload for the body — fetch the full message by ID when you need it. The payload always carries enough to identify the message (id, grant_id, thread_id, sender, subject); branch on message.created.truncated for very large messages (over ~1 MB) and re-fetch in that case. The next section is that fetch.
Read the message and open a ticket
Before the agent can classify anything, it needs the actual content. Fetch the full message by ID — GET /v3/grants/{grant_id}/messages/{message_id}:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
From the CLI:
nylas email read <message-id>
That returns the subject, the from sender, the thread_id, and the body. Now you do two things your app owns, not Nylas:
Open the ticket in your system. This is a write to your ITSM store — create a ticket row, capture the requester, the subject, the body, and crucially the thread_id. That thread_id is the join key between the email conversation and the ticket, and since Agent Accounts don't support custom metadata, this row is the only place that mapping lives.
Classify the request. Routine or hard? This is a call to your LLM, not a Nylas feature. Feed it the subject and body and ask: is this a password reset, a VPN reconnect, one of the known self-serve fixes — or something that needs a human? The model does the reading; your code does the routing based on what it returns.
This is also why the fetch above isn't optional. A large message.created payload arrives as message.created.truncated with the body omitted — and those are precisely the long, detailed requests most likely to be the hard ones. A handler that classifies straight off the webhook will choke on exactly the messages it most needs to read correctly. Don't assume the webhook always carries enough to classify: fetch the full message by ID (GET /v3/grants/{grant_id}/messages/{message_id}) before you read the body, and you're robust to truncation by construction.
async function handleRequest(msg) {
const full = await nylas.messages.find({
identifier: HELPDESK_GRANT_ID,
messageId: msg.id,
});
// Your ticket store. thread_id is the join key — Nylas has no field for this.
const ticket = await tickets.create({
threadId: full.data.threadId,
requester: full.data.from?.[0]?.email,
subject: full.data.subject,
body: full.data.body,
});
// Your LLM decides routine vs. hard. Not a Nylas call.
const { category, fix } = await classify(full.data);
if (category === "routine") {
await replyWithFix(msg.id, ticket, fix);
} else {
await escalate(full.data.threadId, ticket);
}
}
Marking the message read, if you want to, is its own operation — PUT /v3/grants/{grant_id}/messages/{message_id} with {"unread": false}, or nylas email mark read <message-id>. Reading the message over the API does not mark it read as a side effect, so keep the fetch and the mark-read as two separate steps.
A note on routing by sender vs. routing by content
You might wonder whether an inbound Rule could handle the routine-versus-hard split for you. It can't, and it's worth knowing exactly where the line is, because it's a common wrong turn.
Agent Account inbound Rules match on from.* only — from.address, from.domain, from.tld, with operators is, is_not, contains, and in_list. They cannot see the subject or the body. So a rule is great for sender-based routing — auto-route everything from your VIP exec domain into a priority folder, say — but it categorically can't tell a password reset from a server outage, because that decision lives in the content, and content routing is app-side work you do after the webhook.
A sender-based example that is a rule — fast-track requests from a leadership 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": "VIP senders to priority folder",
"trigger": "inbound",
"match": {
"conditions": [
{ "field": "from.domain", "operator": "in_list", "value": ["<VIP_LIST_ID>"] }
]
},
"actions": [
{ "type": "assign_to_folder", "value": "<PRIORITY_FOLDER_ID>" }
]
}'
From the CLI:
nylas agent rule create \
--name "VIP senders to priority folder" \
--trigger inbound \
--condition from.domain,in_list,<VIP_LIST_ID> \
--action assign_to_folder=<PRIORITY_FOLDER_ID>
And the catch that bites everyone: a rule is inert until it's attached to a workspace. Creating it does nothing on its own — you activate it by adding its ID to the workspace's rule_ids array (nylas agent rule create attaches to the default workspace for you; the raw API does not). If you build rules through POST /v3/rules directly, finish the job with nylas workspace update <workspace-id> --rules-ids <rule-id> or PATCH /v3/workspaces/{id}. The full mechanics are in the Policies, Rules, and Lists guide.
Bottom line: rules route by who sent it. The agent routes by what it says. The helpdesk needs both, and they're separate tools.
Reply with a fix
For the routine cases — and in an IT inbox that's most of them — the agent answers directly. The reply has to thread, so the requester sees it land in the same conversation, not as a fresh email. Over the API that's POST /v3/grants/{grant_id}/messages/send with reply_to_message_id set to the inbound message's ID, plus the to recipient and the body:
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"reply_to_message_id": "<INBOUND_MESSAGE_ID>",
"to": [{ "email": "requester@yourcompany.com" }],
"body": "Reset your password at https://sso.yourcompany.com/reset. The link is valid for 30 minutes. Reply here if it does not arrive."
}'
The reply_to_message_id field is what preserves the thread — Nylas sets the In-Reply-To and References headers so the reply groups correctly in the requester's mail client. From the CLI, nylas email reply fetches the original to populate the recipient and subject automatically, so you only supply the body:
nylas email reply <inbound-message-id> --body "Reset your password at https://sso.yourcompany.com/reset. The link is valid for 30 minutes. Reply here if it does not arrive."
By default the reply goes only to the original sender; pass --all if a request came in with other people on the To/Cc and they should stay looped in. After the agent replies, update the ticket in your store — mark it auto-resolved, stamp the fix you sent — so a human glancing at the queue sees it's handled.
Escalate the hard cases to a human folder
The cases the model flags as hard never get an auto-reply. Instead the agent moves them somewhere a person watches. First, the destination has to exist — create a custom folder once, at setup, and reuse its ID forever. POST /v3/grants/{grant_id}/folders:
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/folders" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"name": "Needs human"
}'
From the CLI:
nylas email folders create "Needs human"
Both print the new folder's ID — save it. The nice property of Agent Accounts: a folder created over the API shows up as a real IMAP mailbox, so the moment you create Needs human it appears as a folder in any mail client connected to the account. The agent and the on-call engineer are looking at the same backing store, which is what makes the handoff work with no extra sync layer. Create the folder once; don't recreate it on every escalation — look it up by name or cache the ID.
Now move the flagged message into it. Over the API, PUT /v3/grants/{grant_id}/messages/{message_id} with a folders array holding just the review folder's ID:
curl --request PUT \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"folders": ["<NEEDS_HUMAN_FOLDER_ID>"]
}'
From the CLI:
nylas email move <message-id> --folder <needs-human-folder-id>
One contract detail to say out loud: that folders array is a replace, not an append. Sending ["<NEEDS_HUMAN_FOLDER_ID>"] removes the message from inbox and puts it solely in the review folder — which is exactly what you want for an escalation, since it also pulls the message out of the inbox your agent watches. If you ever wanted a message in two folders, you'd send both IDs.
If the hard case is mid-conversation and you want the whole thread in front of the reviewer, look at it first to get the message list — nylas email threads show <thread-id> (or GET /v3/grants/{grant_id}/threads/{thread_id}, which returns a message_ids array) — then loop nylas email move over each ID. The engineer opens Needs human, reads the full request, and replies straight from their mail client; SMTP preserves threading, so their reply threads back to the requester and shows up on the API side too. Update the ticket to escalated with the assignee when you move it, so the queue reflects reality.
Things to know
-
Tickets live in your system; the mapping is your state. Nylas is the mailbox. Custom metadata isn't supported on Agent Account grants, so the ticket-to-conversation join lives in your database keyed by
thread_id— the samethread_idthe webhook hands you on every inbound message. - Classification is your LLM, not a Nylas call. Routine-vs-hard is a content decision. Feed the model the subject and body; route on what it returns.
-
Inbound Rules route by sender only.
from.address,from.domain,from.tld. They can't match subject or body, and a rule does nothing until it's attached to a workspace viarule_ids. Use rules for sender-based fast-tracking; do content routing in app code. -
Webhooks are application-scoped. Subscribe once with
POST /v3/webhooks; filter each payload bygrant_id. Dedupe on the top-level notificationid(constant across the up-to-three retries), and re-fetch the body whenmessage.created.truncatedfires. -
Reply, don't compose, for fixes. Set
reply_to_message_id(API) or usenylas email reply(CLI) so the answer threads. A freshsendstarts a new conversation the requester won't connect to their original request. -
The folder move is a replace. A
foldersarray overwrites, so a single-element array cleanly pulls the message out ofinboxand intoNeeds human. That folder shows up as a normal IMAP mailbox for whoever picks up the hard cases.
What's next
- Build an email support agent — confidence gating and risk tiering for customer-facing replies, the external cousin of this internal helpdesk
- Policies, Rules, and Lists — sender-based inbound routing, outbound blocks, and how rules attach to workspaces
- Provisioning Agent Accounts — domains, DNS, and creating the mailbox
-
Connect mail clients to an Agent Account — IMAP/SMTP setup so an engineer can work the
Needs humanfolder -
Nylas CLI commands — the full
nylas email,nylas agent, andnylas webhookreference
Top comments (0)