Frameworks and agents · Published 2026-10-21 · Updated 2026-10-21 · AgentiSend
Send email from Mastra
AgentiSend mail from Mastra goes through one createTool on an Agent, and execute returns a refusal as code and fix instead of throwing.
To send email from Mastra on AgentiSend, register one createTool whose execute posts to POST /emails with the agent's own key and returns a refusal as code and fix.
Install
pnpm add @mastra/core zod agentisendEnvironment
| Variable | Required | What it is |
|---|---|---|
AGENTISEND_API_KEY | yes | The agent's key, with sending_access and a budget on it. Not your own. |
MAIL_FROM | yes | The From address, on a domain you have verified. |
AGENTISEND_BASE_URL | no | Defaults to https://api.agentisend.com. |
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 agent
/**
* 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;
}Mastra checks inputSchema before execute. The object execute returns is the tool result. The agent instructions say to stop when fix names a person, so a later step does not treat the refusal as a timeout. The suite constructs the agent and calls the tool twice. It does not call a hosted model. The second result is:
{
"error": {
"code": "agent_budget_exceeded",
"message": "This key has used 1 of its 1 recipients for the current monthly period. Each To, CC and BCC recipient counts as one email.",
"fix": "Wait for the period to reset — get_agent_budget and GET /limits/keys/:id both say when. Raising a budget is a person’s decision, made in the console; a key cannot raise its own.",
"docs_url": "https://agentisend.com/what-does-agent-budget-exceeded-mean#agent_budget_exceeded",
"retryable": false
}
}execute sets Idempotency-Key to purpose/recipient. The ceiling on the key is set in An AI agent with a budget and a loop guard before Agent is constructed. reply_to_email is how this agent answers one stored message. Listing the inbox is a full-access read, in Receiving and replying.