cd /news/ai-agents/escalate-an-ai-email-agent-s-thread-… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-57988] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=Β· neutral

Escalate an AI email agent's thread to a human

A developer at Nylas built a handoff mechanism for AI email agents to escalate threads to humans when the agent should not reply. The solution uses a custom folder called 'Needs human' and a local pause flag, since Nylas does not store a server-side pause flag for Agent Accounts. The approach relies on standard grant-scoped endpoints for moving threads and checking the pause state before drafting replies.

read12 min views1 publishedJul 13, 2026

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.

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:

Needs human

β€” that lives alongside the six system folders every Agent Account ships with (inbox

, sent

, drafts

, trash

, junk

, archive

).The honest part up front, because it shapes the whole design: Nylas doesn't store a "d" 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 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 flag is the invisible one your agent checks before it ever drafts a reply. You need both, and they do different jobs.

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.

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.

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:

nylas email threads show <thread-id>

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.

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 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 d" 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.setThreadd(threadId, {
    d: 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  = await db.getThreadd(msg.thread_id);
if (?.d) {
  // 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 ** 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.

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-. 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.setThreadd(threadId, { d: 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.

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 flag before you classify or draft, so a d 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  = await db.getThreadd(msg.thread_id);
  if (?.d) 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.

PUT .../threads/{id}

(or .../messages/{id}

) with a folders

array 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.PUT /threads/{id}

. The CLI has no thread-move command, so from the terminal you loop the thread's message_ids

through nylas email move

. Either way, the whole conversation has to travel together β€” a half-moved thread is worse than an un-moved one.thread_id

. Don't try to encode state in a folder alone β€” folders move, and a reviewer reading a thread can move it back without meaning to.message.created

on the d thread β€” your guard catches it, the agent stays quiet, and the new message is already in the shared mailbox for the human to see.thread_id

is the key you onnylas email

and nylas agent

referenceWhen this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com

:

── more in #ai-agents 4 stories Β· sorted by recency
── more on @nylas 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/escalate-an-ai-email…] indexed:0 read:12min 2026-07-13 Β· β€”