agentsope/agentsop-llm-tool-idempotency
>- Decision protocol for making side-effectful agent tools idempotent — so when an LLM tool call is retried (timeout, framework resume, user re-run, model duplicate emit), the second it'll call exactly once; the tool must promise the second call is safe. Framework-agnostic — applies to LangGraph node bodies that re-run on resume, MCP tools, OpenAI tool-calling duplicate email sent, charged twice, exactly-once, idempotency key, tool called twice, retry side effect, double-send, at-least-once delivery.
npx skills add https://github.com/agentsope/SkillAlchemy --skill agentsop-llm-tool-idempotency
> One-liner: The LM is at-least-once; the tool must be at-most-once.
> Every framework that promises "durable execution" still re-runs node bodies
> on resume. Every HTTP client retries on timeout. Every model occasionally
> emits the same tool_call twice. Idempotency belongs in the tool, not in a
> wish.
Activate this skill when any of the following triggers fire:
send_, create_, charge_,post_, write_, publish_, transfer_, delete_, update_, or
notify_.
Discord webhook, S3 PUT, payment gateway, internal write API).
interrupt(...) call AND aside effect in the same function body — the resume re-runs the body from the
top [langgraph/gotchas].
CrewAI delegation, or any layer where a transport timeout could be
interpreted as "retry" even though the operation succeeded server-side.
"duplicate row" / "got two emails".
(tool_name, args_hash) invoked morethan once within a single user turn.
Do not activate when the tool is read-only (GET-equivalent), or when the
side effect is genuinely commutative AND verified safe under duplication.
LM tool-call semantics Tool side-effect semantics
───────────────────── ─────────────────────────
At-least-once delivery Must be at-most-once
(network retry, framework (one charge, one email,
resume, model dup-emit, one record)
user re-prompt)
│ │
└────── gap to bridge ───────────┘
↓
IDEMPOTENCY KEY
(a stable identifier the LM
commits to BEFORE the call,
which the tool dedupes on)
The LM cannot promise it'll call exactly once. Four independent retry
sources stack here:
server actually completed the operation, the client retries. Stripe's
docs call this out as the canonical case [stripe/idempotency].
from an interrupt. Code before the interrupt re-executes on every resume
[langgraph/gotchas]. Same applies to Temporal-style workflows on replay.
tool_call_id twice (rare but documented in OpenAI tool-calling); model
may re-emit on context-compaction round-trips.
after timeout, with the same instructions.
Any one of these turns a single-intent action into multiple side effects
unless the tool itself dedupes.
Naive design says: "I'll make the LM call the tool exactly once."
Mature design says: "I'll make the tool ignore the second call."
The inversion matters because the LM is in the *control* path; the tool is in
the *execution* path. Execution-path guarantees are the only ones that hold
under failure.
A common bug: the tool generates a UUID *inside* itself, then dedupes on that
UUID. This breaks because retry creates a *new* UUID. The key has to:
key on retry.
up the same key.
Stripe's pattern (the industry reference): client generates an idempotency key
(typically UUIDv4), sends it as Idempotency-Key: <uuid> HTTP header. Server
caches the response for 24 hours keyed by that header [stripe/idempotency].
A retry with the same key returns the cached response — no second charge.
| Side-effect class | Examples | Idempotency mechanism |
|---|---|---|
| Non-replayable external API | Stripe charge, Twilio SMS, SendGrid email | Caller-supplied idempotency key passed to API (header or body field) |
| Internal write to a store you own | INSERT into your DB, PUT to your S3 bucket, append to your queue | Dedup table keyed on (operation_id, args_hash) with unique constraint, OR INSERT … ON CONFLICT DO NOTHING, OR content-addressed write |
| Compensatable operation (no native idempotency, must undo on duplicate) | Bank wire to a counterparty, physical device actuation | Saga pattern: record intent, perform, compensate-on-duplicate-detection; or a reservation token (two-phase) |
The SOP in §3 walks you through classifying first, then picking the mechanism.
Ask three questions, in order:
Anthropic Files API): use the native key. Stop here.
Go to Step 2.
block the second call — fail-closed dedup table, no fallback.
Decision tree:
Side effect classified above.
│
├─ Native idempotency-key API (Stripe et al.)
│ → OP-1: pass key in HTTP header / SDK kwarg
│
├─ Internal write you own
│ ├─ Operation has natural content identity (file hash, message dedup id)
│ │ → OP-3: content-addressed write (PUT key = sha256(content))
│ ├─ Single-row insert
│ │ → OP-4: INSERT ... ON CONFLICT (idempotency_key) DO NOTHING RETURNING *
│ └─ Multi-step write
│ → OP-2: dedup table + transaction at the tool boundary
│
└─ Compensatable, non-idempotent external call
→ OP-5: saga (record intent → call → compensate on duplicate)
| Source of key | When to use | Pitfall |
|---|---|---|
| uuid4() in agent state, persisted before call | Default for one-shot operations | Lost if state isn't persisted before the side effect |
| hash(user_id, intent, day) | Idempotency per-user-per-intent-per-day (e.g., daily digest email) | Too coarse → blocks legitimate second sends |
| hash(canonicalized_args) | Content-determined (same email body to same address) | Blocks legitimate "send the same message again later" |
| hash(thread_id, node_id, run_id) | LangGraph node-level dedup | Doesn't help across thread resumes that re-enter the same node |
| Composite: hash(thread_id, node_id, attempt_args) | Recommended for LangGraph node bodies that re-run on resume | One more arg to wire |
Naming convention: store the key in agent state as
{tool_name}_idempotency_key, persist it before the tool call. On
resume, check if state already has a key — if yes, reuse; if no, generate.
Agent state ──→ Tool wrapper ──→ HTTP client ──→ External API
(uuid) (header) (transport) (server)
Verify each layer:
only).
uuid4() call.Idempotency-Key header *and* propagates it onclient-side retries (don't generate a new key per HTTP retry).
(Stripe: 24 h; AWS SQS dedup: 5 min default; design your own to ≥ 1 h).
A successful idempotent retry returns the original response. A *conflict*
(same key, different args) usually returns 4xx — Stripe returns
HTTP 409 Conflict with code idempotency_key_in_use if the request payload
mismatches [stripe/idempotency]. Decision rules:
surface a was_replay: true flag for observability.
fall back to "just send again". This catches "agent re-prompted with new
message content but reused old key" bugs.
(server-side wait) or return 409 / 425 Too Early. Caller must back off.
The classic race: tool calls API, API succeeds, tool crashes before recording
the dedup row. Retry now duplicates because the dedup row is missing.
Two safe patterns:
call in the same transaction; commit. If external API is the side effect,
the dedup row goes in *before* the external call, with a status: pending
flag, then updated to status: completed after. On retry, pending means
"wait or fail" — not "go ahead and call again".
your own dedup table entirely; trust Stripe's. Recommended when available.
was_replay in the tool resultThe LM should know it didn't actually re-send. Return:
{
"result": { /* original response payload */ },
"was_replay": true,
"original_called_at": "2026-05-19T10:11:12Z"
}
Why: prevents the agent from thinking "send failed, let me try a different
phrasing" and producing a logical (not technical) duplicate.
Format: Trigger → Action → Output → Evidence.
(Stripe, Square, modern PayPal, Adyen, Anthropic Files, AWS with client
tokens).
key = state.get("charge_key") or uuid4(); persist to state; pass as
Idempotency-Key: <key> header (or SDK kwarg, e.g.
stripe.PaymentIntent.create(..., idempotency_key=key)).
response. TTL ~24 h for Stripe.
[stripe/idempotency] "Stripe's idempotency works by savingthe resulting status code and body of the first request made for any given
idempotency key, regardless of whether it succeeded or failed."
or aggregation that doesn't fit content-addressing.
tool_name TEXT, args_hash TEXT, result JSONB, status TEXT,
created_at TIMESTAMPTZ). On call: INSERT … ON CONFLICT(key) DO NOTHING
RETURNING …`. If insert succeeds, perform side effect, then
UPDATE … SET result=…, status='completed'. If conflict, read row, return
its result.
implementation [stripe/idempotency]; appears in PayPal, AWS SDK
whitepapers as the standard "dedup table" pattern
[aws/idempotency-whitepaper].
content (build artifact, immutable record, derived data).
key = sha256(canonical_content); write toPUT /bucket/{key}. S3 conditional put (If-None-Match: *, available
since 2024) returns 412 on second write.
table needed.
[aws/s3-conditional] S3 conditional writes documentation;this is the underlying primitive in Git, IPFS, content-addressable storage
generally.
INSERT … ON CONFLICT … DO NOTHING(audit log, message record, user-generated record).
(idempotency_key) or (user_id, intent, request_id). Use
INSERT … ON CONFLICT(idempotency_key) DO NOTHING RETURNING id.
"first call, inserted".
ON CONFLICT semantics [pg/insert]; MySQLINSERT IGNORE; SQLite INSERT OR IGNORE. The database does the dedup;
no application race window.
can be undone (refund, retraction, correction email).
status='pending', key=<id> in atransaction.
status='completed', external_id=<their_id>.stored external_id, do not call again.
not call again.
pending past TTL: investigate; possiblycompensate the now-known external_id.
[microservices/saga]; Temporal workflow[temporal/idempotency] uses the same shape under the hood.
interrupt()interrupt() and performs a sideeffect.
def send_email_node(state):
# Step 1: derive a stable key BEFORE interrupt
key = state.get("email_key") or str(uuid4())
state["email_key"] = key # persist via reducer
decision = interrupt({"to": state["to"], "body": state["body"]})
if decision != "approve":
return {"status": "rejected"}
# Step 2: side effect uses the key — safe under resume
result = send_email_api(to=state["to"], body=state["body"],
idempotency_key=key)
return {"email_sent": True, "email_key": key, "result": result}
external send is a no-op on the second pass.
[langgraph/gotchas] "Side effects after interrupt() willre-run on resume — wrap them with an idempotency key drawn from state."
See LangGraph payment-double-charge Case Study 4 in
output/langgraph-sop-skill/SKILL.md.
retry on transport timeout.
idempotency_key argument as part of the tool schema; require it for
side-effectful tools. Maintain a server-side dedup window (e.g., 5-minute
in-memory LRU + persistent backing for cross-restart safety).
cache; only one execution.
*server* responsibility — analogous to Stripe's server-side dedup. Surface
the requirement explicitly in the tool's input schema.
(plain Python loop, CrewAI task, custom orchestrator).
(intent_id, args_hash, key, status='in_progress') to a local sqlite or
durable store. After call: update to status='completed' with result.
Wrap in a context manager so a crash leaves in_progress, and a retry
reads that state and either polls or fails-closed.
to Temporal's activity-level idempotency [temporal/idempotency].
→ propose response → interrupt() for human review → if approved, call
send_email(...) in the same node. They notice that after a resume, the
customer sometimes gets two emails. The cheatsheet warns that on resume
"the entire node function re-runs from the top" [langgraph/gotchas], so
the proposal-classification *and* the email-send both re-execute.
keys on the v3 mail send endpoint.
send_email into adownstream node that runs *only after* the interrupt-bearing node
returns approval into state. (LangGraph SOP "side effects after
interrupt" rule.)
email_key = uuid4() *before* the interrupt, persist to state. The
send-tool checks a local Postgres email_dedup table keyed on
email_key. (OP-4)
ON CONFLICT(key) DO NOTHING RETURNING key`
fetch row, return its cached result.
status='sent', message_id=<sg_id>.
'sending' past a 5-minuteTTL, the reconciler queries SendGrid's API by sender+recipient+time
window and reconciles.
alone reduces the rate; the dedup table closes the residual race.
AND tool-layer dedup. Either alone leaks.**
"Send Alice the meeting summary." The first call succeeds. The second call
comes 20 seconds later. Should the tool block (idempotency!) or allow
(legitimate re-send!)?
should derive from (user_turn_id, intended_recipient, intended_subject)
— not from the message content. Two *turns* → two intents → two keys
→ two sends.
send_email(...) twice (modeldup-emission), the user_turn_id is the same → key collision → second
call is a no-op. This catches the failure we care about.
user_turn_id is new → new key → second send happens.
send_emailwithin 60 seconds of a successful previous call to the same recipient
with similar content, the *agent* prompt should ask the user "I sent
this 20 seconds ago — re-send?" — a UX rail, not a technical one.
(model/framework duplication). UX rail catches the human-intent bug class.
They're separate; both needed.
"you just did this" prompt for cross-turn near-duplicates.**
create_record(name='Alice'). The HTTP client timesout. The agent (different turn, longer context) retries:
create_record(name='Alice Smith'). It reuses the same idempotency key
from state because the key is keyed on (thread_id, tool_name). Stripe-
style: returns 409 idempotency_key_in_use. Agent sees an error.
before getting a response).
(thread, tool). Either:
tool event), not per (thread, tool)` pair.
idempotency_key_in_use is a *programmer bug*, not a recoverable
state. Surface to the operator with full diagnostics; do not let the
agent retry with yet another args change.
business identifier (e.g., natural unique on name) and check whether
the record was created. If yes, decide: update vs. delete-and-recreate.
bug, not a silent dedup.
(intent + args_hash), treatsame-key-different-args as a bug to surface, not a duplicate to absorb.**
send_slack_message(channel, text) to anagent. The MCP client uses a transport with retry-on-timeout. Slack
messages duplicate.
chat.postMessage does not have native idempotency (itaccepts a client_msg_id but doesn't enforce uniqueness server-side).
treats it as request/response; retries are the client's call.
schema:
{
"name": "send_slack_message",
"input_schema": {
"properties": {
"channel": {"type": "string"},
"text": {"type": "string"},
"idempotency_key": {"type": "string",
"description": "Unique per send-intent. Server dedupes within
5 min. Reuse the same key to retry safely."}
},
"required": ["channel", "text", "idempotency_key"]
}
}
idempotency_key → previous_response. Retry within window → cached
response. Outside window → new send.
before call. (OP-8 if the agent has no first-class durable execution.)
tool schema layer.
idempotency_key a required schema fieldon every side-effectful MCP tool. Document the dedup window in the
description.**
framework resume, HTTP client retry, user re-prompt — at least one will
fire in production. The cheatsheet warning is unambiguous: every node body
re-runs on resume [langgraph/gotchas].
uuid4() callon each tool invocation defeats the entire mechanism — the retry generates
a *new* UUID and the dedup table sees no collision.
now() or any time-varying value. Same logical operationretried 30 seconds later must produce the same key. Time-based keys
guarantee duplicates.
"check then insert" pattern has a race window. Use database-enforced
unique constraint + ON CONFLICT (OP-4) so the dedup is atomic.
ask "send Alice the same message" tomorrow — the args are identical but
the intent is new. Key on a turn-level or run-level identifier.
was_replay: true in the tool result so the LM (and observability) knows
the second call was absorbed.
Persist to the same durable store as your agent state (checkpointer,
workflow log, sqlite).
interrupt() in a LangGraphnode.** The interrupt-resume pattern re-runs the body from the top
[langgraph/gotchas]. Move side effects to a downstream node OR use
OP-6.
timeout) could mean either "didn't happen" or "happened but you can't
read the response". Idempotency keys turn this from an error case to a
retry case.
call has the same network properties as Stripe's — if it has side effects,
it needs an idempotency key.
| Scenario | Why a key isn't enough | What to do |
|---|---|---|
| Physical actuation (open valve, fire missile) | No way to "undo"; second activation has new physical effect | Two-phase commit; explicit human confirm; never auto-retry |
| Cash wire to a third-party bank | Some networks don't have idempotency; some have but with 24 h windows | Saga (OP-5) + reconciliation job + manual review |
| Cross-service distributed transaction | Each service has its own dedup window | Choreographed saga with compensation; not a single key |
| External service ignores idempotency key | They claim to support it but don't dedup | Wrap in your own OP-2 table; never trust unverified claims |
| Streaming side effect (Kafka producer) | Producer at-least-once by default | Use Kafka's idempotent producer + transactional writes; not just a key |
every resume [langgraph/gotchas]. Idempotency key MUST be drawn from
*checkpointed state*, not generated inside the node.
different agents in a single crew run. Per-agent tool binding + dedup at
the tool layer prevents one agent from undoing another's work
[crewai/tools].
tool_call_id twice during compaction; treat tool_call_id as a useful
*signal* but not as the idempotency key itself — the model can synthesise
new ids on retry.
the tool schema (OP-7).
tool_use_id is per-emission, not per-intent.
| | Stripe API | AWS SQS (FIFO) | Temporal | LangGraph | MCP | Plain HTTP/Python |
|---|---|---|---|---|---|---|
| Idempotency unit | HTTP request | Message | Workflow activity | Node body | Tool call | Caller-managed |
| Key carrier | Idempotency-Key header | MessageDeduplicationId | Activity ID + input hash | State field | Tool input arg | Custom |
| Dedup window | 24 hours | 5 minutes (default) | Until workflow completes | Until checkpoint expires | Implementation-defined | None by default |
| Storage | Stripe-side | SQS-side | Temporal cluster | Checkpointer (Postgres/SQLite/Redis) | Your server | Your code |
| What replays on retry | Cached response returned | Message acknowledged silently | Activity skipped, result returned | Node body re-runs! | Depends on server | Whatever you wrote |
| Where bugs typically hit | Caller reuses key on different args (409) | Window expires under load | Non-deterministic activity code | Side effect inside re-running body | Missing key arg | All of the above |
(OP-1). Don't reinvent — Stripe's 24-hour window and 409-on-mismatch
semantics are battle-tested.
INSERT … ON CONFLICT)for single-row; OP-2 (dedup table + transaction) for multi-step.
(content-addressed). It is idempotency-by-construction; no key needed.
resume) is non-negotiable. Skipping it is the #1 source of duplicate-charge
bug reports in LangGraph production.
idempotency_key into the inputschema for any side-effectful tool. The protocol won't enforce it; your
server must.
a pre-call checkpoint to sqlite; treat in_progress rows as
"do not retry without reconciliation".
transport layer you traverse is at-least-once. The only way out is a
per-operation key + dedup at the execution boundary.
in the caller's persistent state. The retry must produce the *same* key.
resume.** LangGraph's official cheatsheet says it explicitly
[langgraph/gotchas]; Temporal docs likewise warn about
non-determinism in activities [temporal/idempotency]. Idempotency is
the orthogonal axis.
was_replay: true, themodel can't reason about its own action history; it'll loop or "fix"
non-bugs.
absorb.** Treat it loudly. The bug is almost always "key isn't specific
enough" — fix the key generation, don't suppress the error.
Short tags used inline → full sources in references/:
[stripe/idempotency] = https://docs.stripe.com/api/idempotent_requests[aws/idempotency-whitepaper] = AWS Architecture Blog: "Building idempotentAPIs" (2023) and Powertools-Lambda idempotency module docs.
[aws/s3-conditional] = https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html[pg/insert] = https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT[temporal/idempotency] = https://docs.temporal.io/develop/activities#idempotency[microservices/saga] = https://microservices.io/patterns/data/saga.html[langgraph/gotchas] = LangGraph cheatsheet / FAQs — side-effects onresume; mirror in output/langgraph-sop-skill/SKILL.md Case 4.
[crewai/tools] = https://docs.crewai.com/en/concepts/tools — per-agenttool binding pattern.
Take agentsope/agentsop-llm-tool-idempotency from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.