A Timeout Is Not a Failure: Build Idempotent Tool Calls for AI Agents in TypeScript A developer published a TypeScript demo showing how AI agents that retry timed-out tool calls can cause duplicate side effects like double-charging a customer's card, and built a small exactly-once layer using idempotency keys to prevent it. The mock billing service injects three fault types — lost-ack, late-commit and redelivery — and compares four retry strategies against a ledger that counts real charges, with the code available on GitHub. An agent calls a tool. The tool times out. So the agent tries again. That sounds responsible. Sometimes it is. Sometimes it is a second charge on a customer's card. A timeout tells you what the client saw. It does not tell you what the server did. That distinction matters a lot more now that agents send email, open tickets and move money. This week a paper put real numbers on it. None of the fixes are new. Old idea. New caller. So let's build a tiny exactly-once layer. By the end, you'll run one command: npx tsx retry.ts And watch four retry strategies hit four kinds of network fault, with a ledger counting every real charge. No API key. No real model. Just TypeScript. One honesty note: this is my small model of the paper's idea, not the Limbo benchmark. The faults are scripted. The billing service is fake. Code: github.com/bobbyhalljr/tool-call-idempotency https://github.com/bobbyhalljr/tool-call-idempotency A tool call is an intent with arguments. A fake billing service commits charges to a ledger. A fault injector breaks the first call in one of three ways. Four strategies try to finish the job. At the end, we count charges, not opinions. It's also a small version of the architecture behind Roster https://get-roster.com : the agent proposes, the harness owns what actually happens. The customer "acme", the $49.00 amount and the timings are example inputs. You will need Node.js 18 or newer. mkdir tool-call-idempotency cd tool-call-idempotency npm init -y npm install --save-dev typescript tsx @types/node Save the following blocks, in order, as retry.ts . // retry.ts: a tiny exactly-once layer for agent tool calls. // Everything is mocked: an in-memory billing service with injected network faults. // No API key, no network, no real model. Customers, amounts and timings are example inputs. import { createHash } from "node:crypto"; // Step 1: model the tool call type ChargeArgs = { customer: string; amountCents: number }; type Charge = { id: string; customer: string; amountCents: number; at: number }; // lost-ack: the charge commits, the response is lost client sees a timeout // late-commit: the request is still in flight when the client gives up; it lands 90s later // redelivery: the transport delivers one request twice; the client sees one success type Fault = "none" | "lost-ack" | "late-commit" | "redelivery"; type CallResult = | { status: "ok"; charge: Charge } | { status: "timeout" } | { status: "error"; message: string }; Three faults. Three different lies. lost-ack committed, but the client heard nothing. late-commit is still in the pipe when the client gives up. redelivery looks like a clean success. It charged twice. From the client's side, the first two look identical. // Step 2: a mock billing service that honors idempotency keys class Billing { ledger: Charge = ; now = 0; // simulated seconds private keys = new Map