How to make an AI agent ask before it sends email

Put the approval where the model can't skip it: in your tool code or the client's approval hook, not in the prompt. Require it for one-to-many sends, new recipients, and anything about money or legal terms. Show the person the exact from, recipients, subject and body, create the idempotency key with the draft, expire requests nobody answers, and log who approved what. Back it with a key that can draft but not send.

Unitpost ·

"Ask before you send" in a system prompt is a request, not a control. A model can forget it, misjudge what counts, or be talked out of it by the email it is reading. A real approval step is code that stops the send until a person says yes, backed by a credential that can't send without one.

Which sends need a person?

Not all of them. Approving every routine reply teaches people to click yes without reading. Approve on risk:

  • One-to-many. Anything to a segment or a long recipient list. A mistake sent to one person is a mistake; sent to a list it is an incident.
  • New recipients. An address that isn't the person being answered, isn't already in your system, or that the model found in a message body.
  • Money and legal content. Refunds, prices, invoices, contract terms, cancellations: anything that commits the company.
  • Firsts. The first send of a new template, or from a newly verified domain.

Everything else can run unattended inside a lane you defined, such as replying to the person who wrote in.

Client confirmation or server-side controls?

The two fail in different ways.

In the client. MCP clients and agent frameworks can pause before a tool call. ChatGPT asks you to confirm before each Unitpost send. In code the hook goes by a different name per framework: toolApproval in the Vercel AI SDK, needs_approval or require_approval in the OpenAI Agents SDK, interrupt_on in LangChain, requireApproval in Mastra, requires_approval in Pydantic AI and canUseTool in the Claude Agent SDK.

That is the right place to ask a person. It is also a setting someone can change. In the Claude Agent SDK, listing the send tool in allowedTools approves every send without asking, and some paths have no pause at all: the Claude API's MCP connector runs email_send inside the request.

On the server. Unitpost has no pending-approval state for a send that arrives over the API or MCP. A valid request from a key with emails:send is accepted. What the server does enforce is scope, so use the key to make the safe path the only path:

  • A key with neither emails:send nor campaigns:send can build templates and segments but never deliver an email.
  • For one-to-many mail, give the agent campaigns:write without campaigns:send (custom capability lists need a paid plan). It creates a campaign draft; a person validates and sends it in the dashboard. The Sending only preset includes campaigns:send, so it is the wrong key for this.
  • An MCP connection opened with ?readonly=1 refuses every write.

Client approval is the person's chance to say no. Key scope is what still holds when the client is misconfigured.

Draft, preview, confirm

  1. Draft. The agent produces the whole message: from, every recipient in to, cc and bcc, the subject, the body and any send time. Generate the idempotency key now and store it with the draft.
  2. Preview. Show the person exactly what will go out, not a summary. First run the checks that would refuse the send: is the from domain verified, does the campaign validate, how many recipients will it reach. Don't ask anyone to approve a send the API will refuse.
  3. Confirm. On yes, send with the stored key, so a double click or a retried approval replays the first result instead of sending twice. On no, tell the agent why, so it can revise.

In code, the simplest version asks inside the tool, before the request. This is the client tool from our Claude API guide, where the tool runner waits for the function to return (post_email is the plain REST call from the same guide):

cURL
@beta_tool
def send_email(sender: str, to: str, subject: str, text: str, idempotency_key: str) -> str:
    """Send one email through Unitpost. sender must be on a verified domain.

    idempotency_key: a new value for each message, and the same value when
    retrying that message, so a retry never sends twice.
    """
    if to not in ALLOWED:
        return f"Refused: {to} is not on this agent's allowlist."
    if input(f"Send '{subject}' to {to}? [y/N] ").strip().lower() != "y":
        return "Refused: a person declined this send."
    return post_email(sender, to, subject, text, idempotency_key)

In a product, replace input() with a request to a reviewer: the tool stores the draft and its idempotency key, tells the agent the send is waiting for approval, and your app sends it when the reviewer says yes.

How does Unit ask before sending?

Unitpost's dashboard assistant, Unit, follows this pattern, and its rules are a useful reference:

  • email_send always produces an Approve or Cancel card. So do sending, scheduling or resuming a campaign, retrying a failed email and suppressing an address. Ordinary edits, such as creating a template or a draft campaign, run without one.
  • Before showing a card it runs a read as the user: the From domain's status for a send, campaign validation for a campaign send. If the API would refuse, no card appears and Unit says why instead.
  • The model can't skip the card. An earlier design let a low-risk tool skip it when the model said the user had consented; that was removed, because a claim of consent can be injected.
  • When the person approves, the server checks their permissions again, and an approved card sends at most once even if the approval is retried.
  • Drafting is not sending. Asked only to write or preview copy, Unit shows the draft and waits.

What about timeouts?

An approval request nobody answers should expire, and expiry should mean "not sent". Unit's cards can't be approved after 24 hours, though Cancel still works. Pick a window that fits the message: minutes for a support reply, a day for a campaign. When it lapses, tell whoever asked that nothing went out.

Two timing details matter. An idempotency key dedupes for 24 hours, so if approvals can take longer, record the email's id when it goes out and check that record before sending. And if you want an undo window instead of an approval, schedule the send with scheduled_at a few minutes ahead and let a person cancel it with PATCH /email/{id}. That is opt-out, not opt-in: silence means send, so keep it for mail that would be fine without review.

Keep the audit trail

For every request, store the draft as it was shown, who approved or declined it and when, and the id of the email that resulted. That record is what lets you widen an agent's lane later with evidence rather than hope.

Unitpost keeps its own side. The activity log (GET /v1/activity) names the actor and, for API, MCP and Unit changes, the credential that made them. The request log (GET /v1/logs) keeps 30 days of API calls with their key and status, never a body. Reading them takes activity:read and apikeys:read, which no key preset includes, so give them to a separate audit key rather than to the agent.

Frequently asked questions

Does Unitpost hold API sends for approval?

No. A valid request from a key with emails:send is sent; there is no pending-approval state on the API or MCP. Approval lives in your client or your code, and the server enforces scopes, the verified-domain gate, suppression and rate limits. In the dashboard, Unit asks before every send.

Should every email an agent sends need approval?

No. Approving every routine reply trains people to click yes without reading. Let the agent send alone inside a narrow lane, such as answering the person who wrote in, and ask a person about lists, new recipients, and money or legal content.

What should happen if nobody answers an approval request?

Nothing should be sent. Expire the request after a window that fits the message, tell whoever asked that it wasn't sent, and require a fresh approval if the draft comes back. Unit's approval cards can't be approved after 24 hours.

Free to start

Start sending for free

$0/ mo

  • 5,000 emails / mo — no credit card
  • Your domain with DKIM, SPF & DMARC
  • REST API, SMTP & official SDKs
  • Visual editor + code templates
  • Open & click tracking + signed webhooks
  • 10,000 contacts · automatic suppression