Read our operating thesis — Machines work better in tandem
JOURNALRELIABILITY · JULY 2026

Idempotency for AI agent actions

A retried HTTP request is a duplicate. A retried agent action is a second refund. Every idempotency pattern you know assumes a client that repeats itself exactly — an agent does not, and that breaks the key.

WRITTEN BYTandem Machines
Coordinated Intelligence
PUBLISHED
READING TIME16 minutes
SHARE
LinkedInX
KEY TAKEAWAYS
  • 01Every idempotency pattern in common use assumes a client that repeats the identical request. An agent does not.
  • 02Derive the key from the intent — entity, operation, occurrence — not from a request hash and never from a per-attempt UUID.
  • 03Timeouts are more dangerous than errors: an error says nothing happened, a timeout says nothing at all.
  • 04Give every mutating tool a matching read. An agent that can check does not need to guess.
  • 05Log the intent before the attempt. Reconciliation after a crash needs a record of what was tried, not only what succeeded.

What idempotency means here

An operation is idempotent when performing it more than once has the same effect as performing it once. Reading a record is naturally idempotent; setting a field to a value is idempotent; incrementing a counter or issuing a refund is not. Making the second category safe to retry requires a deduplication mechanism, and for agents that mechanism has to be built differently.

The concept is old and well understood in web APIs. What follows is not an introduction to it — it is an account of the specific ways the standard approach fails when the caller is a language model in a loop rather than an HTTP client with a retry policy.

The distinction that matters
An operation is naturally idempotent when its own semantics make repetition harmless (a PUT, an upsert, a set assignment). It is made idempotent when repetition is harmful and you add a deduplication record to suppress it. Almost everything an agent does that anyone worries about falls in the second category.

Why agents break the standard pattern

The HTTP pattern assumes the client sends the same request again with the same key, so the server can recognise it and replay the stored response. An agent violates that assumption in four ways: it may retry with different arguments, retry through a different tool, retry as a fresh instance after a crash, or retry many turns later after other actions have intervened.

Consider what actually happens when a tool call fails. A conventional client catches the error and re-sends a byte-identical request. An agent does something more interesting and much less convenient: it reasons about the failure. It reads the error, forms a theory, and tries something slightly different — a corrected argument, a narrower scope, sometimes an entirely different tool that reaches the same outcome.

“A dumb client repeats itself. An agent reasons about the failure — which means the second attempt is not the same request.”

That behaviour is exactly why agents are useful, and it is precisely what defeats the two key strategies people reach for first.

Key strategyWorks for an HTTP clientFails for an agent because
Hash of the request bodyYes — the retry is byte-identicalThe agent's second attempt has different arguments, so it hashes differently and is treated as new
Client-generated UUID per callYes — the client reuses its key on retryThe agent generates a fresh value each attempt, so nothing ever matches
Intent-derived keyYesThis is the one that survives — see below
BOTH STANDARD APPROACHES FAIL FOR THE SAME REASON — THEY KEY ON THE REQUEST

The four retry shapes

HOW AN AGENT'S SECOND ATTEMPT DIFFERS FROM THE FIRST
01Same call, retriedTransport failure; the agent repeats the tool call as-is
02Corrected argumentsThe agent read the error and adjusted its input
03Different toolThe agent decided the first approach was wrong and routed around it
04New instanceA crash or session restart; no memory of the first attempt at all
ONLY THE FIRST OF THESE IS COVERED BY A CONVENTIONAL RETRY POLICY

The fourth is the one that actually causes incidents. A process dies mid-loop, something restarts it, and the replacement has the original objective but no knowledge of what the first instance already did. Any deduplication that lives in the agent’s own memory is gone with the process that held it.

Deriving the key from intent

Build the key from the business entity, the operation, and the logical occurrence — refund:order_1234:line_2. That value is identical no matter how the agent phrases its arguments, which tool it routes through, or which process is running. It is derivable independently by any instance, which is what makes it survive a crash.

The test for a good key: could a second, independent agent with the same objective compute the same key without having seen the first attempt? If yes, the key works. If it depends on anything the first attempt generated — a UUID, a timestamp, a request hash — it does not.

Key derivation
// ✗ Fails: different arguments produce a different hash
const key = sha256(JSON.stringify(toolInput));

// ✗ Fails: a fresh value every attempt, so nothing ever matches
const key = crypto.randomUUID();

// ✓ Works: derivable by any instance, from the intent alone
function refundKey(orderId: string, lineId: string) {
  return `refund:${orderId}:${lineId}`;
}

Note what the good key does not contain: the amount. That is deliberate and it is the interesting part. If the agent decides on a different refund amount the second time round, you almost certainly still want that suppressed — one refund per order line is the business rule, and a second refund for a different amount violates it just as badly as a duplicate for the same amount. Including the amount in the key would let it through.

Where the record lives

The conventional 24-hour key expiry exists to cover client-side network retries, which resolve in seconds. An agent operates on a different timescale: it may revisit the same order in a later session, next week, after a human asked it to take another look. If the rule is one refund per order line, then the deduplication record belongs on the order — as a real constraint in the domain model — not in a cache with a TTL chosen for a different problem.

A useful reframing: you are usually not implementing idempotency keys, you are implementing a uniqueness constraint on a business fact. If “a line can be refunded once” is true, the database should say so. Then the deduplication is a consequence of the schema rather than a layer that can be forgotten.

Timeouts, not errors, are the dangerous case

An explicit error tells the agent that nothing happened. A timeout tells it nothing at all — and a model with no information will typically assume failure and retry. Most duplicate actions in production trace to a call that succeeded on the server and timed out on the way back.

This asymmetry deserves more attention than it gets. Teams write careful error-handling paths and then discover that the errors were never the problem: the model handled a 422 invalid amount perfectly well. What it handled badly was thirty seconds of silence.

The fix is a read, not a retry policy

The single highest-leverage change is to give every mutating tool a corresponding read, and to say so in the tool description. An agent that can ask “does a refund exist on this line?” does not need to guess whether its own attempt landed.

Pair every mutation with a read
const issueRefund = tool(
  "issue_refund",
  "Issue a refund for one order line. If a call times out, do NOT retry — " +
    "call get_refunds first to check whether it already succeeded.",
  { orderId: z.string(), lineId: z.string(), amountCents: z.number().int() },
  async (input) => { /* ... */ },
);

const getRefunds = tool(
  "get_refunds",
  "List refunds already issued for an order. Call this before issuing a refund, " +
    "and after any issue_refund call that failed or timed out.",
  { orderId: z.string() },
  async ({ orderId }) => { /* ... */ },
  { annotations: { readOnlyHint: true } },
);
THE INSTRUCTION BELONGS IN THE TOOL DESCRIPTION — THAT IS WHAT THE MODEL READS AT DECISION TIME

Read-your-write beats deduplication where it is available, because it removes the ambiguity instead of compensating for it. Keep the idempotency key anyway — it is the backstop for the case where the agent skips the check — but the read is what prevents the attempt.

Classifying your tools

Sort every tool by which failure hurts more: a duplicate or a drop. That gives three classes with three different mechanisms. Most teams apply exactly-once machinery uniformly, which is expensive where it is unnecessary and insufficient where the real answer is a human.
ClassDuplicateDropMechanism
Exactly-onceHarmfulHarmfulIntent-derived key + uniqueness constraint + reconciliation
At-least-onceHarmlessHarmfulMake naturally idempotent — upsert, set semantics, retry freely
At-most-onceHarmfulTolerableHuman approval gate. Do not retry automatically at all
THE THIRD ROW IS THE ONE PEOPLE TRY TO SOLVE WITH RETRIES, AND CANNOT

Refunds, payments and order placement are exactly-once. Cache invalidation, internal state sync and log writes are at-least-once, and the right move there is to redesign the operation so repetition is meaningless rather than to guard it. Anything irreversible and externally visible — an apology email to a customer, a message to a partner, a public post — is at-most-once, and the honest answer is that no retry policy makes it safe. Put a person in front of it.

That third row is where the classification earns its keep. Teams repeatedly try to make “send the customer an email” safe with idempotency keys, when the actual requirement is that a human sees it once before it goes out, and never after.

The concurrent duplicate

Two tool calls carrying the same key can arrive at the same moment — agents call tools in parallel, and a supervisor may run more than one instance. A check-then-write implementation has a race between the check and the write, so the deduplication must be a single atomic operation.

The naive shape reads the key, sees nothing, and proceeds. Two callers both read nothing, both proceed, and you have the duplicate the key was supposed to prevent. Correct implementations insert first and let the database arbitrate.

Atomic claim, then act
// The unique constraint on (key) is what makes this safe.
// Insert first — the loser of the race gets a constraint violation, not a duplicate.
async function claim(key: string): Promise<"claimed" | "in_progress" | "done"> {
  try {
    await db.actions.insert({ key, state: "in_progress", startedAt: now() });
    return "claimed";
  } catch (e) {
    if (!isUniqueViolation(e)) throw e;
    const existing = await db.actions.findByKey(key);
    return existing.state === "done" ? "done" : "in_progress";
  }
}

async function issueRefundOnce(orderId: string, lineId: string, amountCents: number) {
  const key = `refund:${orderId}:${lineId}`;

  switch (await claim(key)) {
    case "done":
      // Replay the stored outcome rather than acting again.
      return (await db.actions.findByKey(key)).result;
    case "in_progress":
      // Another attempt is mid-flight. Do NOT act. Report the conflict so the
      // agent can wait and read, instead of assuming failure and retrying.
      throw new InProgressError(key);
    case "claimed":
      break;
  }

  const result = await paymentProvider.refund({ orderId, lineId, amountCents });
  await db.actions.complete(key, result);
  return result;
}
RETURNING THE STORED RESULT ON A REPLAY MATTERS — AN AGENT THAT GETS AN ERROR WILL TRY AGAIN

Two details in there are easy to get wrong. Replaying the stored result on a duplicate rather than returning an error is important, because an error is exactly the signal that provokes another attempt. And in_progress must be distinguishable from done: collapsing them either blocks a legitimate replay or tells the agent a mid-flight action has completed.

Orphaned claims

A claim written by a process that then died leaves a permanent in_progressrecord, and every later attempt is refused. Store the claim timestamp and treat claims older than the operation’s plausible duration as candidates for reconciliation — not for automatic release. Auto-releasing a stale claim is how you get the duplicate you built all of this to prevent, because the original operation may have succeeded after all.

Crash and reconciliation

Reconciliation needs to answer “was this attempted?”, which requires the intent to be written down before the attempt. Systems that log only outcomes cannot distinguish an action that never started from one that succeeded and lost its acknowledgement — the two cases requiring opposite responses.

This is a write-ahead log in miniature, and it is the same discipline as an audit trail: record the decision and its inputs at the moment the agent commits to it, then record the outcome separately. The gap between the two records is the set of actions whose status is genuinely unknown, and that set is what a human or a reconciliation job needs to inspect.

Record stateWhat it meansCorrect response
No recordNever attemptedSafe to act
in_progressAttempted; outcome unknownDo not act. Read external state or escalate
doneCompleted; result storedReplay the stored result
in_progressStale — older than the operation’s plausible durationReconcile against the external system. Never auto-release

The asymmetry in the last row is the point. An unknown-status action can only be resolved by asking the system that would have performed it. If the payment provider has a refund for that line, the action completed; if it does not, it did not. Guessing is not an option available to you, and it is not one you should make available to the agent either.

Testing it

Three tests cover the cases that matter, and none of them are the happy path: call the same intent twice with different arguments, kill the process between the claim and the completion, and fire two identical calls concurrently. If all three leave exactly one effect, the mechanism works.
THE THREE TESTS THAT ACTUALLY EXERCISE IT
01Reworded retrySame intent, different arguments — must be suppressed
02Crash mid-actionKill between claim and complete; restart must not duplicate
03Concurrent duplicateTwo identical calls at once — exactly one effect
RUN THESE AGAINST THE REAL STORE — AN IN-MEMORY MOCK CANNOT FAIL THE THIRD

The third has to run against the real database. A mocked store serialises access and will pass a check-then-write implementation that the actual one fails, which is a specific and common way to ship this bug with green tests.

There is also a test worth writing at the agent layer rather than the tool layer: give the agent a tool that times out on the first call and succeeds on the second, and assert on the number of real effects. That exercises the model’s retry behaviour, the tool description, and the deduplication together — which is the only combination that actually runs in production.

None of this is novel engineering. It is standard distributed-systems hygiene applied to a caller that is more capable and less predictable than the ones the patterns were designed for. The only real conceptual move is to stop keying on the request and start keying on the intent — because the intent is the thing that survives the agent changing its mind.

Frequently asked questions

What is idempotency?

An operation is idempotent when performing it more than once has the same effect as performing it once. Reading a record is naturally idempotent. Setting a value to 5 is idempotent. Adding 5 to a value is not, and neither is issuing a refund — which is why retrying the second kind safely requires extra machinery.

Why do AI agents need idempotency?

Because an agent retries on its own initiative, and it often cannot tell whether its previous attempt succeeded. A tool call that times out returns no result, so the agent assumes failure and tries again. Without a deduplication mechanism, that second attempt is a second real action — a second refund, a second order, a second email.

How is agent idempotency different from HTTP idempotency?

HTTP idempotency assumes a client that repeats the identical request with the identical key. An agent is not that client: it may retry with different arguments because it reasoned about the failure, retry through a different tool, or retry as a fresh instance after a crash. Keys derived from a request hash or generated randomly per attempt both fail under those conditions.

What should an idempotency key be derived from?

The intent, not the request. A key built from the business entity, the operation and the logical occurrence — for example refund:order_1234:line_2 — stays stable when the agent rewords its arguments, switches tools, or is replaced by a new instance. A hash of the request body does not, and a per-attempt UUID defeats the purpose entirely.

How long should an idempotency key be stored?

For the business window, not a technical TTL. The common 24-hour expiry exists to cover client-side network retries. An agent may revisit the same order days later, so if the rule is one refund per order line, the deduplication record belongs on the order itself rather than in a short-lived cache.

What should happen when an agent's tool call times out?

The agent should be able to read the current state rather than guess. A timeout is more dangerous than an error because an error confirms nothing happened while a timeout confirms nothing at all. Giving every mutating tool a corresponding read — so the agent can check whether the refund exists before issuing one — prevents more duplicates than any retry policy.

Sources

  1. 01Idempotent requests Stripe, 2026
  2. 02Making retries safe with idempotent APIs Amazon Builders' Library, 2024
  3. 03The Idempotency-Key HTTP Header Field IETF HTTPAPI Working Group, 2025
Tandem Machines
Notes on coordinated intelligence — one essay a month
Subscribe
PREVIOUS

The AI agent tech stack: seven layers