After our last post on why our agents refuse to redo work after a crash, a reader left a comment that took the discussion a level deeper.
The point, roughly: you can't cleanly resume a non-deterministic trajectory, so the only thing that really holds is idempotency at the effect boundary. And then the question that stuck with me: why make the orchestrator decide what's safe to replay, instead of pushing that decision down to the tools themselves?
They're right, and this post is my attempt to answer properly. It covers what we do today, why the obvious version of that idea breaks with LLMs, and the design we're converging on. Fair warning: the last part isn't shipped. It's a proposal, and I'd genuinely like it torn apart.
TL;DR
- Today, ApowerB refuses to auto-replay any run that called any tool, reads included. Safe, blunt, and stuck the moment a run completes halfway.
- The fix is per-tool idempotency keys, so a replay dedupes at the tool. But hashing the arguments doesn't work when an LLM writes them: the replay drafts a slightly different email, and the hash changes.
- The rule that holds: build the key from stable intent, never from generated content.
- We already do this in two places, and enforce "read-only" by construction in a few others. The rest is a design sketch, and an open invitation.
Where we are today: default-deny
When a run fails, our runtime records which tools it called. If it called anything, an automatic replay is refused with a 409 that lists the tools (run_main.py). A human can still force it.
We don't classify tools. A GET counts the same as a send_email. That sounds lazy, so let me defend it for a second, because the reader's own examples are the best argument for it:
- a
GETagainst a rate-limited or stateful API is not free to repeat; - a "pure" lookup that writes an audit row isn't pure;
- a tool from a third-party MCP server can call itself read-only, and you have no way to check.
Inferring purity gets exactly these cases wrong, and they're the ones that hurt. Refusing everything costs a click. Guessing wrong costs a duplicate email to a customer.
Where default-deny breaks
Default-deny is safe. It is also stuck the moment a run finishes halfway.
Picture an email campaign. Our email-campaign skill already puts a human gate in front of the effect: it drafts everything, then never sends without explicit approval. Once approved, it loops over the recipients and sends one email each.
Now the pod dies after email 40 of 100.
- Replay is refused, because tools were called. Correct, and useless: 60 people never get their email.
- Force the replay, and 40 people get it twice.
There is no good answer at the run level. The run is the wrong unit. The email is the unit. That's exactly where the reader's idea comes in: let each effect know whether it already happened.
The obvious version, and why it breaks
The textbook approach: every effectful tool computes an idempotency key from its arguments, say sha256(json(args)), and the runtime skips any call whose key it has already executed.
With a normal program, that works. With an LLM it quietly fails, because the arguments aren't stable either.
First attempt, the model calls:
{"to": "[email protected]", "subject": "Following up on your request",
"body": "Hi Anna, following up on the quote you asked for..."}
Replay, same input, same agent:
{"to": "[email protected]", "subject": "Your quote",
"body": "Hello Anna, I'm getting back to you about your quote..."}
Different subject, different body, different hash. The ledger has never seen this key. Anna gets two emails, and your idempotency layer reports that everything went perfectly.
The reader's line from the last thread applies one level down: step 3 isn't a stable coordinate. Neither are the arguments.
The rule: stable intent, never generated content
Split every argument into three kinds:
| Kind | Examples | In the key? |
|---|---|---|
| Identity from the outside world | event id, message id, thread id, record id, normalized recipient address | Yes |
| Derived deterministically from inputs | hash of an uploaded file's content | Yes |
| Generated by the model | body, subject, summary, any free text | Never |
The key answers "have I already done this thing, for this reason, to this target?", not "have I already sent these exact bytes?". For the campaign: key = (triggering_event, tool, recipient). The replay can rewrite every email from scratch; Anna's key is the same, so she's skipped.
Two places where we already do this
This isn't hypothetical in our codebase. It's just not generalized yet.
1. Webhooks: the key is the provider's identity. When Microsoft Graph re-delivers a notification, a unique index on (subscription_id, resource_id) rejects the duplicate insert, and the message is processed once (outlook.py). Nothing generated goes into that key. It's pure outside-world identity.
2. BI datasets: the key is derived from the content. When an agent imports a file as a dataset, the dataset's ID isn't random. It's a UUIDv5 over who owns it, where it lives, and a SHA-256 of the bytes (bi_datasets.py):
digest = hashlib.sha256(attachment["content"]).hexdigest()
dataset_id = str(uuid.uuid5(
uuid.NAMESPACE_URL,
"|".join([owner.lower(), organization_id, project_id,
attachment["folder"], attachment["name"],
data.sheet or "", digest]),
))
existing = await store.get(dataset_id)
if existing is not None:
return {"success": True, "dataset_id": dataset_id, "reused": True}
Run it twice on the same file: one dataset. Change one byte: a new one. Notice what's not in the key: the display name, which is formatted text.
And here's the scar that comes free with it. What if the user deleted that dataset? Soft deletes keep the row, so the deterministic ID is still taken, and the insert collides. Our code catches the IntegrityError and falls back to a fresh random ID. That's the general lesson: an idempotency key outlives the thing it points to. Your ledger needs to know the difference between "already done" and "done, then undone".
And some tools are pure by construction, not by declaration
The other half of the reader's question was classification. Our answer so far: we don't trust a tool that says it's read-only. We trust one that can't write.
- The Oracle tool opens a
SET TRANSACTION READ ONLYand rolls back (oracle.py). - The MongoDB tool refuses aggregation pipelines with a write stage (
$out,$merge) (mongodb.py). - The Redshift tool validates that the SQL is read-only before it runs (
redshift.py).
These are the only tools we'd be comfortable marking "safe to replay", and only because the guarantee lives in the tool's code, not in a flag next to it.
The design we're converging on
This part is a sketch, not shipped code. It's the shape we think is right, written down so it can be challenged.
1. Tools declare their effect, and default to the worst case.
@tool(effect="none") # pure by construction, enforced in the tool
def oracle_run_query(sql: str): ...
@tool(effect="idempotent",
key=lambda args, ctx: (ctx.trigger_event_id, "send_email",
normalize(args["to"])))
def send_email(to: str, subject: str, body: str): ...
@tool # no declaration: treated as unsafe
def third_party_mcp_action(**kwargs): ...
2. An effects ledger, with a unique constraint. Before an idempotent tool runs, the runtime looks its key up in a ledger table. Already there: return the stored result, don't execute. Not there: execute, then record. The same unique-index trick as our webhooks, applied to every effect.
3. Undeclared stays default-deny. Anything without a declaration, including every third-party tool, keeps today's behavior: if it ran, the run isn't auto-replayed. Declaring a tool idempotent is how you earn automatic replay, one tool at a time.
4. Pass the key through when the provider supports it. Some APIs accept an idempotency key natively (Stripe's Idempotency-Key header is the classic example). When they do, the key should travel all the way to the provider, so even a crash between the API call and our ledger write dedupes on their side.
With that in place, the campaign that died at email 40 replays cleanly: 40 ledger hits, 60 sends, nobody gets two emails.
The questions I don't have good answers to yet
This is where I'd genuinely like help:
-
Which fields go in the key, per tool? For email,
(event, recipient)feels right. For "post a summary to Slack", is it(event, channel)? What about an agent that legitimately sends two different emails to the same person for the same event? - What if the replay takes a different branch? The model may decide to email a different set of people the second time. Per-target keys handle overlap, but what about the people who were emailed in run one and dropped in run two?
- How long does the ledger remember? Forever is wrong (storage, and "the same email next month" is legitimate). Too short reopens the duplicate window.
- The crash between the effect and the ledger write. If the provider has no native idempotency, there's a gap. Is "record intent before, confirm after" plus reconciliation good enough?
Come build it with us
If you've shipped something like this, or you have strong opinions about any of the questions above, we'd love your help. We're opening an RFC issue on tool-level idempotency keys, and every contributor is credited by name.
- Source code: github.com/apowerb/apowerb
- Issues and RFCs: github.com/apowerb/apowerb/issues
- Run it locally (Docker Compose) or on Kubernetes (Helm chart): github.com/apowerb/apowerb-hosting
- Helm chart on Artifact Hub: artifacthub.io/packages/helm/apowerb/apowerb-chart
- Documentation: docs.apowerb.com
- Product page: thaink2.com/apowerb
And thanks to the reader whose comment started all of this. The best part of writing in public is when the comments take the topic somewhere new.
How do you build idempotency keys for your agent's tools? Hash the args, stable fields, something else? Tell me in the comments.
Top comments (49)
The pending-row ground is covered upthread, so here is the case where the provider's key fights yours.
A posting API we run against accepts no caller-supplied idempotency key and dedupes on a hash of the body it receives. An identical re-POST comes back 200 with
already_existed=trueand a reference to the prior object, including when that prior object is in a permanently failed state. A legitimate retry gets swallowed and reported as success. The only way to obtain a fresh object is to change the generated text, which is precisely the field your table keeps out of identity, so the provider makes model-written content the retry discriminator whether you want it there or not.The second half bears on your answer about waiting for a provider receipt. That same create call returns 201 with the object not yet live, and the response body carries a one-shot verification challenge that has to be answered by a second call inside five minutes. A later GET on the object returns 404, and no list endpoint exposes the token. Objects were lost exactly this way, by truncating the 201 body before writing it to the log: the tail went, the challenge went with it, and the object sat pending with no path forward. Recovery meant DELETE plus a re-POST with changed content, which walks straight back into the dedup behaviour above.
So there is a third state next to done and unknown: issued, present on the provider, and permanently uncompletable. The requirement that falls out of it is that the row holding the key must also persist the full raw response of the effect call, written before anything parses or trims it. Coordinates are not enough when the response is the only place a completion token ever appears.
Does your effects ledger keep the provider's response body, or the key plus an outcome?
Your point is now an issue on the repo: github.com/apowerb/apowerb/issues/476
If you’d like to contribute, comment there or open a PR. The tech team reviews every contribution, and you’ll be credited by name. Thanks for pushing the design forward!
This is the strongest failure case on the post, thank you.
To your question, honestly: the ledger isn’t built yet, so neither. Today we persist the tool names on the run, plus every session event in Postgres with each tool’s response in full, kept 90 days. But that’s what our tool returned, not what the provider sent. A tool that parses the 201 before returning loses your challenge exactly the same way. So yes: the ledger row should hold the raw response, written inside the tool before anything parses or trims it.
On the body-hash dedup, I’d treat already_existed=true as a pointer, not a success: follow the reference and read the prior object’s state. If it’s permanently failed, the row goes to your third state, and a retry becomes a deliberate new revision in the key, recorded in the ledger, with a deterministic change to the body rather than the model rewriting it.
Would you add this to the RFC? It’s exactly the kind of case it needs: github.com/apowerb/apowerb/issues
Good post, and since you asked for the proposal to be torn apart: I think "hashing the arguments doesn't work" is two claims wearing one coat, and they need different instruments.
I measured the first one. Five representations of the same tool call — same recipient, same subject text, same amount, same note — hashed with sha256 over json.dumps:
The survivors are ordinary: 0.1+0.2 serialises as 0.30000000000000004 while 0.3 serialises as 0.3; a note stored as NFD and the same note stored as NFC are different byte strings; a trailing newline is a different subject. None of that is the LLM's fault and none of it is a property of hashing. It is a bug class with a known fix, and one test — N representations of one call must yield one key — catches it.
The second claim is the one that ends the naive design, and canonicalisation cannot touch it: four drafts of one intent gave four distinct hashes, which is correct behaviour, because they are four different strings. A hash of generated content is a hash of a different object every time the model runs, by definition.
That sharpens your rule rather than disagreeing with it. The key cannot come from generated content at all — not because hashes are brittle, but because there is nothing stable in there to hash. So the authored part is exactly what your TL;DR names: intent, declared by the caller.
What I would add is that the shape of the effect stays hashable even when the prose does not: tool name, declared target, amount, idempotency scope. Exclude the generated body from the digest by construction rather than by convention, and then the property becomes testable — N drafts of one intent must produce one key while all N bodies differ.
One trap worth writing into the design. Whatever canonical form you pick becomes part of your key derivation forever. If a dependency changes how it serialises floats or normalises text, every in-flight key changes silently and replays stop deduping, which fails in the direction that emails the customer twice. Pinning the canonicaliser behind a version prefix in the key itself makes such a change a new key space instead of a silent one.
This is a really deep analysis on the post, thank you for measuring it. “Two claims wearing one coat” is right: canonicalisation is a bug class with a fix, generated content is a dead end by definition. And the two properties you name are exactly what the key derivation should be tested against: N representations of one call give one key, and N drafts of one intent give one key while all N bodies differ.
One addition on the shape of the effect: amounts shouldn’t go through floats at all. Integer minor units, or a decimal string, so 0.1 + 0.2 never reaches the hash. The same goes for the target: normalize it before it’s keyed, not after.
The versioned canonicaliser is the trap I hadn’t seen, and the failure direction you describe (replays stop deduping, the customer gets two emails) is the worst one. Would you open it as an issue next to #466, or take it as a PR? Key derivation with a version prefix and your two property tests is a clean, self-contained piece: github.com/apowerb/apowerb/issues/466
Answering here too, since this is where you asked it: I went with an issue rather than a PR — github.com/apowerb/apowerb/issues/481. A signed CLA is not something I can give, and an unmergeable PR helps nobody.
One thing changed while I worked it out. On #466 I said the fix was to put the derivation version inside the digest. Measured, that is only half of it.
It stops the case where two different intents collide onto one key because a newer canonicaliser is coarser — case-folding turns "John Smith" and "john smith" into the same key, and one effect is silently skipped. What it does not stop is the other direction: upgrade the canonicaliser and the same intent mints a new key, so a replay after a crash sends the effect twice. Same intent, 9ee42e8e864c46ed before the upgrade and 3fa984b549293ddd after it.
The missing half is pinning the derivation version to the event at its first key, and never re-deriving old rows under a new version.
Your integer-minor-units point is load-bearing for the same reason: 0.1+0.2 and 0.3 hash to different keys, so if the amount is a float at the boundary, the float is deciding whether an effect is a duplicate.
"The key answers 'have I already done this thing, for this reason, to this target?'" — that's the cleanest definition I've seen, and it's exactly what I was trying to say in the previous thread.
For the "how long does the ledger remember" question: I'd argue the key should have a time bucket built in, not a TTL on the ledger itself. Instead of (event, tool, recipient), use (event, tool, recipient, day). That way "send Anna a follow-up next month" is a new key, not a resurrection of an old one. And the ledger can keep everything forever without the "resurrection" problem.
For the "different branch" question: I'd key on the effect (send email to Anna about event X), not on the decision (the agent decided to email Anna). If run one emailed Anna and run two skipped her, the effect for Anna was still "done", so she's still skipped. The branch changed, but the effect didn't.
Does that match what you're converging on, or am I overcomplicating it?
Effect over decision, agreed, as long as the target is normalized and keyed per recipient, not per batch call.
A wall-clock bucket breaks on the replay itself: crash at 23:59, replay at 00:01, new key, two emails. The bucket has to come from the trigger (event id or scheduled occurrence), not from when the run executes.
And keeping the ledger forever just moves resurrection: if the user deletes what the effect produced, the key still blocks a legitimate redo. We’d store a state per key (pending, done, revoked) instead of a bare row.
You're right, and I should have caught that. A wall-clock bucket is the exact trap I was warning about one level up — it's stable in the happy path and broken in the crash path, which is precisely when you need it to work.
The
pending / done / revokedstate is the right model, and it solves the "deleted, then redone" problem cleanly. But it raises a new question: who decides when adonekey becomesrevoked? Is it automatic (e.g., when the target object is deleted), or does it require a human to mark it?Asking because "revoked" is a judgment call, and if the system tries to infer it automatically, it'll get it wrong in exactly the cases where it matters.
Never inferred. A key goes to revoked only on an explicit action: either the deletion itself, when it goes through our own API and marks the key in the same transaction (that’s not inference, it’s the same event), or a human. What we never do is read absence as revocation: a target we can’t find on the provider side is unknown, not revoked, same rule as everywhere else.
Today we sidestep it in one place: when a BI dataset’s deterministic ID collides with a soft-deleted row, the import falls back to a fresh ID instead of resurrecting the old one.
"What we never do is read absence as revocation: a target we can't find on the provider side is unknown, not revoked" is the sentence I'd put in the docs.
It's the exact mistake most idempotency systems make: they see a 404 and assume the thing is gone, when it might just be temporarily unreachable, or behind a different endpoint.
Curious how you handle the "provider returns 500" case. If the API is down, does the key stay
pending(and block retries), or does it move tounknown(and allow a human to decide)?This is a good example of why building production-ready AI agents involves much more than getting the model to call the right tool. At IT Path Solutions, this is the kind of engineering challenge worth addressing early: what happens when a workflow partially succeeds, retries, or encounters an uncertain outcome? Designing those failure paths upfront can make agentic systems far more dependable in production.
Totally, the failure paths are where most of the design work actually goes. Thanks for reading!
Your first bullet, the GET that is not free to repeat, is the everyday case for a paid search tool: a replay bills again and hands back rows the client already holds, and a hash of the arguments catches neither, because the model rewrites the filter the way it rewrites Anna's subject line. What held for us is your rule applied to a read: the key is the record identity, never the query. The caller passes the ids of the result sets it already received, the tool skips every record present in them and bills only what is new, so a replay with a reworded title filter still delivers each person once. And the ledger lesson transfers whole: a record missing from the re-pull is unseen, not gone, so nothing downstream gets marked as having left.
“Unseen, not gone” is the same rule as “unknown, not nothing happened”, great extension to reads. One detail: the ids already received should come from the run’s own record, not from the model. Added to #466 with credit to you: github.com/apowerb/apowerb/issues/466
Great piece! Your distinction between external identity and LLM-generated content for idempotency keys completely hits the nail on the head.
A few thoughts on your open questions:
Multiple emails per recipient/event: Add a logical action tag or step type to the key (e.g., (event_id, recipient, "acknowledgment") vs (event_id, recipient, "summary")) to separate distinct intents.
Branch divergence on replay: If the agent drops a recipient on the second run, they simply aren't emailed again, which is usually fine. For the ones targeted twice, deterministic keys successfully block duplicates. Post-execution reconciliation helps catch any missed critical actions.
Ledger retention: Instead of a blind time-based TTL, tie cleanup to the parent business object's lifecycle (e.g., active campaign or ticket status) combined with a rolling 30-day window.
The gap before ledger write: For APIs lacking native idempotency, an Outbox pattern (PENDING state before the call, confirmed after) paired with an async reconciliation loop is the safest bet.
Thanks! Fully agree on the outbox and on tying retention to the parent object. One condition on the action tag: it has to come from the tool or workflow definition, never from the model, or it’s generated content again. Let’s fold all four points into the RFC.
Two things on the open questions.
On drift between runs: the ledger as described keys on (event, tool, target), which handles overlap but not the drop. I'd freeze the intended target set as a plan row before the first effect — a list of (target, key) written when the plan is approved — and let a replay reconcile against the union of plan and ledger. Then "emailed in run one, dropped in run two" becomes a visible diff (approved but never sent) instead of a silent skip, and the metric you want changes from "did the run finish" to "did every approved recipient get exactly one effect".
On retention: a time TTL feels wrong for this. A provider replay window like Stripe's 24h is a clock, but "the same email next month is legitimate" is a business rule. Since the key already scopes to the event, the event id is the natural expiry — a new event id never collides, so the row can live as long as you keep the business record of the event.
One question: for a tool whose provider has no idempotency support and whose effect is irreversible, do you treat the ledger write as the only barrier, or do you require a provider-side receipt you can look up later before the row counts as done?
Your point is now an issue on the repo: github.com/apowerb/apowerb/issues/477
If you’d like to contribute, comment there or open a PR. The tech team reviews every contribution, and you’ll be credited by name. Thanks for pushing the design forward!
Very good idea. To your question: not the ledger alone. The ledger stops us from duplicating our own effects; it doesn’t prove the effect happened. For an irreversible tool with no provider idempotency, a row would go pending before the call, done only on a provider receipt we can look up later (a message id, a transaction id), and unknown if the process dies in between. Unknown is never retried automatically, it goes to a human. That’s the rule our runs already follow: an unrecorded tool history means “unknown”, never “nothing happened”, and the replay is refused. A provider with no receipt at all never reaches done on its own.
The plan row fits naturally: our email-campaign skill already stops for approval before sending, and the approved list is exactly the set to freeze.
This deserves to be in the RFC. Would you post it there? github.com/apowerb/apowerb/issues
Thanks in advance for your contribution, and for the exchange, it was a really nice one.
You nailed the hard part: the key answers "have I done this, for this reason, to this target." We run Service Bus duplicate detection on a few flows, and the dedup key always comes from the business identifier, never the payload, for exactly this reason. On the ledger TTL question, scoping retention to the window where replays actually happen feels right. A duplicate from six months ago is usually a different request.
Thanks for the comment. Yes, Service Bus is a good reference here, since its duplicate detection makes the same call: business ID as the key, and a bounded window rather than forever. One constraint on our side: the window has to cover how long a failed run stays replayable, since a replay can be triggered by a human well after the crash, not just by an automatic retry. How do you handle flows where the business ID alone isn’t enough, like two legitimate messages for the same order?
The rule that the key comes from stable intent and never from generated content is the part I would pin above every agent tool. The email campaign that dies at 40 of 100 shows why both obvious answers fail: refuse the replay and 60 people get nothing, force it and 40 get the email twice.
Max Kaya's idea of freezing the intended target set as plan rows before the first effect fits well with what you already have. Once a human approves the campaign, that approval is the stable intent: a fixed list of recipients, each keyed on the event id and the resolved record, with nothing the model wrote. A replay then only has to ask which rows are still pending.
We use the same idea on client builds outside agents too. Write down what done looks like for each target before anything irreversible runs, and recovery becomes a lookup instead of a judgment call. Will the revoked state be visible to the person approving a forced replay?
Yes, that’s the whole point of the state. Today a forced replay only shows what the 409 lists: the tools the run already called. With per-target rows, the person forcing it should see each approved recipient as pending, done, revoked or unknown, with who revoked it and when. Only the pending ones go out. Revoked and unknown stay visible, and nothing happens to them unless someone picks them explicitly.
“Recovery becomes a lookup instead of a judgment call” is a great way to put it. And approval is the right moment to freeze the list, as Max suggested.
The pending row upthread covers the crash case. The same insert also covers one nobody has raised: parallelism inside a single turn. The Messages API can return several tool_use blocks in one assistant response, and the docs leave execution order to the caller, with a note that tools with side effects or shared state may be better run sequentially. Run them with Promise.all and two calls carrying the same (event, tool, recipient) key can both read an empty ledger before either writes. A select, then send, leaves that window open for the length of the request. The fix is the one your webhook section already uses: a unique index that rejects the duplicate insert, moved off (subscription_id, resource_id) and onto the effect key, with the pending row written before the call. The loser of the insert skips instead of asking. So: does that recipient loop live inside one tool call, or does the model get one tool_use block per recipient? Inside one call the loop is yours to serialise. One block per recipient hands the ordering to the runtime, and a batch call instead hides the race behind a key covering many targets, where a partial failure gets hard to attribute.
One tool_use block per recipient. The skill tells the model to send one call per recipient and not to parallelize, but that’s an instruction in the prompt, not a guarantee. If the model emits several send calls in one response, the ordering stops being ours, which is exactly your race.
So the guarantee has to live in the database, not the prompt: the pending row inserted against a unique index on the effect key, before the call, and the loser of the insert skips. We’d keep one block per recipient on purpose: each key covers exactly one target, so a partial failure stays attributable.
You clearly know this problem better than most. Want to open an issue for it on the repo, and take the PR? The ledger table with the unique index on the effect key and the pending insert before the call would be a great first contribution. The tech team will review it, and you’ll be credited by name: github.com/apowerb/apowerb/issues