Skip to content
Browse the docs

Frameworks and agents · Published 2026-09-22 · Updated 2026-10-21 · AgentiSend

An AI agent with a budget and a loop guard

A key scoped to sending, a ceiling, and the loop guard refusing the fourth identical send and holding it for a person.

To let an AI agent send email without letting it send too much, give it its own key scoped to sending, put a ceiling on that key, and let the API's loop guard refuse a repeated send before it reaches anyone. The script below does all three, then makes the agent loop on purpose so you can watch the refusal. It is run by the test suite against a local AgentiSend server.

Install

pnpm add agentisend

Environment

VariableRequiredWhat it is
AGENTISEND_API_KEYyesYour own key, with full_access. It mints the agent's key; the agent never sees it.
MAIL_FROMyesThe From address, on a domain you have verified.
AGENTISEND_BASE_URLnoDefaults to https://api.agentisend.com.

The script

/**
 * Give an agent its own key, its own budget, and a guard that stops it.
 *
 * The three calls below are the whole control plane:
 *
 *   POST  /api-keys                — a key scoped to sending, and nothing else
 *   PATCH /limits/keys/:id         — a hard spend ceiling for the period
 *   POST  /emails                  — the agent sends through its own key
 *
 * The last part of the script is the point. An agent stuck in a retry loop
 * sends the same message again and again; after the third near-identical send
 * inside the window the API refuses the fourth, holds it for a human to
 * approve, and returns `approval_required` with the held action's id and a
 * `fix` that says who decides it and where. Nothing was delivered, and the
 * agent is told what to do rather than left to guess.
 */
import { AgentiSend, AgentiSendError } from '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 interface Refusal {
  code: string;
  message: string;
  fix: string;
  status: number;
  /** How many sends went through before the guard refused one. */
  sendsBeforeRefusal: number;
}

export interface Report {
  apiKeyId: string;
  budgetPerPeriod: number;
  firstMessageId: string;
  refusal: Refusal;
}

export async function run(): Promise<Report> {
  // The key you already hold — full access, kept by you, never given to the agent.
  const owner = new AgentiSend();
  const from = mailFrom();

  // 1. A key the agent holds. `sending_access` cannot read your logs, touch
  //    your domains, or mint further keys.
  const key = await owner.apiKeys.create({
    name: 'support-triage-agent',
    permission: 'sending_access',
  });

  // 2. A ceiling. The agent cannot spend past it, whatever it decides to do.
  //    50 in the key's own window (a rolling 30 days) and 10 a minute. The
  //    window stays as it is: an API key may only lower a limit, and a daily
  //    window is compared over 30 days: 200 a day is up to 6,000, more than a
  //    new Free key's 5,000 a month.
  const limit = await owner.limits.update(key.id, {
    budget_per_period: 50,
    rate_ceiling_per_minute: 10,
  });

  // 3. The agent, holding only its own key.
  const agent = new AgentiSend(key.token);

  const first = await agent.emails.send({
    from,
    to: 'customer@example.com',
    subject: 'Ticket 4182 — we are looking into it',
    text: 'Someone from support will reply within the hour.',
  });

  // 4. Now the failure mode this exists for: the agent loops. Same recipient,
  //    same body, over and over. The guard refuses before the fourth copy
  //    reaches anyone.
  let sends = 1;
  for (let attempt = 0; attempt < 10; attempt += 1) {
    try {
      await agent.emails.send({
        from,
        to: 'customer@example.com',
        subject: 'Ticket 4182 — we are looking into it',
        text: 'Someone from support will reply within the hour.',
      });
      sends += 1;
    } catch (err) {
      if (err instanceof AgentiSendError && err.code === 'approval_required') {
        return {
          apiKeyId: key.id,
          budgetPerPeriod: limit.budget_per_period ?? 0,
          firstMessageId: first.id,
          refusal: {
            code: err.code,
            message: err.message,
            fix: err.fix,
            status: err.status,
            sendsBeforeRefusal: sends,
          },
        };
      }
      throw err;
    }
  }

  throw new Error('The loop guard did not refuse a repeated send. Check the key is the agent key.');
}

export function print(report: Report): void {
  console.log(`agent key       ${report.apiKeyId}`);
  console.log(`budget          ${report.budgetPerPeriod} emails in 30 days`);
  console.log(`first send      ${report.firstMessageId}`);
  console.log(`sends allowed   ${report.refusal.sendsBeforeRefusal}`);
  console.log(`refused with    ${report.refusal.code} (HTTP ${report.refusal.status})`);
  console.log(`message         ${report.refusal.message}`);
  console.log(`fix             ${report.refusal.fix}`);
}

if (process.argv[1]?.endsWith('agent.ts') || process.argv[1]?.endsWith('agent.js')) {
  print(await run());
}
examples/ai-agent-with-budget/agent.ts, run by the test suite against a local AgentiSend server

Run it

node --experimental-strip-types agent.ts

The output ends with the refusal: the code, the message naming the recipient and the window, and the fix, which says not to send it again, that a person approves or rejects it in the console under Agents → Approvals, and that the key that asked cannot approve itself. The refusal also carries action_id, the held send's id; the agent's retries wait on that one action.

What each call does

  • POST /api-keys with sending_access mints a key that reads only the mail it sent itself (and, with a domain_scope, only its mail from that domain). It cannot read mail that arrived at your domains, touch your domains or mint further keys.
  • PATCH /limits/keys/{id} sets the ceiling for the period and the rate. The agent cannot spend past it, whatever it decides to do.
  • POST /emails through the agent's key is an ordinary send until the guard sees the loop. The held send is in the approval queue: GET /agent-actions lists it for your own key, and a person signed in to the console approves or rejects it with one request.

Had the agent run past its ceiling instead, the refusal would be agent_budget_exceeded, with a fix that says to wait for the period to reset — GET /limits/keys/{id} says when — and that raising the budget is a person's decision, made in the console. POST /limits/keys/{id}/kill stops the key immediately, from anywhere.

Using it from an agent framework

The script uses the SDK directly, and that is how a framework tool wraps it: the tool holds the agent's key, calls emails.send, and returns code and fix on a refusal so the model knows what to do next rather than retrying. The same wrap is written out for LangChain.js, the Vercel AI SDK, the Claude Agent SDK, the OpenAI Agents SDK, CrewAI, Mastra and LangChain for Python. Agents that speak MCP can skip the SDK and connect to the MCP server with the same scoped key.

Next