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;
}
},
});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;
}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.