# Escalate an AI email agent's thread to a human

> Source: <https://dev.to/mqasimca/escalate-an-ai-email-agents-thread-to-a-human-3akc>
> Published: 2026-07-13 21:37:42+00:00

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](https://developer.nylas.com/docs/cookbook/agent-accounts/handle-replies/), 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 "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.

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](https://developer.nylas.com/docs/v3/agent-accounts/provisioning/) 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](https://developer.nylas.com/docs/cookbook/agent-accounts/handle-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](https://developer.nylas.com/docs/v3/agent-accounts/mail-clients/) — 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:

```
# 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.

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:

``` js
// 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.

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](https://developer.nylas.com/docs/v3/agent-accounts/mail-clients/), 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.

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:

``` js
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.

`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 pause state in a folder alone — folders move, and a reviewer reading a thread can move it back without meaning to.`message.created`

on 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.`thread_id`

is the key you pause on`nylas email`

and `nylas agent`

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

:
