Idempotency for LLM agents
Day 20: Idempotency When the Caller Is an Agent
Your dedupe table can report zero duplicates while your agent files three tickets for the same bug. Nothing is broken. The table is doing exactly what you built it to do. The problem is that the techniques you learned for HTTP clients assume a retry sends the same request again, and an LLM agent never does. Agents now file tickets, send emails and move money, and those are hard to undo. If you don't update the design, your safeguards will keep telling you everything is fine while the duplicates pile up.
Three tickets, zero dedupe hits
A Sentry alert fires on the auth service, and a triage agent is told to file a Jira ticket. The first call to Jira times out, so the orchestrator retries the step twice. Here's what lands in the backlog:
Attempt 1: "Login fails with 500 after SSO redirect"
Attempt 2: "SSO callback returns Internal Server Error on sign-in"
Attempt 3: "Users unable to log in: /auth/callback throwing 500"Any engineer can see in about a second that this is one bug. The dedupe table can't. It hashes the request body, the three bodies differ, and so you get three keys and three misses. Working as designed. The design just assumed a retry is a copy of the original request.
Replay vs regeneration
A network client that retries replays the request. An agent that retries regenerates the call. It still wants the same side effect, but the payload is brand new. Every problem in this lesson comes from that one difference.
What changes between attempts | HTTP client retry | LLM agent retry
--------------------------------|------------------------|-------------------------------------------
Request body bytes | Nothing | Wording, phrasing, summaries
Field ordering / optional fields| Nothing | Fields added, dropped, reordered
Amounts and quantities | Nothing | Rounding, currency format, "about $40" vs 40.00
Formats | Nothing | Dates, markdown vs plain text, casing
Which tool is called | Same endpoint | Might pick a sibling tool (create vs comment)
Idempotency key | Same (reused by client)| New, if minted at call timeIn practice, a "retry" in an agent system usually means the orchestrator runs the reasoning step again. If your tool-retry wrapper sits above the model call, each retry is a fresh sample from the model, not a resend.
Quick refresher: what an idempotency key is
An idempotency key is a unique token the client attaches to a request with side effects. The server stores the key alongside the result. When the same key shows up again, the server hands back the stored result and skips the work. Stripe's API is the standard example:
- ▹The client generates the key, usually a UUID, before sending the request.
- ▹It travels in a header (`Idempotency-Key`) next to the payload.
- ▹The server checks a dedupe table. New key: do the work and store the response. Seen key: return the stored response.
- ▹Stripe goes one step further and compares parameters on a reused key. If they differ from the original request, you get an error. A reused key is expected to come with an identical payload.
This is where agents break it. The key is either derived from the payload (a hash of the body) or minted right next to it (a UUID created inside the tool wrapper at call time). Either way, it's tied to a payload the model is about to rewrite. The hash changes the moment the wording changes. The per-call UUID is new on every attempt. And if you somehow do reuse the key, a Stripe-style server rejects the request because the parameters changed. Three failures, one cause: the key gets its identity from the payload, and the payload won't sit still.
Move the key upstream: mint it at the plan step
The fix is to create the key earlier. An agentic action moves through five stages: goal → plan step → reasoning → tool call → side effect. Everything after the plan step involves the model producing tokens, so all of it can drift between attempts. The plan step is the last stable point, which makes it the place to mint the key.
- ▹Create the plan step and write it to durable storage (your DB, or a workflow engine like Temporal) before any LLM call fills in arguments.
- ▹Give each step a stable `step_id`. Retrying the step reuses it; only a genuinely new step gets a new one.
- ▹Every tool call made while executing the step inherits that `step_id` from the orchestrator's context. The model never sees it and never generates it.
- ▹On retry, "did this already happen?" turns into a ledger lookup on step and intent key. No more guessing by comparing payloads.
- ▹Retries loop back to reasoning, never to planning. A new plan means new steps, and that's the case intent keys (next section) exist to catch.
Dedupe on intent, not payload
A step ID handles retries inside one run. It does nothing for a re-plan, a second agent picking up the same alert, or a crash that restarts the run from scratch. For those, have the plan step state the effect it intends, then build a normalized key from that statement. Think "a ticket exists for bug X" or "customer Y has been notified about invoice Z".
Build the intent key from identifiers that already live outside the model: the Sentry issue ID, the customer ID, the invoice ID and version, the target project. Never build it from text the model wrote. If the model wrote it, it'll be different next time.
{
"intent_key": "sha256(jira.ticket_exists|project=AUTH|bug=sentry:48213)",
"effect": "jira.ticket_exists",
"subject": { "project": "AUTH", "bug_ref": "sentry:48213" },
"step_id": "run_7f3a/step_2",
"status": "committed",
"result": { "issue_key": "AUTH-1042" },
"created_at": "2026-10-13T09:14:02Z"
}Now replay the three attempts from the opening with the ledger in place:
- ▹Attempt 1: the plan step declares `jira.ticket_exists` for `sentry:48213`. No ledger entry yet, so the step inserts a `pending` row and calls Jira. Jira creates AUTH-1042, but our side times out before the response arrives.
- ▹Recovery: before retrying, the orchestrator asks Jira for any issue linked to `sentry:48213`, finds AUTH-1042, and marks the row `committed`.
- ▹Attempt 2: the model writes a different title. Doesn't matter. Same intent key, ledger hit, and the tool returns AUTH-1042 without touching Jira.
- ▹Attempt 3: same again. The agent gets the existing ticket and moves on, maybe by adding a comment.
def execute_step(step, tool, args):
# step.intent_key was minted at planning time from external IDs
row = ledger.try_insert(step.intent_key, step.step_id, status="pending")
if row is None: # unique constraint hit: someone got here first
existing = ledger.get(step.intent_key)
if existing.status == "committed":
return existing.result # short-circuit
return reconcile(existing, tool) # pending: check the world, don't guess
result = tool.call(args, idempotency_key=step.intent_key)
ledger.commit(step.intent_key, result)
return resultGet the granularity right. Too coarse (`customer_notified|customer=Y`) and you'll swallow a notification that really was new. Too fine (anything containing model text) and it never matches. Include the version of the thing the intent is about, like the invoice revision or the alert occurrence, and stop there.
The part keys can't fix: one-shot effects
Some effects can't be pulled back. Someone has already read the Slack message. The PR comment has already kicked off CI. The email is sitting in the customer's inbox. If your ledger write fails after the send, or the downstream system ignores your key, a perfect intent key won't save you. So sort your tools into two groups:
- ▹Replayable tools: call them twice with the same intent and the world ends up in the same state. Upserts, setting a status, adding a label, PUT-style updates, and APIs that honor an idempotency key (Stripe charges with a stable key, for instance). The agent can call these directly.
- ▹One-shot tools: a second call is a visible duplicate, possibly with consequences. Chat messages, emails, SMS, PR comments that trigger automation, payment APIs without idempotency support, webhooks to third parties. Keep the agent's hands off these.
For one-shot tools, the agent records the intent and something deterministic does the sending. You've got three options, and they combine well:
- ▹Transactional outbox. The agent writes the ledger row and an outbox message in one database transaction. A relay process sends each message once, deduping on the intent key. The model is out of the delivery path entirely.
- ▹Delayed-send window. Messages wait a short while before going out, so duplicates for the same intent key get collapsed, and a human or policy check can cancel. Gmail's undo-send relies on the same kind of short hold, though it doesn't merge duplicates.
- ▹Human approval. For high-stakes or external effects (refunds, customer messages, anything legal), the agent drafts and a person approves. The approval becomes the one-shot event, and it dedupes itself.
tools:
jira.ensure_ticket: { effect_class: replayable, intent: jira.ticket_exists }
jira.set_status: { effect_class: replayable, intent: jira.ticket_in_state }
slack.post_message: { effect_class: one_shot, route: outbox, window: 120s }
email.send_customer: { effect_class: one_shot, route: human_approval }
github.comment_pr: { effect_class: one_shot, route: outbox }One caveat: the outbox relay is still at-least-once between itself and the downstream system. What you've bought is that a duplicate can now only come from a small, deterministic, testable piece of code, not from a model sampling text.
Prefer convergent tools
The cheapest way to make an action retry-safe is to stop offering the model "do X" and offer "make sure X is true" instead. `create_ticket(title, body)` becomes `ensure_ticket(bug_ref, project, state)`. That tool looks for an issue linked to `bug_ref`, creates one if there isn't any, and moves it to `state` if it's somewhere else. Call it three times, get one ticket in the right state. Lookup-then-create can still race, so back it with the intent ledger's unique constraint or a unique external link on the issue.
Convergent tools pay off again once several agents can touch the same thing. When two agents both think they own an outcome, "ensure" semantics are where coordination starts. A later day in the series goes there. Today's goal is smaller: wherever you can, give the model tools whose worst case is a no-op.
When you don't need all this
- ▹Read-only tools. No side effect, nothing to dedupe.
- ▹Tools that already converge, like setting a field to a value. The step ID is enough; skip the intent ledger.
- ▹Prototypes where a human reviews every action. Approval is already your dedupe. Add the ledger before you take the human out.
Common pitfalls
- ▹Building the intent key from model output: a title, a summary, an amount the model formatted.
- ▹Minting the key inside the tool wrapper. It runs after reasoning, so every retry gets a fresh one.
- ▹Putting the retry loop at the framework level, where it re-invokes the model, and treating that like an HTTP retry.
- ▹Keeping the ledger in agent memory or the context window. The process crashes, the ledger's gone.
- ▹A ledger TTL shorter than a retry or re-plan can realistically take.
- ▹Reading a `pending` row as "not done". Reconcile against the real system first.
Architecture checklist for your system
- ▹1. Where is the key minted: before or after the model reasons about arguments?
- ▹2. Does every tool call carry the plan-step ID, injected by the orchestrator and never generated by the model?
- ▹3. Which of your tools are one-shot, and what guards each one: outbox, delay window or human?
- ▹4. Can recovery replay the persisted plan, or does it ask the model again from scratch?
- ▹5. Which of your 'create' tools could become 'ensure' tools?
Where this fits in the series
So far the series has treated reliability as a property of the network and the database: timeouts, retries, transactions, delivery guarantees. Put agents in the system and reliability also depends on how they plan. You need to know where intent gets recorded, when identity gets assigned, and which actions are allowed to bypass the model. Tomorrow's lesson builds on exactly that.
Extend your knowledge
- ▹Read Stripe's engineering blog post "Designing robust and predictable APIs with idempotency" and Brandur Leach's "Implementing Stripe-like Idempotency Keys in Postgres". Then mark exactly where an agent's regenerated payload breaks each assumption.
- ▹Read up on the Transactional Outbox pattern on Chris Richardson's microservices.io, then sketch an outbox table that dedupes on intent_key.
- ▹Go through Temporal's docs on durable execution and activity idempotency. Notice how workflow state keeps step identity across crashes. It's the same idea as minting keys at the plan step.
- ▹Exercise: list every tool your current agent can call, tag each one replayable or one-shot, and turn at least one 'create' tool into an 'ensure' tool.
Discussion
Chat with Chi Cong (AI) about this article. Your conversation is private to you — you can publish a summary for others when you're done.