brainstorming
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or planning anything new, in software or out of it (a talk, a business, a renovation).
agentsop-llm-tool-idempotencyDecision 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 call is a no-op instead of a double-send. The load-bearing premise: the LM cannot promise it'll call exactly once; the tool must promise the second call is safe. Framework-agnosti…
Independent GitHub snapshot · Automated classification · Original documentation governs
name: agentsop-llm-tool-idempotency version: 0.1.0 description: >- 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 call is a no-op instead of a double-send. The load-bearing premise: the LM cannot promise 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 retries, CrewAI delegated tool invocations, and direct HTTP wrappers. Search keywords: duplicate email sent, charged twice, exactly-once, idempotency key, tool called twice, retry side effect, double-send, at-least-once delivery. domain: coder-agent / tool-execution-safety audience: engineers wiring LLM agents whose tools have side effects (email, payment, DB writes, message posting, file creation, API calls) trigger_keywords:
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_.interrupt(...) call AND a
side effect in the same function body — the resume re-runs the body from the
top [langgraph/gotchas].(tool_name, args_hash) invoked more
than 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:
[stripe/idempotency].[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.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:
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:
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:
uuid4() call.Idempotency-Key header and propagates it on
client-side retries (don't generate a new key per HTTP retry).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:
was_replay: true flag for observability.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:
status: pending
flag, then updated to status: completed after. On retry, pending means
"wait or fail" — not "go ahead and call again".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.
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)).[stripe/idempotency] "Stripe's idempotency works by saving
the resulting status code and body of the first request made for any given
idempotency key, regardless of whether it succeeded or failed."tool_call_dedup(key TEXT PRIMARY KEY, 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.[stripe/idempotency]; appears in PayPal, AWS SDK
whitepapers as the standard "dedup table" pattern
[aws/idempotency-whitepaper].key = sha256(canonical_content); write to
PUT /bucket/{key}. S3 conditional put (If-None-Match: *, available
since 2024) returns 412 on second write.[aws/s3-conditional] S3 conditional writes documentation;
this is the underlying primitive in Git, IPFS, content-addressable storage
generally.INSERT … ON CONFLICT … DO NOTHING(idempotency_key) or (user_id, intent, request_id). Use
INSERT … ON CONFLICT(idempotency_key) DO NOTHING RETURNING id.ON CONFLICT semantics [pg/insert]; MySQL
INSERT IGNORE; SQLite INSERT OR IGNORE. The database does the dedup;
no application race window.status='pending', key=<id> in a
transaction.status='completed', external_id=<their_id>.external_id, do not call again.pending past TTL: investigate; possibly
compensate the now-known external_id.[microservices/saga]; Temporal workflow
[temporal/idempotency] uses the same shape under the hood.interrupt()interrupt() and performs a side
effect.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}
[langgraph/gotchas] "Side effects after interrupt() will
re-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.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).(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.[temporal/idempotency].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.send_email into a
downstream 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)INSERT INTO email_dedup(key, status) VALUES($1, 'sending') ON CONFLICT(key) DO NOTHING RETURNING keystatus='sent', message_id=<sg_id>.'sending' past a 5-minute
TTL, the reconciler queries SendGrid's API by sender+recipient+time
window and reconciles.(user_turn_id, intended_recipient, intended_subject)
— not from the message content. Two turns → two intents → two keys
→ two sends.send_email(...) twice (model
dup-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_email
within 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.create_record(name='Alice'). The HTTP client times
out. 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.(thread, tool). Either:
propose to call 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.name) and check whether
the record was created. If yes, decide: update vs. delete-and-recreate.(intent + args_hash), treat
same-key-different-args as a bug to surface, not a duplicate to absorb.send_slack_message(channel, text) to an
agent. The MCP client uses a transport with retry-on-timeout. Slack
messages duplicate.chat.postMessage does not have native idempotency (it
accepts a client_msg_id but doesn't enforce uniqueness server-side).{
"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.idempotency_key a required schema field
on every side-effectful MCP tool. Document the dedup window in the
description.[langgraph/gotchas].uuid4() call
on 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 operation
retried 30 seconds later must produce the same key. Time-based keys
guarantee duplicates.ON CONFLICT (OP-4) so the dedup is atomic.was_replay: true in the tool result so the LM (and observability) knows
the second call was absorbed.interrupt() in a LangGraph
node. The interrupt-resume pattern re-runs the body from the top
[langgraph/gotchas]. Move side effects to a downstream node OR use
OP-6.| 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 |
[langgraph/gotchas]. Idempotency key MUST be drawn from
checkpointed state, not generated inside the node.[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.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 |
INSERT … ON CONFLICT)
for single-row; OP-2 (dedup table + transaction) for multi-step.idempotency_key into the input
schema for any side-effectful tool. The protocol won't enforce it; your
server must.in_progress rows as
"do not retry without reconciliation".[langgraph/gotchas]; Temporal docs likewise warn about
non-determinism in activities [temporal/idempotency]. Idempotency is
the orthogonal axis.was_replay: true, the
model can't reason about its own action history; it'll loop or "fix"
non-bugs.Short tags used inline → full sources in references/:
[stripe/idempotency] = https://docs.stripe.com/api/idempotent_requests[aws/idempotency-whitepaper] = AWS Architecture Blog: "Building idempotent
APIs" (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 on
resume; mirror in output/langgraph-sop-skill/SKILL.md Case 4.[crewai/tools] = https://docs.crewai.com/en/concepts/tools — per-agent
tool binding pattern.