cd /news/ai-agents/require-human-approval-before-your-a… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-57989] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=Β· neutral

Require human approval before your agent sends email

Nylas developer advocate builds an approval queue for AI email agents using the Drafts API. Instead of letting an LLM send email directly, the agent creates a draft that a human must review and approve before it is sent byte-for-byte unchanged. The system uses Nylas Agent Accounts as the mailbox for the agent, with the draft serving as a stable, persisted object that eliminates race conditions between approval and sending.

read11 min views1 publishedJul 13, 2026

Most "AI email agent" demos end with a triumphant send

. The model writes a reply, the code POSTs it, and a real message lands in a real stranger's inbox. That's a great demo and a terrible production default. The moment your agent can send mail with nobody watching, you've handed an LLM a corporate email address and the standing authority to use it. One hallucinated price, one confidently wrong refund promise, one apology to the wrong customer, and you're explaining to legal why a bot signed an email as your company.

There's a boring, durable fix that predates AI by decades: don't send β€” draft. Stage the message, put a human in front of it, and only send once someone with a name and a pulse approves. Email systems have had a "Drafts" folder forever for exactly this reason. The Nylas Drafts API turns that folder into something better β€” an approval queue your agent writes into and your reviewers drain.

This post builds that queue. The agent creates a draft, a human reviews the pending drafts, and an approved draft gets sent byte-for-byte unchanged. No re-rendering, no "the agent regenerates it on approval" race where the thing you approved isn't the thing that ships. I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for, and I'll pair every one with the raw curl

so you can wire it into a backend in whatever language you like.

This is deliberately not about escalating inbound threads to a human (that's a different problem, where the trigger is a message arriving). Here the trigger is the agent wanting to send, and the gate sits on the outbound path.

You could build approval a dozen ways. You could buffer the agent's output in a queue table and call send

later. You could stash a JSON blob in Redis. Both work, and both quietly reinvent something the email stack already gives you.

A draft is a real, persisted email object, on the mailbox, with a stable id

. That buys you three things a homegrown buffer doesn't:

id

. You don't reassemble recipients, subject, body, and attachments from a buffer and hope you got it right β€” the object you approved is the object that sends.That last point is the whole game. The contract you want is "what the human saw is what went out." A draft gives you that for free, because the send action references the stored draft rather than re-supplying the content.

Everything below runs against a grant β€” a grant_id

that represents one mailbox. If you've used Nylas with a connected Gmail or Microsoft account, an Agent Account is the same thing: a grant. Same /v3/grants/{grant_id}/*

endpoints, same auth header, same payloads. There's nothing new to learn on the data plane. The difference is provisioning β€” an Agent Account is a Nylas-hosted mailbox you create on demand, with no OAuth dance and no human to click "Allow."

That matters here because the mailbox is the agent. When the agent drafts from support@yourapp.nylas.email

, the draft sits in that account's own Drafts folder, and the eventual send comes from that address. The reviewer is approving mail "from the bot," which is exactly the identity you want under human control.

One honest caveat up front, because it shapes the design: Agent Accounts don't support custom metadata. You can't tag a draft with

{"approval_status": "pending"}

and filter on it server-side. So the You need:

NYLAS_API_KEY

.*.nylas.email

trial subdomain. New domains warm up over roughly four weeks, so provision early.nylas init

once to store your API key.Examples use the US data region host https://api.us.nylas.com

. Swap in api.eu.nylas.com

if your app lives in the EU.

The mailbox the agent drafts from is an Agent Account. Create one with POST /v3/connect/custom

, using the built-in nylas

provider and an address on your domain:

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": "Support Bot",
    "settings": { "email": "support@yourapp.nylas.email" }
  }'

The response includes the grant_id

you'll use for everything else. The API also auto-creates a default workspace and policy for the account, so there's no extra setup to send mail.

The CLI collapses all of that into one command:

nylas agent account create support@yourapp.nylas.email --name "Support Bot"

No --workspace

flag exists on create, and there's no refresh token to manage β€” that's the OAuth-free part. If you later want to attach a stricter custom policy (say, to cap inbound size or block recipients), you do that against the auto-created workspace with nylas workspace update <workspace-id> --policy-id <policy-id>

. Grab the grant_id

from the output and export it:

export NYLAS_GRANT_ID="<grant-id-from-output>"

Here's the core inversion. Your agent's "send" tool does not call the send-message endpoint. It calls create draft. The message gets composed, persisted, and parked β€” but nothing leaves the mailbox.

Create a draft with POST /v3/grants/{grant_id}/drafts

. The body is the same shape you'd send a message with: recipients, subject, body:

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/$NYLAS_GRANT_ID/drafts" \
  --header "Authorization: Bearer $NYLAS_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "to": [{ "name": "Dana Reyes", "email": "dana@customer.example" }],
    "subject": "Re: Refund for order #4821",
    "body": "Hi Dana, I have flagged order #4821 for a refund. You should see it within 5 business days."
  }'

The response is a draft object with an id

. That id

is the handle the rest of the workflow hangs on β€” log it somewhere. This is the only place the agent gets to act autonomously, and all it produced is an unsent draft.

The CLI mirrors it, with the grant id as a positional argument:

nylas email drafts create "$NYLAS_GRANT_ID" \
  --to dana@customer.example \
  --subject "Re: Refund for order #4821" \
  --body "Hi Dana, I have flagged order #4821 for a refund. You should see it within 5 business days."

Add --json

(or the global --quiet

flag) to get a clean machine-readable handle back instead of a table β€” useful when your orchestration code shells out to the CLI and needs to capture the draft id.

A practical note: when the agent creates the draft, write a matching row in your database β€” draft id, the proposed recipients, which reviewer it's assigned to, and a status of pending

. That row is your approval record. The draft on the mailbox is the payload; this row is the verdict-in-waiting. Because Agent Accounts don't carry custom metadata, this DB row is non-negotiable β€” it's the only place "pending vs. approved" can live.

Now the queue. Every unsent draft on the mailbox is, by definition, something the agent wanted to send and a human hasn't released yet. Listing drafts is listing the approval queue.

Fetch them with GET /v3/grants/{grant_id}/drafts

:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/$NYLAS_GRANT_ID/drafts?limit=20" \
  --header "Authorization: Bearer $NYLAS_API_KEY" \
  --header 'Accept: application/json'

Each item carries the id

, to

, subject

, and body

β€” enough to render a review screen. From the terminal:

nylas email drafts list "$NYLAS_GRANT_ID" --limit 20

nylas email drafts list

defaults to 10 drafts; bump it with --limit

. To pull one draft up for a close read before deciding, use show

:

nylas email drafts show <draft-id> "$NYLAS_GRANT_ID"

Or over the API, fetch the single draft with GET /v3/grants/{grant_id}/drafts/{draft_id}

:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/$NYLAS_GRANT_ID/drafts/<DRAFT_ID>" \
  --header "Authorization: Bearer $NYLAS_API_KEY" \
  --header 'Accept: application/json'

Two things worth being clear-eyed about. First, this endpoint returns all unsent drafts on the mailbox, not "drafts pending approval" β€” the API has no notion of your approval state. The mapping back to "who needs to approve what" comes from your DB rows from Step 1. Join the draft list against your pending

rows and you have the reviewer's worklist. Second, if a reviewer rejects a message, delete the draft (DELETE /v3/grants/{grant_id}/drafts/{draft_id}

, or nylas email drafts delete

) and mark your row rejected

. A rejected message should leave no draft behind to be sent by accident.

Review isn't always thumbs-up or thumbs-down. Sometimes the agent got 90% there and a human wants to fix a number or soften a sentence. Because the draft is a real, mutable object, the reviewer can edit it in place before approving β€” and the edit becomes part of what eventually sends.

Update a draft with PUT /v3/grants/{grant_id}/drafts/{draft_id}

:

curl --request PUT \
  --url "https://api.us.nylas.com/v3/grants/$NYLAS_GRANT_ID/drafts/<DRAFT_ID>" \
  --header "Authorization: Bearer $NYLAS_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "body": "Hi Dana, your refund for order #4821 is approved and will post within 3 business days."
  }'

The CLI doesn't have a draft-update command β€” nylas email drafts

covers create, list, show, send, and delete, but not edit. So in-place edits go through the API PUT

above. From the terminal, the equivalent is to delete the stale draft and recreate it with the corrected content:

nylas email drafts delete <draft-id> "$NYLAS_GRANT_ID"
nylas email drafts create "$NYLAS_GRANT_ID" \
  --to dana@customer.example \
  --subject "Re: Refund for order #4821" \
  --body "Hi Dana, your refund for order #4821 is approved and will post within 3 business days."

That gives you a new draft id, so update your DB row to point at it. The API PUT

is cleaner because it keeps the same id, which is why a real review UI would call it rather than shelling out.

This is the under-appreciated payoff of using a draft as the primitive. A buffer-in-Redis design forces "approve or regenerate." A draft lets a human take the agent's draft, correct it, and ship the corrected version β€” no round-trip back through the model, no risk the model rewrites the part the human liked. The CLI's create

command also accepts a --reply-to <message-id>

flag if the draft is a reply, which keeps it threaded to the original conversation.

The reviewer approves. Now β€” and only now β€” the message goes out. The send action is a POST

against the existing draft's id

. There is no separate "send draft" path and no re-supplying the content: POST /v3/grants/{grant_id}/drafts/{draft_id}

sends the stored draft as-is.

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/$NYLAS_GRANT_ID/drafts/<DRAFT_ID>" \
  --header "Authorization: Bearer $NYLAS_API_KEY" \
  --header 'Accept: application/json'

That empty-bodied POST is the entire approval action. Whatever the draft holds at that instant β€” original or human-edited β€” is what sends. The part I like as an SRE is that this is unforgeable in a useful way: there's no content in the send call to tamper with, so "the thing approved" and "the thing sent" can't drift.

The CLI version, with a confirmation prompt you can suppress for automated approval workers:

nylas email drafts send <draft-id> "$NYLAS_GRANT_ID" --force

After the send succeeds, flip your DB row to sent

and stamp who approved it and when. That row plus the now-sent message is your audit trail: agent proposed, human approved, message went out, all attributable.

One behavioral gotcha to know: messages sent via the drafts endpoint don't emit open or click tracking β€” message.opened

and message.link_clicked

won't fire. If tracking matters more than the in-place-edit ergonomics, the alternative is to keep the draft purely as a review artifact and, on approval, send the approved content through POST /v3/grants/{grant_id}/messages/send

with tracking_options

set. You lose the "send the exact stored object" guarantee, so pick based on which property you care about more. For most approval flows, the unchanged-object guarantee wins.

The approval queue stops bad sends. A few cheap additions stop bad behavior around it:

messages/send

. If the agent literally can't send, no prompt injection can talk it into sending. The gate isn't a policy you hope holds β€” it's the absence of the capability.to

/cc

/bcc

against a small set of allowed domains in your own code before you call create-draft, so a hallucinated address never even reaches the queue. The pending

rows, delete the drafts, and make the agent re-propose against fresh information rather than sending a week-old promise.None of these are Nylas features you toggle β€” they're application logic around the grant. The grant stays a dumb, reliable pipe; the policy lives in your code, which is exactly where you can test it.

nylas agent

and nylas email drafts

flag used abovePoint your agent at the create-draft endpoint, drain the queue with a human, and send the approved object unchanged. The agent still does the writing. A person still owns the sending. That's the line worth keeping.

When 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/require-human-approv…] indexed:0 read:11min 2026-07-13 Β· β€”