AI agents

Idempotency Keys for Agent Writes Behind a Human Gate

Antonio Evans10 min read

A close-up of a steel key stamped with a UUID, inserted into a heavy padlock on a document labeled 'Approved', with a dashed line showing a retry path looping back to the same lock.
Key points

Key points

  1. 1Derive the idempotency key from the immutable approval record (run ID, step ID, tool name, target resource), never from the model output or the retry attempt.
  2. 2Error handling must write the key to a durable store before it calls the side-effecting API, and that store must record the execution state, not just the key.
  3. 3The double-execution failure mode happens when a key is bound to an attempt instead of to the approved action, so two retries look like two permitted actions.
  4. 4Check the key before every execution attempt, hold a lock while the action runs, and never reuse a key for a new approval.

Why an approval record is not an idempotency key

An approval schema is good at naming what a human authorized: the tool, the arguments, the reviewer, the timestamp. It is silent about execution. The moment execution begins, two facts start changing in ways the approval record does not capture: how many times the action has been attempted, and whether the outside system has already accepted it.

That gap is where duplicates live. A timeout on a payment API tells you only that the caller stopped waiting, not whether the charge posted, a distinction retry logic has to assume the worst about why classic retry logic misleads you here. In a normal service this ambiguity is manageable. In an agent loop it is worse, because the retried step is a non-deterministic model call that may choose a different action the second time.

The general fix is old: any request that changes state should produce the same result however many times it runs, the property Temporal's write-up on idempotency and durable execution defines as idempotent. For agents the twist is that the thing you must make idempotent is not the model call. It is the tool execution on the other side of a human gate.

So keep two records, separate and linked. The approval record is evidence for the reviewer and the auditor. The execution record is state for the runner: a key, a state, a result, a timestamp. The article on idempotency for agents shows the incident shape that produces four refunds from one approval when the runner wakes four times and finds the approval flag still true.

What to derive the key from

The tempting derivation is a hash of the tool arguments. The article on agent reliability patterns explains why that fails: two legitimate refunds of the same amount to the same customer for two orders produce identical arguments, and two retries of one intended action can differ if the model rephrases a free-text field. Argument hashing collides distinct actions and splits identical ones.

Derive from intent instead. The article's recipe ties the key to four things: the run identifier, the step index or plan item, the target resource, and the action name. That combination says "in this run, for this planned step, perform this action on this resource". A retry of the same step produces the same key no matter what words the model used, and a genuinely different action produces a different key.

Because approval is part of the story, extend the recipe with the approval identity. A reviewer who rejects and then re-submits, or a second approval round after a change, should not land on the old key. Bind the key to the specific approval decision record, not to the queue item.

Candidate inputStable across retriesDistinguishes two legitimate actionsSurvives model fallback
Hash of tool argumentsNo (model may rephrase)No (identical refunds collide)No
Model output textNoNoNo
Retry attempt numberNo (changes every time)Yes, wronglyNo
Run ID aloneYesNo (many actions per run)Yes
Run ID + step ID + action + target + approval IDYesYesYes

The last row is the one to implement. Derive the key once, when the approval becomes executable, and write it into the execution record. Everything downstream reads the stored key. Nothing recomputes it from live state.

A second subtlety: the approval system may hold arguments that do not match what the agent will actually send at execution time. Fix the approved arguments into the approval record and have the runner send exactly those. The HITL approval JSON evidence approach is built on this idea, capturing immutable context snapshots and explicit parameter diffs so the action a reviewer saw is the action that runs. If the runner rebuilds arguments at execution, argument drift breaks the equality you were counting on, and a payload mismatch becomes your next duplicate source.

A key derivation you can implement

The following is a small, library-free version. It hashes a canonical string so logs are readable and the key fits any header or column.

import hashlib

def idempotency_key(run_id, step_id, action, target, approval_id):
    canonical = "|".join([
        "v1",                # bump only with a migration plan
        str(run_id),
        str(step_id),
        str(action),         # e.g. "stripe.refund"
        str(target),         # e.g. "order:98412"
        str(approval_id),    # the specific decision record
    ])
    return "idem_" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()

Three properties matter here. The version prefix lets you change the scheme without silently colliding old records. The action name prevents a refund and a void on the same order from sharing a key. The approval ID prevents a re-submission from being swallowed by the earlier approval's record, which is what you want when a reviewer legitimately changes the decision.

Do not include the model name, the provider or the tools list. The statement that approvals are run-wide, including across handoffs and nested agent-tool executions, comes from the OpenAI Agents SDK human-in-the-loop docs, and it is exactly why the key cannot depend on which agent or model happened to raise the call. A fallback from one model to another mid-run must not move the key.

If your platform layers a policy gate outside the agent, as described in the guide to runtime policy enforcement for AI agents, that gate is the best place to compute and attach the key. One gate in front of shell, cloud API, Kubernetes and MCP calls means one derivation path instead of one per integration.

Storing the key and the execution state

Write the execution record before the side effect, not after. The order is: insert the record with state claimed, commit, then call the API, then update to succeeded or failed. If the process dies between commit and API call, the next attempt sees claimed and must decide what that means. If it dies after the API call but before the success write, you have a dead record and a live side effect, which is why the record needs room for an outcome the runner can reconcile.

StoreGood fitWatch out for
Unique index on a relational tableDefault choice for most teamsNeeds a claim timeout or a sweeper for dead records
Key-value store with conditional writesVery high volume, simple lookupsNo joins; keep the audit copy elsewhere
Workflow engine historyTeams already using durable executionUnderstand the retry semantics before you trust them

The workflow-engine row deserves care. Native retry semantics do not hand you exactly-once as a gift. The AWS durable execution documentation on idempotency and retries is unusually explicit that both its at-least-once and at-most-once semantics are per attempt, and that neither guarantees a step runs once across the whole workflow. Only at-most-once combined with a no-retry strategy limits a step to one end-to-end attempt. Most teams need a retry, so they need their own key and their own record.

Give every record a claim timeout. A record in claimed state older than a sane window (minutes for an API call, longer for a job) becomes unknown, which routes to a reconciler rather than an automatic retry. This is the cheapest single control against the double-execution failure mode, and it costs one column and one scheduled query.

The double-execution failure mode

For a mutation, the default mode is two executions. Retying until success is the default in most frameworks, and doing so is only safe when the operation is idempotent.

The failure has a precise shape. Suppose the runner computes the key from "the current attempt" rather than from the approved action. Perhaps it appends a nonce, or uses the model's tool call ID. Attempt 1 sends idem_a. The network times out. Attempt 2 sends idem_b. The receiving system sees a new key, which by definition is a new request, so it charges again. Both attempts were individually correct. The design was wrong.

The way to stop a repeat of a failed agent step is to stop conflating two different retries. The distinction is between retrying the thinking and retrying the doing. A read-only or planning step may be re-run freely, because nothing outside the loop changed. A doing step, any tool with a side effect, gets one durable key and a recorded result, and the runner returns the recorded result instead of calling the API. That is the whole pattern, and the agent reliability write-up names it as the line that decides whether an agent can face customers.

Three variants are worth recognizing by their fingerprints:

  • A missing record: the runner calls the API with no lookup at all, so every retry is a fresh request. Fix with a mandatory lookup, failing closed.
  • A per-attempt record: the runner looks up, but the key changes per attempt. Fix by computing the key once at approval time and storing it.
  • A premature record: the runner writes succeeded before the API responds, then reuses that claim on later invocations. Fix by recording claimed first and reconciling unknowns.

The pattern applies at the tool boundary, not in the prompt, for the same reason guardrails belong at the gate. A prompt instruction is text the model reads and can be overridden, and the guardrails guide makes the general point that enforceable technical boundaries beat written policy for autonomous systems. An idempotency check in the code that executes the tool call is infrastructure between intent and side effect, not a suggestion.

Reviewer re-submission, model fallback and replay

The three scenarios the short answer names each stress a different part of the key.

Reviewer re-submission. A reviewer rejects, edits the payload, and approves again. If the key includes the approval ID, the new approval yields a new key and the action runs once for the new decision. If you instead reuse the key, the runner finds a prior rejection record and returns it, which looks like the approval was ignored. Rejections should never share a key with a later approval for the same step. Store the rejection under its own key and mark the step as superseded.

Reviewer double-click. A reviewer clicks approve twice, or the browser retries the webhook. The article on idempotency for agents traces the Slack-signal scenario: four webhooks arrive, the framework reads one approval flag four times, and four refunds go out. The fix is at the approval endpoint, which should accept the decision idempotently against the approval record, so the runner has one approval ID to work with.

Model fallback. The run pauses, the primary model errors, and a fallback picks up. The fallback may call the same tool with cosmetically different arguments. Because the key is derived from the approval record and the plan step, not the model output, the retried call lands on the existing record and returns its result. This is why the key must be stored on the approval, not inferred at dispatch time. If the fallback legitimately proposes a different action, it needs a new approval and therefore a new key, which is the correct outcome.

Long pauses and expired credentials. Approval workflows that pause for hours hit process restarts, deploys and expired OAuth tokens, a set of failure modes the guide to HITL approval workflows for SaaS API actions lays out in detail. The key does not expire with the token. The execution record outlives the process, so a resumed run that refreshes credentials and reissues the call still finds the record and avoids a second side effect. Treat the key as durable and the token as disposable.

Checklist before you turn the gate on

Run this list against one approval flow before expanding it. Each item maps to a failure you have seen in the table and the sections above.

CheckPass condition
Key derivationBuilt from run, step, action, target and approval ID; version prefix present
ImmutabilityKey computed once and stored on the approval record; no recompute at dispatch
Approval endpointDuplicate approve and reject clicks are idempotent against the decision record
Execution recordInserted and committed before the API call
Claim timeoutDead claims move to unknown and route to a reconciler
Replay behaviorA repeat returns the recorded result, never re-calls the API
Fallback pathModel switch does not change the key
Negative testKill the process mid-call in staging and confirm one side effect

The last row is the one teams skip. You cannot test the failure mode by reading code. Kill the runner between the commit and the API response, restart it, and count the side effects on the other side. If you see two, the key is bound to the attempt. If you see one and a record in unknown, the design works.

For teams standing up the whole stack rather than patching one flow, the choice of how models are reached matters more than it looks. A run that pauses for a human and resumes days later will be served by whatever model is available then, and the idempotency design above is what makes that safe. Quanta Inference offers text, image, video, audio and embedding models through one OpenAI-compatible API key, prepaid and priced in US dollars, which keeps the fallback path from also being a migration project. The key you derived from the approval record does not care which model answers the resume.

FAQ

FAQ

Should the idempotency key live on the approval record or the tool call?

Put it on the approval record, computed once when the decision becomes executable, and copy it to the tool call at dispatch. The approval is the stable fact; the tool call is the thing that repeats. Storing the key on the approval means every retry, replay and model fallback reads the same value instead of deriving a new one under pressure.

How do I handle a reviewer who rejects, then changes their mind?

Treat each decision as its own record with its own key. The rejection is recorded under one key, and the new approval under another, because the action has been re-authorized and may carry edited arguments. Reusing the rejection's key makes the second approval invisible to the runner, which returns the earlier result and leaves the reviewer convinced the system ignored them.

What happens if the process dies after the API call but before the success write?

The record stays in its claimed state until the claim timeout expires, at which point a reconciler checks the downstream system for evidence of the effect before deciding whether to retry. This is why the execution record needs a status and a timestamp, not just a key. Retrying automatically from a dead claim is how one refund becomes two.

Does an agent framework's built-in approval flow give me exactly-once execution?

No. Framework approval flows pause and resume runs, and the OpenAI Agents SDK docs on human-in-the-loop interruptions describe exactly that, but pausing is not deduplication. The AWS durable execution docs on idempotency and retries state plainly that its retry semantics are per attempt and do not guarantee a single execution. You still need your own key and record.

How does this interact with blast-radius limits on the action?

They are complementary controls on different axes. Idempotency answers "did this exact approved action already run", while blast-radius gates cap scale, rate and reversibility of actions in general, as the write-up on blast-radius gates for agent actions argues. Keep both: the gate stops the hundredth refund, and the key stops the same refund from posting twice.

Sources

Sources

  1. Retries, Idempotency and Observability in Agentic Loops, AI ...
  2. What is idempotency? And why it matters for durable systems
  3. Idempotency for Agents: The Production-Safe Pattern You' ...
  4. Implementing Human-in-the-Loop Approval JSON Evidence in ...
  5. Human-in-the-loop - OpenAI Agents SDK
  6. How to implement runtime policy enforcement for AI agents
  7. Idempotency and retries - AWS Durable Execution SDK ...
  8. Guardrails for AI Agents: Types, Benefits & How to Implement
  9. Implementing Human-in-the-Loop Approval Workflows for ...
  10. Blast-Radius Gates: Limiting What a Single Agent Action ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles