Most "AI email agent" demos quietly assume the agent answers everything. Point a model at the inbox, generate a reply, send it, repeat. That's a fine loop right up until the model hits a message it shouldn't touch — an angry customer, a legal question, a refund the agent has no authority to approve — and confidently fires off a reply anyway. The expensive failures in agent email aren't the threads the agent gets wrong. They're the threads the agent answers at all when it should have stepped back.
So let's build the part that steps back. Not the classifier that decides a message is risky — that's triage, a separate problem. This is the handoff: once something flags a thread as "needs a human," how do you actually pull the whole conversation out of the agent's reach, park it where a person can find it, and make sure the agent keeps its hands off until that person clears it?
I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I wire up an escalation path. Every operation gets the two-angle tour: the raw curl call and the nylas command that does the same thing.
What the handoff actually needs
An Agent Account is, underneath, just a Nylas grant with a grant_id. That's the spine of everything here, and it's worth sitting with: there is nothing new to learn on the data plane. The same grant-scoped endpoints you already use — Messages, Threads, Folders, Drafts — work against this grant exactly the way they work against any Gmail or Microsoft grant you got through OAuth. So the escalation path isn't some special agent feature. It's three plain operations you already half-know:
-
A place to put escalated threads. A custom folder — call it
Needs human— that lives alongside the six system folders every Agent Account ships with (inbox,sent,drafts,trash,junk,archive). - A way to move the whole thread there. Not one message — the thread. A reply is just the latest message in a conversation; a reviewer needs the full chain.
- A way to stop the agent from replying to anything in that folder until a human signs off.
The honest part up front, because it shapes the whole design: Nylas doesn't store a "paused" flag for you on an Agent Account. Custom metadata isn't supported on these grants, so there's no server-side field you can set to say "hands off this thread." The pause is your state — a row in your database keyed by thread_id. The folder move is the visible, durable signal a human sees in their mail client; the pause flag is the invisible one your agent checks before it ever drafts a reply. You need both, and they do different jobs.
Before you begin
You need an Agent Account and its grant_id. If you don't have one yet, it's a single call — POST /v3/connect/custom with "provider": "nylas" and the address in settings.email:
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 back data.id — that's your grant_id. From the CLI it's one line:
nylas agent account create support@yourcompany.com --name "Acme Support"
The provisioning docs cover domains and DNS. Everything below assumes you've run nylas init, so the CLI is already pointed at your application, and that you have a reply loop driven by the message.created webhook — the loop described in Handle email replies. This post is about the boundary that loop hands off across.
Create the review folder
The destination has to exist before you can route anything to it. Create a custom folder once, at setup time, and reuse its ID forever. On the API it's POST /v3/grants/{grant_id}/folders with a name:
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"
}'
The response hands you back the folder object. Save data.id — that's the folder ID you'll move messages into, and it's the same ID whether you reference it from the agent or a reviewer references the folder by name in their mail client.
From the CLI it's one line:
nylas email folders create "Needs human"
That prints the new folder's ID. If you want to confirm it landed alongside the system folders, list them — GET /v3/grants/{grant_id}/folders:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/folders" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
Or from the CLI:
nylas email folders list
A nice property of Agent Accounts: a custom folder created through the API shows up as a real IMAP mailbox. So the moment you create Needs human, it appears as a folder in Outlook, Apple Mail, or Thunderbird for anyone connected to the account. The agent and the human are looking at the same mailbox — the API and IMAP share one backing store — which is exactly what makes this handoff work without any extra sync layer.
You only do this once per account. Don't create the folder on every escalation; look it up by name (or cache the ID) and reuse it.
Move the whole thread, not just the message
Here's the part people get wrong. When something flags a conversation for human review, the instinct is to move the message that triggered the flag. But a reviewer opening Needs human needs the whole exchange — what the agent said, what the customer said back, the original request three messages up. Move one message and you've handed your colleague a single confused reply with no context.
So you move the whole thread. Over the API this is one call: PUT /v3/grants/{grant_id}/threads/{thread_id} updates the thread, and like every Nylas PUT it replaces the nested data with what you send. Pass a folders array holding just the review folder's ID, and every message in the conversation moves into Needs human at once:
curl --request PUT \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/threads/<THREAD_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"folders": ["<NEEDS_HUMAN_FOLDER_ID>"]
}'
That single PUT is the clean path — no fetching message IDs, no looping. In code:
async function escalateThread(grantId, threadId, needsHumanFolderId) {
// One call moves the entire conversation into the review folder.
await nylas.threads.update({
identifier: grantId,
threadId,
requestBody: { folders: [needsHumanFolderId] },
});
}
The CLI is the interesting asymmetry here. nylas email threads only exposes list, show, mark, delete, and search — there's no thread-move command. So from the terminal you move per-message instead: fetch the thread's message_ids, then move each one with nylas email move. First, look at the thread to get its messages. Over the API that's GET /v3/grants/{grant_id}/threads/{thread_id}, which returns a message_ids array:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/threads/<THREAD_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
From the CLI:
# Show the thread and its messages
nylas email threads show <thread-id>
# Move each message in the thread into the review folder
nylas email move <message-id> --folder <needs-human-folder-id>
nylas email move takes one message ID and a --folder flag with the destination folder ID. Loop it over every message ID the thread returns and the conversation lands in Needs human together — the same end state the thread-level PUT gives you in one shot, just assembled message by message because the CLI has no thread-move verb.
One caveat worth saying out loud because the API contract demands it: that folders array is a replace, not an append. Sending ["<NEEDS_HUMAN_FOLDER_ID>"] removes the thread (or message) from inbox and puts it solely in the review folder. That's usually what you want for an escalation — it pulls the thread out of the inbox the agent watches, which doubles as your first line of "don't auto-reply." But if you ever want a thread to live in two folders at once, you send both IDs in the array. For a clean handoff, one folder is right.
Pause the agent — and be honest that it's your state
Moving the thread out of inbox already buys you something: if your reply loop only acts on messages in inbox, an escalated thread is now invisible to it. For a lot of agents that's enough. But it's fragile — a stray rule, a reviewer dragging the thread back to read it, a new inbound on the same thread landing in inbox — any of those can put a message back in front of the agent. You don't want the agent's silence to depend on a folder location it doesn't control.
So you keep an explicit pause flag, and you check it on the way in, before the agent ever drafts a reply. This is the part there's no Nylas field for. Custom metadata isn't supported on Agent Account grants, so "this thread is paused" has to live in your application's store, keyed by thread_id — the same thread_id the message.created webhook hands you on every inbound message.
The escalation writes the flag at the same moment it moves the thread:
async function escalate(grantId, threadId, needsHumanFolderId, reason) {
await escalateThread(grantId, threadId, needsHumanFolderId);
// Your state — Nylas has no field for this on Agent Accounts.
await db.setThreadPaused(threadId, {
paused: true,
reason, // "low_confidence", "flagged_legal", etc.
escalatedAt: Date.now(),
});
}
And the reply loop checks it first, ahead of any model call. The cheapest guard goes at the very top of your webhook handler, right after you confirm the event is for your Agent Account:
// Inside the message.created handler, before drafting anything:
const pause = await db.getThreadPaused(msg.thread_id);
if (pause?.paused) {
// A human owns this thread now. Do nothing.
return;
}
That return is the whole point of the post. The agent saw the inbound reply, recognized the thread is under human review, and declined to act. No draft, no send, no "let me just acknowledge it" — silence, which is the correct behavior for a thread a person is handling.
Two guards, two jobs, and they back each other up: the folder move is the durable, human-visible signal (a reviewer sees the thread sitting in Needs human), and the pause flag is the authoritative gate your code actually trusts. If they ever disagree — say the thread got moved but the flag write failed — the flag is the one that decides whether the agent replies. Treat the database write as the source of truth and the folder as the UI.
How a human picks it up
This is where Agent Accounts earn the design. The reviewer doesn't need your app, a custom dashboard, or API access. Because the account exposes IMAP and SMTP, a person connects the mailbox in any standard mail client — Outlook, Apple Mail, Thunderbird — using the account's address as the username and an app password as the password.
You set that app password once. From the CLI you can set it at creation time:
nylas agent account create support@yourcompany.com \
--name "Acme Support" \
--app-password "MySecureP4ssword2024"
(The password rules: 18–40 characters, printable ASCII, with at least one uppercase, one lowercase, and one digit.) Point the client at mail.us.nylas.email on port 993 for IMAP and 465/587 for SMTP, and the reviewer's mail client opens the same mailbox the agent is driving. The Needs human folder you created over the API shows up there as a normal folder. Escalated threads appear inside it. The human reads the full conversation — every message moved together, in order — and replies straight from their mail client.
When they reply over SMTP, Nylas preserves the threading headers, so their reply threads correctly back to the customer and shows up in the same thread on the API side too. The agent never had to hand anything off in-band; the shared mailbox is the handoff.
The one thing your code still owns is the un-pause. When the human is done — and you'll usually detect this by watching for a sent message on the thread from the human, or simpler, by giving reviewers a way to clear it — you flip the flag back:
await db.setThreadPaused(threadId, { paused: false, clearedAt: Date.now() });
If you want the thread to flow back through the agent afterward, move it back to inbox the same way you moved it out (PUT .../threads/{id} with {"folders": ["<INBOX_FOLDER_ID>"]}, or loop nylas email move <message-id> --folder <inbox-id> over the thread's messages). Often you don't — once a human owns a thread, they tend to keep it — so the simplest policy is: cleared threads stay where the human left them, and the agent stays out until a brand-new conversation arrives.
Wiring it into the webhook
You don't need anything new on the Nylas side to make this fire — escalation rides on the standard message.created webhook the reply loop already uses. If you don't have one yet, create it with POST /v3/webhooks, passing the trigger and your handler URL:
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
The handler order is what matters. Check the pause flag before you classify or draft, so a paused thread short-circuits immediately and never reaches the model:
app.post("/webhooks/nylas", async (req, res) => {
res.status(200).end(); // ack fast
const event = req.body;
if (event.type !== "message.created") return;
const msg = event.data.object;
if (msg.grant_id !== AGENT_GRANT_ID) return;
// 1. Is a human already handling this thread? Stop here if so.
const pause = await db.getThreadPaused(msg.thread_id);
if (pause?.paused) return;
// 2. Otherwise run your normal triage + reply logic.
// If that logic decides the thread needs a human:
// await escalate(AGENT_GRANT_ID, msg.thread_id, NEEDS_HUMAN_FOLDER_ID, reason);
});
The escalation itself can be triggered by anything: a low-confidence score from your classifier, a keyword denylist, a rule that fires on certain senders, or a customer who simply asks for a human. How you decide is out of scope here — the point is that once decided, escalate() does the same three things every time: move the thread, set the flag, and let the human take over in their mail client.
Things to know
-
The folder move is a replace, not an append.
PUT .../threads/{id}(or.../messages/{id}) with afoldersarray overwrites the existing folders. Send only the review folder's ID to pull the thread cleanly out of the inbox; send multiple IDs if you genuinely want it in two places. -
Move the whole thread, not one message. The API does this in a single
PUT /threads/{id}. The CLI has no thread-move command, so from the terminal you loop the thread'smessage_idsthroughnylas email move. Either way, the whole conversation has to travel together — a half-moved thread is worse than an un-moved one. -
Pausing is your state, not Nylas's. Custom metadata isn't supported on Agent Accounts, so the "paused" flag lives in your database keyed by
thread_id. Don't try to encode pause state in a folder alone — folders move, and a reviewer reading a thread can move it back without meaning to. - Check the flag before the model, not after. The whole value of the handoff is that the agent does no work on a paused thread. Gate at the top of the handler, ahead of any draft or send.
-
New inbound on a paused thread is normal. The customer might reply again while a human is mid-review. That fires
message.createdon the paused thread — your guard catches it, the agent stays quiet, and the new message is already in the shared mailbox for the human to see. - The human needs an app password, set once. Without it, IMAP and SMTP logins are rejected. Set it at account creation or rotate it later; rotating disconnects any live mail client until the reviewer re-enters it.
What's next
- Handle email replies in an agent loop — the reply loop this handoff pauses
- Connect mail clients to an Agent Account — IMAP/SMTP setup so a human can pick up the thread
- How Agent Account mailboxes work — folders, drafts, and the inbound message lifecycle
-
Email threading for agents — why
thread_idis the key you pause on -
Nylas CLI commands — the full
nylas emailandnylas agentreference
AI-answer pages for agents
When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com:
- Topic runbook: https://cli.nylas.com/ai-answers/agent-account-handoff-to-human.md
- Industry playbooks hub: https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md
Top comments (0)