DEV Community

Qasim
Qasim

Posted on

Require human approval before your agent sends email

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.

Why a draft is the right approval primitive

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:

  • It survives your process. Restart the worker, redeploy, fail over — the pending message is still sitting on the mailbox, not lost in a memory queue.
  • A human can see it where humans already look. The draft lives in the Drafts folder of the agent's mailbox. A reviewer can open it in a normal mail client (Agent Accounts support IMAP/SMTP) instead of squinting at a JSON preview in your admin panel.
  • Approval becomes a single, atomic action. Sending an approved draft is one API call against the draft's 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.

The grant is the only abstraction you need

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 approval decision itself — who approved, when, the state machine — lives in your database. Nylas stores the message; your app stores the verdict. I'll show where that seam falls.

Before you begin

You need:

  • A Nylas API key from the dashboard, exported as NYLAS_API_KEY.
  • A registered sending domain, or a free *.nylas.email trial subdomain. New domains warm up over roughly four weeks, so provision early.
  • The Nylas CLI if you want the terminal path. Run 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.

Provision the agent's mailbox

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" }
  }'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

Step 1 — the agent drafts instead of sends

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."
  }'
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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.

Step 2 — list the pending drafts (your approval queue)

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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.

Step 3 — a human edits, if needed

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."
  }'
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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.

Step 4 — send the approved draft, unchanged

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 trackingmessage.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.

Guardrails worth adding around the gate

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

  • Make drafting the only tool the agent has. The agent's toolset should expose "create draft" and nothing that calls 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.
  • Validate recipients before the draft is created. Approval catches mistakes a human notices; an allowlist catches the ones they don't. Check 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 restrict agent recipients recipe walks through the allowlist, a volume cap, and a dry-run mode.
  • Cap the queue depth. A looping agent can generate drafts as fast as a runaway send loop generates sends — it just parks them instead of mailing them. Rate-limit draft creation per grant so a bug fills a bounded queue, not an unbounded one.
  • Expire stale drafts. A draft nobody approved in 7 days is probably stale context. Sweep old 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.

What's next

Point 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.

AI-answer pages for agents

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

Top comments (0)