I build AgentiSend, which is one of the three products compared below. Read the comparison with that in mind — every row cites a page you can open yourself, and two of the rows say the other products are fine.
The new failure mode is a retry loop with a mail client attached
The old way to send too much email was a bug in a for-loop: you wrote users.forEach(send) against the wrong query and found out from the support inbox. It was bad, and it was bounded, because the loop terminated.
An agent does not terminate. An agent has a goal, a tool, and an error it does not understand. The send times out at the load balancer, the model reads the timeout as "that did not work", and it calls the tool again. Then again. The message is identical each time, so nothing in your code looks wrong: every individual call is a well-formed transactional send to a real person who really does have an open ticket. The forty-first copy is the one the recipient reports.
This is not hypothetical arithmetic about token budgets. It is the ordinary shape of a tool-calling loop when the tool's failure signal is ambiguous. So an autonomous sender needs three things that a transactional API was never asked for before:
- A ceiling on the credential, not on the code. The agent holds a key; the key has a number attached; past that number the send is refused. Code you can edit is not a control when the thing editing the behaviour is a model.
- Duplicate detection at the send path. Same body, same recipient, same window — hold it, do not deliver it, and tell someone.
- A stop button. One request, from a phone, that pauses everything while you work out what happened.
The rest of this piece is the honest version of building those yourself, then what it looks like when they are API primitives, then a table.
Building it yourself on Resend or Postmark
You can do this. It is roughly forty lines and it mostly works. Here is the shape, written against the Resend SDK; the Postmark version differs only in the client call.import { Resend } from 'resend'; import { createHash } from 'node:crypto'; const resend = new Resend(process.env.RESEND_API_KEY); // Process-local state. This is the part that is a lie in production. const sentToday = new Map(); // agentId -> count const recent = new Map(); // fingerprint -> timestamp let paused = false; const DAILY_CAP = 50; const DUPLICATE_WINDOW_MS = 60 * 60 * 1000; function fingerprint(message) { return createHash('sha256') .update([message.to, message.subject, message.text].join('')) .digest('hex'); } export async function agentSend(agentId, message) { if (paused) { return { ok: false, code: 'paused', fix: 'A person turned sending off. Turn it back on.' }; } const used = sentToday.get(agentId) ?? 0; if (used >= DAILY_CAP) { return { ok: false, code: 'budget_exceeded', fix: `Raise the cap for ${agentId} or wait for the reset.` }; } const print = fingerprint(message); const last = recent.get(print); if (last && Date.now() - last < DUPLICATE_WINDOW_MS) { return { ok: false, code: 'duplicate', fix: 'This exact message went to this address already.' }; } const { data, error } = await resend.emails.send(message, { idempotencyKey: `agent/${agentId}/${print}`, }); if (error) return { ok: false, code: error.name, fix: '' }; sentToday.set(agentId, used + 1); recent.set(print, Date.now()); return { ok: true, id: data.id }; } export function killAll(reason) { paused = true; console.warn('sending paused:', reason); }
That is a working guard, and if you run one process and one agent it is enough. Three things about it are worth saying out loud before you ship it.
The state is in the wrong place. sentToday is a Map in one Node process. Two instances behind a load balancer means two independent caps, so the real ceiling is DAILY_CAP × replicas. Move it to Redis or Postgres and the counter now needs to be atomic, because the whole point is the moment when two concurrent sends both read 49. That is a compare-and-set, a test for the race, and an on-call story the first time the cache is cold.
The kill switch does not reach the process that is looping. paused = true is a variable in whichever instance served the request. You need it in shared storage too, read on every send, which is a hot-path lookup you now have to make fast and fail-safe — and "fail-safe" here means deciding whether a Redis outage should stop all mail or none.
The daily reset is a cron job you will forget. sentToday has no expiry. Whose midnight? What happens to the counter during a deploy?
None of this is hard. It is all tedious, it is all load-bearing, and you are writing it instead of the thing your agent is for. Resend and Postmark are good at delivering mail — that is a different job from refereeing a model, and neither of them claims to do the second one.
One piece of that snippet is genuinely worth keeping either way: the idempotency key derived from the content rather than the clock. Resend documents Idempotency-Key on POST /emails, noting that keys "expire after 24 hours" and may be up to 256 characters. A retry after a timeout replays the first send instead of mailing twice, and that alone kills the most common duplicate.
The same three controls as API calls
Here is the version where the ceiling lives on the credential. Install:npm install agentisend
You mint the agent a key of its own and attach the limit before you hand it over. Your key does this; the agent never holds it.import { AgentiSend } from 'agentisend'; const owner = new AgentiSend(process.env.AGENTISEND_API_KEY); // yours, never the agent's const key = await owner.apiKeys.create({ name: 'support-agent', permission: 'sending_access' }); await owner.limits.update(key.id, { budget_per_period: 50, period: 'daily', rate_ceiling_per_minute: 10, }); const agent = new AgentiSend(key.token); // holds only its own key
sending_access is the narrow permission: that key cannot read the message log, touch domains, or mint further keys. The two ceilings are messages per period and messages per rolling minute. Both count messages — there is no cost counter, which is worth knowing before you plan around one.
Now the send. preflight runs every gate — domain, budget, loop guard, suppression, content — and delivers nothing, which is the call you want in the tool's dry-run path and in your tests:const message = { from: 'receipts@yourdomain.com', to: 'customer@example.com', subject: 'Your receipt', text: 'Thanks. The details are attached to your account.', }; const check = await agent.emails.preflight(message); if (!check.ok) throw new Error(`${check.error?.code}: ${check.error?.fix}`); const { id } = await agent.emails.send(message, { idempotencyKey: `receipt/${message.to}` });
And the part the article is about — what the agent reads when it loops. Refusals throw AgentiSendError, carrying code, message, fix and docsUrl:import { AgentiSend, AgentiSendError } from 'agentisend'; try { await agent.emails.send(message); } catch (err) { if (err instanceof AgentiSendError) { // approval_required — the loop guard held it; a person decides // agent_budget_exceeded — the ceiling on this key is spent // kill_switch_active — someone stopped everything console.error(err.code, err.fix); } }
The fix field is the bit that changes how a model behaves. A refusal that says "Forbidden" invites a retry, because retrying is what models do with ambiguity. A refusal that says this send is waiting in the approvals inbox, and the key that asked cannot approve itself is an instruction, and the loop stops. Feed code and fix back as the tool's return value and the model has somewhere to go that is not "try again".
The kill switch is one request because the moment you need it is not the moment to read documentation:curl -X POST https://api.agentisend.com/limits/kill-all \ -H "Authorization: Bearer $AGENTISEND_API_KEY" \ -H "Content-Type: application/json" \ -d '{"reason": "invoice-bot loop"}'
Every key on the account stops; queued sends stay queued rather than being lost; POST /limits/resume-all is a second, deliberate request, and both write an audit row with the reason you typed. A single key stops with POST /limits/keys/{id}/kill.
The runnable version of all of this — mint the key, set the ceiling, then loop on purpose so you can watch the refusal arrive — is the agent with a budget and a loop guard guide.
The comparison
Every "them" cell below comes from the vendor's own published documentation, linked at the head of the column, and every "AgentiSend" cell is an endpoint in a published API contract.
Capability Resend Postmark AgentiSend Send budget per key Keys carry a permission (full or sending); no per-key send budget in the published API Server and account tokens carry privileges, not send budgets PATCH /limits/keys/{id} sets messages per period and per rolling minute; over it, agent_budget_exceeded Duplicate / loop detection Not documented Not documented Repeated near-identical sends to the same recipient inside a window are held rather than delivered Account-wide kill switch No account-wide pause in the published API; keys are deleted one at a time No account-wide pause documented; tokens are revoked one at a time POST /limits/kill-all pauses every key and writes an audit row; resume-all undoes it Approval queue for held sends Not documented Not documented A held send waits in the approvals queue; GET /agent-actions lists it and a person approves or rejects Dry run that reports every gate Not documented The POSTMARK_API_TEST token validates request data without delivering POST /emails/preflight runs domain, budget, loop, suppression and content checks, sends nothing, returns { ok, checks, error } Refusal names the call that repairs it An error name and a message ErrorCode (integer) and Message code, message, fix, docs_url, plus retryable and a wait hint when waiting can help Hosted MCP server Yes — Resend hosts one at mcp.resend.com/mcp, with an open-source local package Not documented Yes — api.agentisend.com/mcp, same scoped key, so the budget applies to the tool call Idempotency keys Idempotency-Key on POST /emails and the batch endpoint; keys expire after 24 hours Not documented on the email API Accepted on every mutating endpoint, remembered for 7 days
Two rows deserve a note rather than a tick. The MCP row is a genuine draw on existence: Resend shipped a hosted server with a broad tool surface, and "we have an MCP server" is not a differentiator any more. The difference is what happens when the agent calls the tool twice too often, which is the rest of the table. And Postmark's test token is a real feature doing a narrower job — it validates that your request is well-formed, which is what you want in CI, and it is not a budget check because Postmark does not have budgets to check.
When to pick which
Pick Postmark if what you are sending is receipts, password resets and alerts from application code you wrote, and the sender is a deployment rather than a model. Its focus on transactional mail is the whole product, and the controls in this article are answering a problem you do not have.
Pick Resend if you want the largest developer ecosystem in this category and your agent work is early enough that a process-local cap is genuinely fine. The forty lines above are real code, they work, and shipping them on top of Resend is a perfectly defensible engineering decision — especially if you already run one process.
Pick AgentiSend when the credential leaves your codebase. The moment an agent holds a key, or a customer's agent does, the ceiling has to be enforced somewhere the model cannot reach, and that is the argument for putting it at the API. A fuller row-by-row version of the first column is at AgentiSend vs Resend.
The honest negative: AgentiSend is younger than both, and if your requirement is "signed business associate agreement for health data", none of the three above is your answer — that is Amazon SES.
FAQ
Can't I just set a rate limit in my code?
You can, and for one process you should. The failure is distribution: a counter in a Map multiplies by your replica count, and the moment you move it to shared storage you are writing an atomic decrement, a race test, and a decision about what happens when the store is down. The reason to push it to the API is not that the logic is hard, it is that the enforcement point should be somewhere the agent's own code cannot be edited to bypass.
What stops the agent from just raising its own budget?
Permission scoping. The key you hand the agent is minted with sending_access, which cannot read your message log, touch your domains, or mint further keys — and cannot raise its own ceiling. Raising it is a call from your key. Same principle as the approval queue: the key that asked for a held send cannot approve it.
How do I test the refusal without waiting for an agent to misbehave?
Make it misbehave on purpose. Set a budget of a handful of messages and send past it in a loop, or send the same body to the same address four times and watch the loop guard hold the fourth. The published example does exactly that and prints the refusal, its code, and its fix. For test suites that must not touch anyone, address the mail to a @simulator.agentisend.com address — nothing leaves the building.
Further Reading
Discover more articles on similar topics across our network
Comments
Loading comments…