Side effects and identity
Trace a model-requested tool call through schema validation, execution and a returned observation.
THE IDEA
Side effects and identity
Tools differ in what they change. A read can be repeated freely. A write (send an email) or a payment changes the world each time. If a send succeeds but the confirmation times out, a sensible model retries, and the email goes out twice. Give each logical request an identifier (an idempotency key) so the host recognises the repeat and ignores it, and gate irreversible actions behind approval.
Predict first. Can retrying a purchase tool accidentally buy twice?
Chapter context
A tool call is a structured request for the application to perform an operation. The model proposes arguments; the surrounding application decides whether the operation is valid and permitted and is responsible for executing it.
TRY IT YOURSELF
Live lab
The mail server times out on the first send, but the email actually went out. A model that sees a timeout will naturally retry. Decide what your host does about it.
TRY IT YOURSELF
Don't send it twice
The mail server times out on the first send, but the email actually went out. A model that sees a timeout will naturally retry. Decide what your host does about it.
Finish with exactly one email in the outbox, even though the first attempt times out.
Task given to the model: Email [email protected] with the subject "Build" saying the build passed.
This loop as code
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
TOOLS = [ # name, description and input_schema are the "Tool definition"
{"name": t["name"], "description": t["description"], "input_schema": t["schema"]}
for t in [
{
"name": "send_email",
"description": "Send an email. This has a real side effect.",
"schema": {
"type": "object",
"properties": {
"to": {
"type": "string"
},
"subject": {
"type": "string"
},
"body": {
"type": "string"
}
},
"required": [
"to",
"subject",
"body"
]
}
}
]
]
messages = [{"role": "user", "content": "Email [email protected] with the subject \"Build\" saying the build passed."}]
for step in range(6): # Step budget
response = client.messages.create(model="claude-haiku-4-5", max_tokens=600, tools=TOOLS, messages=messages)
messages.append({"role": "assistant", "content": response.content})
calls = [b for b in response.content if b.type == "tool_use"]
if not calls:
break # final answer
results = []
for call in calls: # the model only PROPOSES these
problems = validate(call.input, schema_for(call.name)) # your host-side check
if problems:
output, is_error = "Invalid arguments: " + "; ".join(problems), True
else:
output, is_error = run_tool(call.name, call.input) # your code runs it
results.append({"type": "tool_result", "tool_use_id": call.id, "content": output, "is_error": is_error})
messages.append({"role": "user", "content": results})Update the controls above and this code changes with them. Run it yourself with the key in an environment variable.Exercises
Part 1. Do it hands-on
Count deliveries by hand, then write the idempotent retry and the retry-safety rules.
- To do: The mail server delivers the first send but times out before confirming; the model retries once. Emails actually delivered without any idempotency guard?
- To do: The same run when every send carries an idempotency key that the host remembers?
- To do: A read-only lookup is retried 3 times. How many side effects does it cause?
- To do: Can retrying a purchase tool accidentally buy twice if there is no identity or deduplication? Type yes or no
Part 2. Do it in code
- Outbox (PROVIDED) records each delivered email in .sent; with key=None every send is delivered, with a key a repeat is recognised and returns "duplicate". If timeout_first is set, the FIRST call delivers the email and then raises TimeoutError (no confirmation).
- make_key(to, subject, body): a stable idempotency key: the first 10 hex characters of sha1 of the three fields joined with "\x1f". The same logical request always gives the same key; changing any field changes it.
- send_with_retry(outbox, to, subject, body, dedupe, attempts=2): try to send; on TimeoutError try again, up to `attempts` tries in total. Use make_key as the key only when dedupe is True. Return the status string of the first successful return, or "failed" if every attempt timed out.
- effect_class(tool_name): "read" for names starting get_, list_, read_ or search_; "write" for send_, write_, create_, update_ or delete_; "payment" for pay_, charge_, refund_ or purchase_; otherwise "unknown".
- safe_to_retry(effect, has_key): read → True; write or payment → has_key; unknown → False.
Both the live lab and the exercises must be solved to finish this lesson.
Stuck, or something not working? You can come back to it any time.
CHECK YOUR UNDERSTANDING
Can retrying a purchase tool accidentally buy twice?
Chapter reference: math, worked example & code
This section belongs to the whole chapter, so it is the same on every lesson in it and may cover a different lesson's topic. The code for the experiment you just ran is inside the experiment itself.
Math and notation
A useful execution model is: requested operation + validated arguments + permission decision → execution result. Schema validation establishes a data-shape contract; authorization establishes whether this caller may perform this operation. Neither implies the other.
Worked example
A calculator tool requires numeric a and b and returns a+b. For a=2,b=3, its result is 5. If the request instead asks for an unknown field or omits b, reject or repair the request according to the contract before executing. A retry of this pure calculation is harmless; that does not establish the same property for a side-effecting tool.
Reference code
The following example uses the Python standard library and does not call a remote service.
def add(a,b):
if type(a) not in (int,float) or type(b) not in (int,float):
raise ValueError('numeric inputs required')
return a+b
assert add(2,3) == 5
try:
add('2',3)
except ValueError:
pass
else:
raise AssertionError('invalid input accepted')
Python examples are provided for study and local execution; this page does not execute Python.
Common mistakes
A model proposal is not permission. JSON schema shape validation is not business-rule validation. Tool outputs must not silently become trusted instructions.