Skip to content

Blog · Published 2026-09-29 · AgentiSend team

What an agent should do with a refusal: code, fix, retry_after_seconds

A refusal carries a code, a message and a fix. Wait only when retry_after_seconds is present. Three decisions stay with a person.

Filed under Guardrails

A refusal is a JSON body with code, message, fix, docs_url and retryable. When retryable is true, the same body includes retry_after_seconds. When retryable is false, that field is absent. Wait, then send the same request again, only in the first case.

When waiting works

rate_limit_exceeded and rate_ceiling_exceeded set retryable to true. What rate_limit_exceeded means says to wait the seconds in retry_after_seconds and send the same request again. How do I handle rate limits? is the same rule for a client.

When waiting leaves the refusal in place

approval_required, agent_budget_exceeded and human_action_required set retryable to false. The body has no retry_after_seconds. Sending the same request again returns the same refusal.

{
  "error": {
    "code": "approval_required",
    "message": "Blocked: this agent has sent 3 near-identical emails to customer@example.com within 60 minutes, which matches a retry or script loop; review the agent before sending more.",
    "fix": "Do not send it again: a person approves or rejects it in the console under Agents → Approvals, and approving sends it — the message then appears in GET /emails. action_id in this error names the held send; the key that asked cannot approve itself, and a retry waits on the same approval.",
    "docs_url": "https://agentisend.com/what-does-approval-required-mean#approval_required",
    "retryable": false
  }
}
{
  "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
  }
}
{
  "error": {
    "code": "human_action_required",
    "message": "This is a person’s decision, so an API key cannot make it.",
    "fix": "Ask whoever runs this account to do it in the console. Scoping the key differently does not change the answer, and retrying fails the same way.",
    "docs_url": "https://agentisend.com/docs/errors#human_action_required",
    "retryable": false
  }
}

Those blocks are the catalogue message and the catalogue fix. A loop hold replaces the approval_required message with the guard's sentence and keeps that fix. What agent_budget_exceeded means. Budgets and the kill switch.

Three decisions a key cannot make

Approving a held send, resuming a paused key, and raising a budget are a person's decisions. An API key that tries one of them gets human_action_required. The fix says to ask the person who runs the account to do it in the console.

What the example does with the refusal

The handler below is the budget example, which CI runs against the API. It sends until the loop guard refuses, then it reads code, message and fix and stops.

/**
 * 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 50 a
  //    day would be up to 1,500 in 30 days, more than a new Free key's 1,000.
  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

Every code, with its message and its fix, is in the error catalogue.