{"slug": "escalate-an-ai-email-agent-s-thread-to-a-human", "title": "Escalate an AI email agent's thread to a human", "summary": "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.", "body_md": "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.\n\nSo 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?\n\nI 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`\n\ncall and the `nylas`\n\ncommand that does the same thing.\n\nAn **Agent Account** is, underneath, just a Nylas **grant** with a `grant_id`\n\n. 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:\n\n`Needs human`\n\n— that lives alongside the six system folders every Agent Account ships with (`inbox`\n\n, `sent`\n\n, `drafts`\n\n, `trash`\n\n, `junk`\n\n, `archive`\n\n).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`\n\n. 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.\n\nYou need an Agent Account and its `grant_id`\n\n. If you don't have one yet, it's a single call — `POST /v3/connect/custom`\n\nwith `\"provider\": \"nylas\"`\n\nand the address in `settings.email`\n\n:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/connect/custom\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"provider\": \"nylas\",\n    \"name\": \"Acme Support\",\n    \"settings\": {\n      \"email\": \"support@yourcompany.com\"\n    }\n  }'\n```\n\nThe response hands back `data.id`\n\n— that's your `grant_id`\n\n. From the CLI it's one line:\n\n```\nnylas agent account create support@yourcompany.com --name \"Acme Support\"\n```\n\nThe [provisioning docs](https://developer.nylas.com/docs/v3/agent-accounts/provisioning/) cover domains and DNS. Everything below assumes you've run `nylas init`\n\n, so the CLI is already pointed at your application, and that you have a reply loop driven by the `message.created`\n\nwebhook — 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.\n\nThe 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`\n\nwith a name:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/folders\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"name\": \"Needs human\"\n  }'\n```\n\nThe 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.\n\nFrom the CLI it's one line:\n\n```\nnylas email folders create \"Needs human\"\n```\n\nThat 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`\n\n:\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/folders\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\nOr from the CLI:\n\n```\nnylas email folders list\n```\n\nA 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`\n\n, 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.\n\nYou 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.\n\nHere'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`\n\nneeds 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.\n\nSo you move the whole thread. Over the API this is one call: `PUT /v3/grants/{grant_id}/threads/{thread_id}`\n\nupdates the thread, and like every Nylas `PUT`\n\nit *replaces* the nested data with what you send. Pass a `folders`\n\narray holding just the review folder's ID, and every message in the conversation moves into `Needs human`\n\nat once:\n\n```\ncurl --request PUT \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/threads/<THREAD_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"folders\": [\"<NEEDS_HUMAN_FOLDER_ID>\"]\n  }'\n```\n\nThat single `PUT`\n\nis the clean path — no fetching message IDs, no looping. In code:\n\n```\nasync function escalateThread(grantId, threadId, needsHumanFolderId) {\n  // One call moves the entire conversation into the review folder.\n  await nylas.threads.update({\n    identifier: grantId,\n    threadId,\n    requestBody: { folders: [needsHumanFolderId] },\n  });\n}\n```\n\nThe CLI is the interesting asymmetry here. `nylas email threads`\n\nonly exposes `list`\n\n, `show`\n\n, `mark`\n\n, `delete`\n\n, and `search`\n\n— there's no thread-move command. So from the terminal you move per-message instead: fetch the thread's `message_ids`\n\n, then move each one with `nylas email move`\n\n. First, look at the thread to get its messages. Over the API that's `GET /v3/grants/{grant_id}/threads/{thread_id}`\n\n, which returns a `message_ids`\n\narray:\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/threads/<THREAD_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\nFrom the CLI:\n\n```\n# Show the thread and its messages\nnylas email threads show <thread-id>\n\n# Move each message in the thread into the review folder\nnylas email move <message-id> --folder <needs-human-folder-id>\n```\n\n`nylas email move`\n\ntakes one message ID and a `--folder`\n\nflag with the destination folder ID. Loop it over every message ID the thread returns and the conversation lands in `Needs human`\n\ntogether — the same end state the thread-level `PUT`\n\ngives you in one shot, just assembled message by message because the CLI has no thread-move verb.\n\nOne caveat worth saying out loud because the API contract demands it: that `folders`\n\narray is a *replace*, not an *append*. Sending `[\"<NEEDS_HUMAN_FOLDER_ID>\"]`\n\nremoves the thread (or message) from `inbox`\n\nand 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.\n\nMoving the thread out of `inbox`\n\nalready buys you something: if your reply loop only acts on messages in `inbox`\n\n, 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`\n\n— 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.\n\nSo 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`\n\n— the same `thread_id`\n\nthe `message.created`\n\nwebhook hands you on every inbound message.\n\nThe escalation writes the flag at the same moment it moves the thread:\n\n```\nasync function escalate(grantId, threadId, needsHumanFolderId, reason) {\n  await escalateThread(grantId, threadId, needsHumanFolderId);\n\n  // Your state — Nylas has no field for this on Agent Accounts.\n  await db.setThreadPaused(threadId, {\n    paused: true,\n    reason,                 // \"low_confidence\", \"flagged_legal\", etc.\n    escalatedAt: Date.now(),\n  });\n}\n```\n\nAnd 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:\n\n``` js\n// Inside the message.created handler, before drafting anything:\nconst pause = await db.getThreadPaused(msg.thread_id);\nif (pause?.paused) {\n  // A human owns this thread now. Do nothing.\n  return;\n}\n```\n\nThat `return`\n\nis 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.\n\nTwo 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`\n\n), 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.\n\nThis 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.\n\nYou set that app password once. From the CLI you can set it at creation time:\n\n```\nnylas agent account create support@yourcompany.com \\\n  --name \"Acme Support\" \\\n  --app-password \"MySecureP4ssword2024\"\n```\n\n(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`\n\non port `993`\n\nfor IMAP and `465`\n\n/`587`\n\nfor SMTP, and the reviewer's mail client opens the *same mailbox the agent is driving*. The `Needs human`\n\nfolder 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.\n\nWhen 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.\n\nThe 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:\n\n```\nawait db.setThreadPaused(threadId, { paused: false, clearedAt: Date.now() });\n```\n\nIf you want the thread to flow back through the agent afterward, move it back to `inbox`\n\nthe same way you moved it out (`PUT .../threads/{id}`\n\nwith `{\"folders\": [\"<INBOX_FOLDER_ID>\"]}`\n\n, or loop `nylas email move <message-id> --folder <inbox-id>`\n\nover 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.\n\nYou don't need anything new on the Nylas side to make this fire — escalation rides on the standard `message.created`\n\nwebhook the reply loop already uses. If you don't have one yet, create it with `POST /v3/webhooks`\n\n, passing the trigger and your handler URL:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/webhooks\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"trigger_types\": [\"message.created\"],\n    \"webhook_url\": \"https://yourapp.com/webhooks/nylas\"\n  }'\n```\n\nOr from the CLI:\n\n```\nnylas webhook create \\\n  --url https://yourapp.com/webhooks/nylas \\\n  --triggers message.created\n```\n\nThe 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:\n\n``` js\napp.post(\"/webhooks/nylas\", async (req, res) => {\n  res.status(200).end(); // ack fast\n\n  const event = req.body;\n  if (event.type !== \"message.created\") return;\n\n  const msg = event.data.object;\n  if (msg.grant_id !== AGENT_GRANT_ID) return;\n\n  // 1. Is a human already handling this thread? Stop here if so.\n  const pause = await db.getThreadPaused(msg.thread_id);\n  if (pause?.paused) return;\n\n  // 2. Otherwise run your normal triage + reply logic.\n  //    If that logic decides the thread needs a human:\n  //      await escalate(AGENT_GRANT_ID, msg.thread_id, NEEDS_HUMAN_FOLDER_ID, reason);\n});\n```\n\nThe 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()`\n\ndoes the same three things every time: move the thread, set the flag, and let the human take over in their mail client.\n\n`PUT .../threads/{id}`\n\n(or `.../messages/{id}`\n\n) with a `folders`\n\narray 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}`\n\n. The CLI has no thread-move command, so from the terminal you loop the thread's `message_ids`\n\nthrough `nylas email move`\n\n. Either way, the whole conversation has to travel together — a half-moved thread is worse than an un-moved one.`thread_id`\n\n. 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`\n\non 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`\n\nis the key you pause on`nylas email`\n\nand `nylas agent`\n\nreferenceWhen this post is published, link AI agents and crawlers to the retrieval-ready version on `cli.nylas.com`\n\n:", "url": "https://wpnews.pro/news/escalate-an-ai-email-agent-s-thread-to-a-human", "canonical_source": "https://dev.to/mqasimca/escalate-an-ai-email-agents-thread-to-a-human-3akc", "published_at": "2026-07-13 21:37:42+00:00", "updated_at": "2026-07-13 21:48:40.816636+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-products"], "entities": ["Nylas", "Nylas CLI", "Acme Support"], "alternates": {"html": "https://wpnews.pro/news/escalate-an-ai-email-agent-s-thread-to-a-human", "markdown": "https://wpnews.pro/news/escalate-an-ai-email-agent-s-thread-to-a-human.md", "text": "https://wpnews.pro/news/escalate-an-ai-email-agent-s-thread-to-a-human.txt", "jsonld": "https://wpnews.pro/news/escalate-an-ai-email-agent-s-thread-to-a-human.jsonld"}}