Skip to content

Frameworks and agents · Published 2026-09-29 · Updated 2026-09-30 · AgentiSend

Send email from the Vercel AI SDK

Give a Vercel AI SDK agent one tool that posts to POST /emails with its own budgeted key, and stop when the reply is agent_budget_exceeded.

To send email from the Vercel AI SDK, give the model one tool whose execute posts to POST /emails with the agent's own key. The key is sending_access and carries a budget. The file below is that tool and a scripted generateText loop. The loop is run by the test suite against a local AgentiSend server: the first call sends, and the second message is refused with agent_budget_exceeded once the budget is spent. The model in the file is scripted, so the run does not call a hosted model.

The tool and the loop

/**
 * Vercel AI SDK — the agent loop around `sendEmail`.
 *
 * The model here is scripted. `generateText` still runs the SDK's own loop:
 * it checks the tool call against `inputSchema`, calls `execute`, and puts
 * the result in front of the next step. Nothing is billed and no provider is
 * called. The second call is a different message, so a key whose budget is
 * one comes back as `agent_budget_exceeded`.
 *
 * The key in `AGENTISEND_API_KEY` is the agent's, minted as `sending_access`
 * with a budget. `MAIL_FROM` is an address on a domain you have verified.
 */
import { generateText, stepCountIs, tool } from 'ai';
import { MockLanguageModelV4 } from 'ai/test';
import { AgentiSend, AgentiSendError } from 'agentisend';
import { z } from 'zod';

const agentisend = new AgentiSend();

function mailFrom(): string {
  const from = process.env.MAIL_FROM;
  if (!from) throw new Error('Set MAIL_FROM to an address on a domain you have verified.');
  return from;
}

export type SendEmailResult = { sent: true; id: string } | { sent: false; code: string; fix: string };

export const sendEmail = tool({
  description:
    'Send one email to a person who asked for it. Returns the message id, or a refusal ' +
    'with a code and a fix. When the fix says a person decides, stop and report it.',
  inputSchema: z.object({
    to: z.string(),
    subject: z.string().min(1).max(200),
    text: z.string().min(1),
    purpose: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/),
  }),
  execute: async ({ to, subject, text, purpose }): Promise<SendEmailResult> => {
    try {
      const { id } = await agentisend.emails.send(
        { from: mailFrom(), to, subject, text },
        { idempotencyKey: `${purpose}/${to}` },
      );
      return { sent: true, id };
    } catch (err) {
      if (err instanceof AgentiSendError) return { sent: false, code: err.code, fix: err.fix };
      throw err;
    }
  },
});

const UPDATE = {
  to: 'vercel-loop@example.com',
  subject: 'Ticket 4182 — we are looking into it',
  text: 'Someone from support will reply within the hour.',
  purpose: 'ticket-4182-update',
};

const CLOSED = {
  to: 'vercel-loop@example.com',
  subject: 'Ticket 4182 — closed',
  text: 'The ticket is closed.',
  purpose: 'ticket-4182-closed',
};

const usage = {
  inputTokens: { total: 8, noCache: 8, cacheRead: 0, cacheWrite: 0 },
  outputTokens: { total: 8, text: 8, reasoning: 0 },
};

function toolCall(id: string, input: object) {
  return {
    content: [{ type: 'tool-call' as const, toolCallId: id, toolName: 'sendEmail', input: JSON.stringify(input) }],
    finishReason: { unified: 'tool-calls' as const, raw: 'tool-calls' },
    usage,
    warnings: [],
  };
}

let step = 0;

/** A model that asks for the update, then the close, then stops. */
const model = new MockLanguageModelV4({
  doGenerate: async () => {
    const n = step++;
    if (n === 0) return toolCall('call_update', UPDATE);
    if (n === 1) return toolCall('call_closed', CLOSED);
    return {
      content: [{ type: 'text' as const, text: 'Stopped after the refusal.' }],
      finishReason: { unified: 'stop' as const, raw: 'stop' },
      usage,
      warnings: [],
    };
  },
});

/** What each tool call handed back to the model, in order. */
export async function run(): Promise<SendEmailResult[]> {
  const result = await generateText({
    model,
    tools: { sendEmail },
    stopWhen: stepCountIs(4),
    prompt: 'Tell vercel-loop@example.com that ticket 4182 is being looked into, then that it is closed.',
  });
  const outputs: SendEmailResult[] = [];
  for (const one of result.steps) {
    for (const call of one.toolResults) {
      outputs.push(call.output as SendEmailResult);
    }
  }
  return outputs;
}
examples/vercel-ai-sdk/loop.ts, run by the test suite against a local AgentiSend server

The SDK checks the arguments against inputSchema before execute runs. A refusal comes back as { sent: false, code, fix }. agent_budget_exceeded means the period's ceiling is spent. The fix says a person raises it, in the console, so the agent stops instead of calling again.

The key

POST /api-keys with sending_access mints the key. PATCH /limits/keys/{id} sets budget_per_period and period. The same three calls, including the kill switch, are executed in An AI agent with a budget and a loop guard.

Next