AI Agent Idempotency: Prevent Duplicate Charges, Emails, and Records A developer has published a practical design for enforcing idempotency in AI agent tool layers so that retried writes—such as payment charges, customer emails, and CRM records—happen only once. The approach places a deterministic action runner between the model and state-changing integrations, using stable idempotency keys and a Postgres-backed operation ledger with a unique constraint to prevent duplicate side effects. The guide stresses that a timeout represents an unknown commit state and must be reconciled rather than silently treated as a failure. A timeout is not a failed action. It is an unknown action. That distinction matters the first time an agent calls a payment API, sends a customer email, or creates a CRM record—and loses the response. If the agent retries blindly, it may double-charge a card or send the same message twice. If it does nothing, the user’s request may never finish. A prompt telling the model to “avoid duplicates” cannot settle that ambiguity. The fix is idempotency : make each logical write happen once, even when workers crash, queues redeliver, SDKs retry, or the model asks to try again. This guide shows a practical design for putting that guarantee in the tool layer, where it can be tested and enforced. Every distributed system has ambiguous failures. A request may reach the target, commit its side effect, and then lose its response. Traditional backends solve this with idempotency keys, unique constraints, durable jobs, and reconciliation. Agents amplify the problem because they are built to keep trying. A model sees timeout , changes its plan, and asks the same tool to run again. Meanwhile, a queue may redeliver the job after a lease expires, and an HTTP client may retry underneath both of them. One user intent can become four writes. Treat these as distinct outcomes: | Outcome | What the caller knows | Safe next step | |---|---|---| | Rejected before send | The target did not receive it | Correct input, then retry if appropriate | | Explicit failure | The target responded with a permanent error | Stop or request correction | | Explicit success | The target returned a durable receipt | Store and return the receipt | | Unknown commit state | The request may have succeeded | Reconcile before retrying | The last row is the important one. A timeout after send must never be silently mapped to “failed.” An idempotency key identifies the business action, not a network attempt. For example, “send the approved invoice email for invoice inv 123 revision 4” is one action. It should keep the same key if a worker restarts five times. A later resend after a user edits the invoice is a new action and needs a new key. Good keys are stable, scoped, and inspectable: tenant:acme | run:run 8f2 | step:email invoice | invoice:inv 123 | revision:4 Hash that canonical representation if it contains sensitive identifiers. Do not generate a new UUID on every retry; that turns a dedupe mechanism into a duplicate generator. Also do not use only the user prompt. “Email this invoice” can be a valid request more than once. An action should usually include: The agent should request a business action, not manipulate retry behavior directly. Place a deterministic action runner between the model and every state-changing integration. php agent plan - tool request business intent - action runner policy + operation ledger - external API / database - receipt + reconciliation result - agent This boundary is useful even if your tools are ordinary functions. It gives the system one place to validate tenant access, lock the operation, pass provider keys, classify failures, and hide unsafe retry choices from the model. Reads can often be retried. Writes need an operation record first. Postgres is enough for many teams. Create the operation before calling the external service; keep it until the action’s replay window is over. create table agent operations id uuid primary key, tenant id uuid not null, idempotency key text not null, action type text not null, args hash text not null, status text not null check status in 'pending', 'running', 'succeeded', 'unknown', 'failed', 'needs review' , provider reference text, result json jsonb, error json jsonb, lease expires at timestamptz, created at timestamptz not null default now , updated at timestamptz not null default now , unique tenant id, idempotency key ; The unique constraint is a hard concurrency boundary. Two workers can receive the same job, but only one can own the logical action. Store an args hash too: if the same key arrives with different arguments, fail closed. Reusing a key with changed data is almost always a caller bug. pending means no worker has started the side effect. running means a worker holds a short lease. succeeded stores the exact receipt to return on a duplicate call. unknown means the downstream result is ambiguous, so a reconciliation job—not an agent—must decide what happened. Avoid marking an operation failed simply because the HTTP request timed out. That destroys the evidence needed to prevent a duplicate. Here is a simplified pattern. In production, the claim query should use a transaction and a lease so a crashed worker can be recovered safely. type ActionResult = { receiptId: string; status: "sent" | "already sent" }; async function sendInvoiceEmail input: { tenantId: string; runId: string; invoiceId: string; revision: number; to: string; } : Promise