Vercel AI SDK: let an agent send customer email, with every send approved
A runnable TypeScript example: a Vercel AI SDK tool that sends email through an Anlyon action. The model drafts, a person approves, Anlyon sends with a key the agent never holds, and the sender address is fixed.
This example gives a Vercel AI SDK agent the ability to email customers, with three properties that do not depend on the model behaving:
- Every email is approved before it is sent. The request waits in Anlyon, not in your process.
- The agent never holds the email API key. It is in the Anlyon vault and attached at send time.
- The sender is fixed. The model chooses the recipient, subject and text, within limits. It cannot change who the email is from.
It uses Resend as the email provider, and any HTTP email API works the same way. Written against AI SDK 7 and @anlyonhq/sdk 2.1.
How it fits together
generateText (your server route)
-> tool "sendCustomerEmail" (holds an Anlyon key with actions:invoke only)
-> anlyon.actions.invoke("send-customer-email", input) returns at once: pending approval
... a person reviews the exact email in the Anlyon console ...
-> on approval, Anlyon resolves {{secret:RESEND_KEY}} and calls api.resend.com
1. One-time setup, with an operator key
// setup.ts
import { Client } from '@anlyonhq/sdk';
const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });
await ops.secrets.put('RESEND_KEY', { value: process.env.RESEND_API_KEY! });
await ops.actions.create({
name: 'send-customer-email',
description: 'Send one plain-text email to a customer from the support address.',
method: 'POST',
urlTemplate: 'https://api.resend.com/emails',
headers: {
Authorization: 'Bearer {{secret:RESEND_KEY}}',
'Content-Type': 'application/json',
},
// The sender is part of the definition, not the input.
bodyTemplate: {
from: 'Support <support@example.com>',
to: '{{input.to}}',
subject: '{{input.subject}}',
text: '{{input.body}}',
},
inputSchema: {
type: 'object',
properties: {
// Anlyon's schema supports pattern but not minLength/maxLength,
// so the length limits live in the regex.
to: { type: 'string', pattern: '^[^@\\s]{1,64}@[^@\\s]{1,189}$' },
subject: { type: 'string', pattern: '^.{1,150}$' },
body: { type: 'string', pattern: '^[\\s\\S]{1,5000}$' },
},
required: ['to', 'subject', 'body'],
additionalProperties: false,
},
requiresApproval: true,
});
requiresApproval: true gates every invocation of this action. If you later want internal addresses to go straight through, replace it with approval policies from your deploy code.
2. The tool
// tools.ts
import { tool } from 'ai';
import { Client } from '@anlyonhq/sdk';
import { z } from 'zod';
const anlyon = new Client({ apiKey: process.env.ANLYON_AGENT_KEY! });
export const sendCustomerEmail = tool({
description:
'Send a plain-text email to a customer. Every email is reviewed by a person before it is sent.',
inputSchema: z.object({
ticketId: z.string().describe('Support ticket id. One email per ticket reply.'),
to: z.string().email(),
subject: z.string().max(150),
body: z.string().max(5000),
}),
execute: async ({ ticketId, to, subject, body }) => {
const { data } = await anlyon.actions.invoke(
'send-customer-email',
{ to, subject, body },
{ idempotencyKey: `email:${ticketId}` },
);
if (data!.pendingApproval) {
return {
status: 'queued_for_review',
message:
'The email is waiting for a person to approve it. It will be sent exactly as written if approved. Do not send it again.',
};
}
return {
status: data!.status,
message: data!.decision?.explanation ?? data!.error ?? 'Handled.',
};
},
});
The idempotency key ties the send to the ticket, so a model that calls the tool twice for the same reply creates one review, not two. If a ticket can get several replies, add your own message id to the key.
3. The agent
// app/api/support/route.ts
import { generateText, isStepCount } from 'ai';
import { sendCustomerEmail } from '@/tools';
export async function POST(request: Request) {
const { ticket } = await request.json();
const result = await generateText({
model: process.env.AI_MODEL!, // a provider/model id, e.g. from the AI Gateway
system:
'You are a support agent. Draft a short, accurate reply to the ticket and send it with sendCustomerEmail. Never promise refunds.',
prompt: `Ticket ${ticket.id} from ${ticket.email}:\n\n${ticket.body}`,
tools: { sendCustomerEmail },
stopWhen: isStepCount(5),
});
return Response.json({ reply: result.text });
}
The request returns as soon as the email is queued. The reviewer sees the recipient, subject and body in Approvals in the Anlyon console. On approval, Anlyon sends the request snapshot they reviewed. On denial or expiry, nothing is sent, and the decision is recorded on the invocation.
Where the AI SDK's own tool approval fits
The AI SDK has its own tool approval that runs inside your application. As of 2026-09-30 it was needsApproval in AI SDK 6. Check the AI SDK documentation for the version you run. An approval of that kind is a good fit for asking the user in a chat UI before the agent acts on their behalf.
The Anlyon approval is a different control. It sits on the path this action takes to the email API, and the key is in the Anlyon vault. That holds while no other copy of the key is in your application. Use both if you want the user's confirmation and a reviewer's decision.
Related
- AI agent tool permissions: narrow tools, fixed destinations, policies.
- OpenAI Agents SDK example: a blocking approval flow with Stripe refunds.
- How to add human-in-the-loop approval to AI agent tool calls
- Docs: Actions · Approvals
Free during Early Beta Access, no credit card. Start building →
Frequently asked questions
How do I let an AI agent send emails safely?
Do not give it an email API key or a general send tool. Define one narrow send operation with a fixed sender and length limits, keep the API key in a vault, and require approval for every send to an outside address. The agent drafts, a person approves, and a separate system sends.
Why does the tool return immediately instead of waiting for the approval?
Human approval can take minutes or hours, which is longer than a request should hold a model call open. The tool reports that the email is queued for review and returns. When the approver decides, Anlyon sends the exact request they reviewed. The agent does not have to be running.
Which AI SDK version is this written for?
AI SDK 7. As of ai 7.0.97, isStepCount is the stop condition and stepCountIs is kept as an alias. On AI SDK 5 or 6, use stepCountIs. The Anlyon side of the example does not depend on the AI SDK version.
