Making Anthropic Agent SDK Tool Calls Idempotent
The Agent SDK runs a tool at least once. Exactly-once is a property of the API that stores the idempotency key, and the key has to be the same on the retry.
Key takeaways
- -The Agent SDK tool loop is at least once. A timeout, a dropped response, or a second tool_use block can run the same side effect again.
- -tool_use_id identifies one block in the transcript. A retry is a new block, so deduping on that id does not dedupe the HTTP call.
- -Exactly-once lives at the API that stores the idempotency key, not in the hook that saw the tool request.
- -The key has to be derived from the business action and reused. A key minted inside the tool function is a new write on every retry.
- -PreToolUse runs before the HTTP call and cannot see whether the previous attempt already committed.
The Anthropic Agent SDK will run a tool more than once. That is the contract of the loop. A payment, a refund, an email, or a record create is safe only if the second run is the same attempt as the first, and the server is the thing that notices.
People search this as "once semantics." The phrase covers two different promises. At-least-once means the tool may run again after a failure, and your job is to make the second run harmless. Exactly-once means the side effect happens one time even when the response was lost. The SDK gives you the first. The API you are calling can give you the second, if you send a stable idempotency key and that API stores it. A hook in your process cannot give you the second by itself.
At least once is the loop's actual contract
An Agent SDK session is a loop. The model returns a tool call. Your code runs the tool. The result goes back to the model. The model either stops or calls another tool. Anthropic's description of that loop is the execution step: the SDK runs each requested tool and feeds the results back. Nothing in that step says the tool function runs once in the lifetime of the business action.
Three separate things produce a second execution.
The model emits two tool calls with the same arguments. Parallel tools, a correction, or a second turn after a result it did not trust. Both are real calls. The SDK runs both.
The tool function throws, times out, or returns an error the model treats as retryable. The next turn asks for the tool again. The arguments match. The call is new.
The process dies after the HTTP request was accepted and before your code recorded the response. The session resumes, or a new session is started with the same instruction, and the tool runs again. Your in-memory set of "calls I have already made" is gone.
None of these is a bug in the model. They are what an at-least-once loop does. Treating them as exactly-once is how a refund, a charge, or an outbound email happens twice. The payments version of that failure is How to Connect Stripe to Claude Code.
Where the retry boundary sits
Draw the path as four lines, because the word "retry" is used for all of them and only one of them can dedupe a write.
- The model decides to call a tool. That decision is a
tool_useblock with an id. - The SDK invokes your tool function, after any
PreToolUsehooks. - Your tool function performs the HTTP call.
- The server applies the write and stores an idempotency key if you sent one.
A retry at line 1 is a new block. A retry at line 2 is a new invocation. A retry at line 3 is a new HTTP request unless you attach the same key. A retry at line 4 is the same write, if the server kept the key from the first request and you sent it again.
Hooks sit at line 2. They run in your process, before the tool function, which is before the socket. They can block the invocation. They cannot observe whether line 4 already happened on a previous invocation whose response never came home. That is why a pre-call hook cannot dedupe. The hook timing, including PostToolUse and PostToolUseFailure, is in Anthropic Agent SDK Lifecycle Hooks and Idempotency.
The boundary that matters for money is line 4. Everything above it is allowed to repeat.
Why a new tool_use_id defeats dedupe
The callback for a PreToolUse hook receives a tool_use_id. It is the id of that block. It is unique. It is also useless as an idempotency key, because the retry does not reuse it.
A second turn that calls the same tool with the same arguments gets a new id. A resumed session gets a new id. A dedupe map keyed by tool_use_id records the first block and then watches a different block go through. Both HTTP calls happen. The map looks correct in the logs.
Dedupe on the action, not on the block. The action is the tool name plus a canonical form of the inputs that define the business operation: the charge id and the amount, not the timestamp the model added, not the id the SDK minted. Sort keys. Drop fields that change every attempt. Hash that. The same refund produces the same hash. A different amount produces a different hash, which is what you want, because that is a different refund.
An in-process set of those hashes stops a second call inside one live process. It does not stop the call after a crash, and it does not stop a second worker. For those, the hash has to leave the process as the idempotency key on the HTTP request, and the server has to store the first result.
What the tool function has to do
The tool function is the only place that both sees the arguments and sends the HTTP request. That is where the key is attached. Minting the key inside the function, with a UUID, makes every retry a new write. The key has to be an argument, or a pure function of the arguments, computed the same way on every attempt.
A practical split:
- The model supplies the business inputs: which charge, how much, which customer.
- Your tool computes the idempotency key from those inputs, or reads a key that a
PreToolUsehook wrote into the input withupdatedInput. The hook field is only useful if the value is stable across retries. A random value written by the hook is the same bug as a UUID inside the function. - The HTTP client sends that key on every attempt, including the attempt that follows a timeout.
- The client treats "already processed" as success and returns the stored result, rather than as an error the model should retry with new inputs.
Swytchcode's exec path is built for that last part. A retry reuses the idempotency key instead of starting a new write, which is the behaviour described in How Swytchcode Retries Failed API Calls and Idempotency Explained. The tool the SDK runs is then a call into that runtime, not a hand-rolled HTTP client that generates a fresh key because the request object was constructed again. Inputs, outputs, and the exit codes you get when a call is blocked or waiting are in "swy exec" Explained.
The tool the SDK runs is swy exec on a method you already added. Discover the current name in the project. Do not hardcode one from a post. Catalog ids change. Connecting Stripe from Claude Code, without pasting the key into the chat, is How to Connect Stripe to Claude Code. The model does not get to pick a second write because the first response was slow. The method list is the allow list.
Side effects that are not safe to retry
Some APIs store idempotency keys. Many do not. Email, some internal admin routes, and older POST endpoints apply the write every time the request arrives. Sending the same body twice is two sends.
For those, at-least-once in the SDK means you need your own store: record the hash of the action before you send, and on a retry read that record. If the record says the send completed, return the stored result and do not send. If the record says the send started and never finished, you still do not know. That ambiguous row is the crash window. Closing it requires the downstream system, or a human, not another automatic retry.
Reads are the other category. A GET that fetches a charge can run twice. Retrying it is fine. Wrapping it in a dedupe lock that returns a stale body is worse than the second request. Idempotency work belongs on writes.
MCP transports have the same split. A stateless session does not remember the key for you. Where that key has to live once the session itself is stateless is MCP Went Stateless. So Where Do Idempotency Keys Live Now?.
What to log so you can see the second call
Log the action hash, the tool_use_id, and the HTTP result as three columns. The first tells you whether two rows are the same refund. The second tells you they were different blocks, which is normal. The third tells you whether the server accepted one or both.
If you only log the tool id, two refunds look like two tasks. If you only log the hash, you cannot tell a model double-call from an HTTP retry. You want both, plus the policy decision when a rule held the write. Denied and held calls are part of the trail. A chat transcript of the successful turn is not.
FAQ
Does the Anthropic Agent SDK guarantee a tool runs once?
No. The loop is at least once. The model can emit the tool again, the SDK can invoke your function again after an error, and a crash can lose the response after the server has already applied the write.
What is the difference between at-least-once and exactly-once for a tool call?
At-least-once means a repeat is allowed and must be harmless. Exactly-once means the side effect happens one time even when the response was lost. The SDK can give you the first, by letting you see the repeat. The API that stores an idempotency key gives you the second. A hook that denies a repeated tool_use_id gives you neither, because the id changes.
Can I dedupe on tool_use_id?
Only inside a single block. The retry is a new block with a new id and the same arguments. Hash the tool name and the canonical business inputs. Send that value as the idempotency key on the HTTP request.
Where should the idempotency key be created?
Outside the attempt. Compute it from the business inputs, or set it once when the user action is accepted, and pass the same value into every retry. A UUID created inside the tool function, or inside a hook on each call, is a new key and a new write.
Will a PreToolUse hook stop a double charge?
It can stop a second invocation that is still inside the same process and that your hook recognises as the same action. It runs before the HTTP call. It cannot see a charge that succeeded on the server while the response was lost. The key has to be on the request the server stores.
How does this relate to Stripe refunds from Claude Code?
A timed-out refund that is retried with a new idempotency key is a second refund. The editor path and the amount rules are in How to Connect Stripe to Claude Code. The hook's position relative to that HTTP call is in Anthropic Agent SDK Lifecycle Hooks and Idempotency.
More content
How to Log Every API Call an AI Agent Makes
Log the call where it is sent, not where it is described. Record the operation, the actor, the idempotency key, the policy decision and the outcome. Never record the credential. If the agent writes its own log, the calls that were blocked quietly disappear.
How to Connect GitHub to an AI Agent (2026)
Most of what you want an agent to do on GitHub needs no push access at all. Working that out first is the difference between a useful teammate and a token that can rewrite your main branch.
Composio Pricing Compared with Nango, Arcade, Pipedream, and Swytchcode
Composio is free for 100,000 tool calls a month, then $29. Nango is $50 pay-as-you-go. Arcade Team is $25 plus a cent a call. Pipedream Startup is $99 billed annually. Swytchcode is $149 for a million. The headline prices are the least useful part, so here is the arithmetic.
