Skip to content

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

Send email from Mastra

Give a Mastra agent one createTool that posts to POST /emails with its own budgeted key, and stop on agent_budget_exceeded.

To send email from Mastra, register one createTool whose execute posts to POST /emails with the agent's own key. The key is sending_access and carries a budget. The tool below is that call. The agent file registers it and runs it twice. Both are run by the test suite against a local AgentiSend server: the first call sends, and the second message is refused with agent_budget_exceeded. The run does not call a hosted model.

The tool

/**
 * Mastra — a `send-email` tool for an agent that holds its own key.
 *
 * The key in `AGENTISEND_API_KEY` is the agent's, minted with `POST /api-keys`
 * as `sending_access` and given a ceiling with `PATCH /limits/keys/:id`.
 * The idempotency key is the purpose and the recipient, so a retry replays
 * the first send. A refusal is returned, not thrown: the model reads `code`
 * and `fix`. For `agent_budget_exceeded` that means stopping, because the fix
 * names a person.
 */
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
import { AgentiSend, AgentiSendError } from 'agentisend';

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 = createTool({
  id: 'send-email',
  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: ' +
    'calling again will not change the answer.',
  inputSchema: z.object({
    to: z.string().describe('The recipient, one address.'),
    subject: z.string().min(1).max(200),
    text: z.string().min(1).describe('The plain-text body.'),
    purpose: z
      .string()
      .regex(/^[a-z0-9][a-z0-9_-]{0,63}$/)
      .describe('What this message is. The same purpose to the same recipient sends once.'),
  }),
  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;
    }
  },
});
examples/mastra/send-email-tool.ts, run by the test suite against a local AgentiSend server

The loop

/**
 * Mastra — the agent that holds the send-email tool, and the loop the suite runs.
 *
 * `supportAgent` is a real Mastra `Agent`. The suite does not call a hosted
 * model. `run` calls the tool twice, which is the loop: the first send is
 * accepted, and the second message is refused with `agent_budget_exceeded`
 * when the key's budget is one.
 */
import { Agent } from '@mastra/core/agent';
import { sendEmail, type SendEmailResult } from './send-email-tool.js';

type Model = ConstructorParameters<typeof Agent>[0]['model'];

export function supportAgent(model: Model): Agent {
  return new Agent({
    id: 'support-agent',
    name: 'Support agent',
    instructions:
      'You answer support tickets. Email a customer only about their own ticket, with ' +
      'send-email, one purpose per message. If a send comes back with sent: false, read ' +
      'the fix. When it says a person decides, stop and report the code and the fix.',
    model,
    tools: { sendEmail },
  });
}

const UPDATE = {
  to: 'mastra-agent@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: 'mastra-agent@example.com',
  subject: 'Ticket 4182 — closed',
  text: 'The ticket is closed.',
  purpose: 'ticket-4182-closed',
};

export async function run(): Promise<SendEmailResult[]> {
  // Constructing the agent registers the tool. Generating would call a model.
  supportAgent('openai/gpt-4o-mini');
  const outputs: SendEmailResult[] = [];
  for (const input of [UPDATE, CLOSED]) {
    const result = (await sendEmail.execute!(input, {} as never)) as SendEmailResult;
    outputs.push(result);
    if (!result.sent) break;
  }
  return outputs;
}
examples/mastra/agent.ts, run by the test suite against a local AgentiSend server

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. Those calls are executed in An AI agent with a budget and a loop guard.

Next