Making an agent's SMS tool safe to retry: keys, budgets, receipts

An LLM retry isn't a network retry — the model re-decides. Derive the dedupe key from the run, not the arguments, cap the budget, and reconcile against billed calls.

The retry you have to defend against isn’t the one in your HTTP client. It’s the model deciding, one turn later, that it isn’t sure the text went out and calling the tool again. Infrai’s send route carries no idempotency key in its documented body, so the guard lives on your side of the boundary — and the right place for it is the tool wrapper the agent calls, not the transport underneath it.

Three properties make that wrapper safe: a dedupe key derived from the workflow’s identity rather than from the model’s arguments, a response that reports a repeat as success instead of an error, and a hard per-run send budget. Get those and a talkative agent becomes harmless; miss the first and every paraphrase becomes a new message.

An agent’s retry breaks the usual assumptions

A network retry replays identical bytes. A model retry doesn’t.

It re-decides. The second call may have “Your order has shipped!” where the first had “Your order has shipped.” — same intent, different string, and any key hashed over the message text now points at a different row. Worse, after a context window is compacted the agent may have no memory of the first call at all, so it isn’t retrying in its own view; it’s acting for the first time. Parallel tool calls add the last twist: two invocations can be in flight simultaneously, so a read-then-write check without a uniqueness constraint will let both through.

Derive the key from the run, not from the payload

Key derived fromSurvives rephrasingSurvives context compactionSurvives parallel callsVerdict
Hash of message textnonowith a unique indexbrittle
Key the model is asked to generatenononoworst option — the model invents a new one
run_id + tool-call indexyesnowith a unique indexweak after compaction
run_id + step id + recipientyesyeswith a unique indexuse this
Business event id + recipientyesyeswith a unique indexbest when one exists

The rule underneath the table: the key must name the thing that should happen once, in vocabulary the model doesn’t control. An order id and a phone number are facts about the world. A message string is an opinion the model is free to revise.

// dedupe-key.mjs — Node 22 ESM.
import { createHash } from "node:crypto";

/** Stable across paraphrasing, compaction and parallel tool calls. */
export function dedupeKey({ runId, stepId, to, purpose }) {
  if (!runId || !stepId || !to || !purpose) throw new Error("dedupeKey needs runId, stepId, to and purpose");
  return createHash("sha256").update([runId, stepId, purpose, to].join("�")).digest("hex").slice(0, 32);
}

The wrapper the agent actually calls

Two rules in the code below are easy to get wrong. The claim happens before the send, so a crash mid-flight leaves evidence rather than ambiguity. And a repeat returns { status: "already_sent" } with HTTP success — because a tool that throws on a duplicate teaches the model to try harder, which is the opposite of what you want.

// sms-tool.mjs — Node 22 ESM. The function your agent framework registers as a tool.
import pg from "pg";
import { dedupeKey } from "./dedupe-key.mjs";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const MAX_SENDS_PER_RUN = Number(process.env.MAX_SENDS_PER_RUN ?? 5);

export async function sendSmsTool({ runId, stepId, to, purpose, text, sender }) {
  const key = dedupeKey({ runId, stepId, to, purpose });

  const budget = await pool.query("SELECT count(*)::int AS n FROM agent_sms WHERE run_id = $1", [runId]);
  if (budget.rows[0].n >= MAX_SENDS_PER_RUN) {
    return { status: "budget_exhausted", limit: MAX_SENDS_PER_RUN, hint: "ask a human before sending more" };
  }

  const claim = await pool.query(
    "INSERT INTO agent_sms (dedupe_key, run_id, recipient, state) VALUES ($1, $2, $3, 'claimed') ON CONFLICT (dedupe_key) DO NOTHING RETURNING dedupe_key",
    [key, runId, to],
  );
  if (claim.rowCount === 0) {
    const prior = await pool.query("SELECT message_id, state FROM agent_sms WHERE dedupe_key = $1", [key]);
    return { status: "already_sent", message_id: prior.rows[0].message_id, state: prior.rows[0].state };
  }

  const res = await fetch(`${API}/v1/sms/send`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify({ to, body: text, from: sender }),
    signal: AbortSignal.timeout(10_000),
  });
  const json = await res.json().catch(() => ({}));

  if (!res.ok) {
    const err = json.error ?? {};
    // A 5xx that quotes your own input is permanent; keep the claim so nothing retries it.
    const permanent = /E\.164|must be|invalid/i.test(err.message ?? "") || err.retryable === false;
    if (!permanent) await pool.query("DELETE FROM agent_sms WHERE dedupe_key = $1", [key]);
    return { status: permanent ? "rejected" : "retryable", code: err.code, detail: err.message, request_id: err.request_id };
  }

  await pool.query(
    "UPDATE agent_sms SET state = $1, message_id = $2, request_id = $3 WHERE dedupe_key = $4",
    [json.data.state, json.data.message_id, json.metadata?.request_id ?? null, key],
  );
  return { status: "sent", message_id: json.data.message_id, state: json.data.state };
}

agent_sms needs exactly one thing to be correct: dedupe_key as the primary key or a unique index. That constraint, not the application logic, is what makes two simultaneous tool calls resolve to one message.

The send, and the receipt you keep

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/sms/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to": "+14155550142", "body": "Ticket 8842 has been escalated to on-call.", "from": "AcmeOps"}'

Every response carries a metadata block alongside the data, and the two fields worth storing are request_id and idempotent_replay:

{
  "ok": true,
  "data": { "message_id": "msg_9hK2LpQw4RtY7bN1cX8mZs3V", "state": "queued", "segments": 1, "cost_usd": 0.007475 },
  "metadata": { "request_id": "req_a0961517342045e7", "latency_ms": 641, "vendor": "tencent_sms", "idempotent_replay": false, "cost_usd": 0.007475 }
}

Keeping request_id turns “did this actually go out?” from an argument into a lookup. The message id does the same for delivery state, and the read is free:

curl -sS "https://api.infrai.cc/v1/sms/status/msg_9hK2LpQw4RtY7bN1cX8mZs3V" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

An id your account never created answers 404 SMS_MESSAGE_NOT_FOUND rather than an empty success, which is what lets a nightly audit distinguish “we never sent that” from “we sent it and it’s still in flight”.

Reconcile against what you were billed

Your ledger says how many messages you meant to send. The account says how many were charged. If those diverge, something outside your wrapper is sending — a second worker, a stale deployment, a human running a script.

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The response breaks 30 days of spend down by capability, so sms.send reports its own calls, failed_calls and cost:

{
  "ok": true,
  "data": {
    "period": "30d",
    "total_cost": 9.76898443,
    "total_calls": 18471,
    "breakdown": [
      { "key": "sms.send", "label": "sms.send", "cost": 0.2242, "calls": 30, "failed_calls": 0 },
      { "key": "email.send", "label": "email.send", "cost": 0.01196, "calls": 26, "failed_calls": 0 }
    ]
  }
}

Compare calls against SELECT count(*) FROM agent_sms WHERE state <> 'claimed' once a day and alert on any gap. Sends are the only metered part of this loop — status reads, cancels and suppression checks are free and rate-limited — so a message costs $0.007475 once, verified 2026-07-26 and flagged approximate because destination affects it, against $2 of free credit on a new account. Those rates move downward more often than upward, so read the live figure rather than budgeting from this page.

What this design doesn’t give you

It isn’t exactly-once, and nothing sold as SMS is. The gap that remains is the send that succeeded upstream while your process died before recording the message id: the claim row exists, so nothing sends again, but you’re left reconciling by hand. Narrowing that window further means a two-phase write, which for a notification is usually not worth the complexity — accept a rare orphan and audit for it.

Twilio and Vonage put the same responsibility on the caller, so switching provider doesn’t remove the ledger; if you’re already running one, that’s the honest comparison to make. What differs here is that the queue the agent runs on, the error capture, the send itself and the bill for all of them sit behind one key, so the reconciliation above is one API call rather than an export from three consoles.

Worth flagging two boundaries. There are no X-RateLimit-* or Retry-After headers on any route, so an agent that hits a limit learns about it reactively. And single sends publish no idempotency key of their own — the guarantee is the one you build, which is exactly why the constraint belongs in your database.

References

Browse more sms developer guides