Agent write tools fail quietly when retries replay the same mutation without a stable idempotency key. This workshop freezes that failure into an 80-minute lab with a stamp card, a local stub, and a replay assertion students can rerun. The method stays useful for teaching even if you never touch a hosted model product during class.
What this lab actually proves
Most public tool-calling demos stop after the model emits a function name plus JSON arguments for the host. That stopping point is not enough for POST, PATCH, PUT, or delete-style tools under host retry. The host may retry after a timeout while the first mutation already landed on the stub. Students then debug a double-applied fixture instead of learning a write-once contract they can actually test.
The core conclusion is simple enough to put on a whiteboard before anyone opens an editor. A write tool is not complete until the host can prove that identical stamps produce one side effect. Identical stamps must include the tool name, a canonical argument digest, and an idempotency key the model cannot silently omit. If any of those three fields is missing, the stub must fail closed instead of applying a mutation.
This article is a teaching outline, not a production payments design, and the sample server is a lab fixture. Treat every command below as a classroom rerun recipe unless you later replace the in-memory map with a real store. No metric in this outline is a product benchmark, and no timing claim refers to a hosted model endpoint.
The Write-Once Stamp Card
Print or paste this card so every pair fills it before a model proposes create_lab_order. The card is the grading surface; fluent model prose is not evidence that a mutation ran once. Students should keep the filled card beside the curl transcripts through the debrief block.
Stamp card fields
-
tool_name— exact function the host will allow, with no aliases or case folding. -
arg_digest— SHA-256 of canonical JSON arguments, keys sorted, no extra whitespace. -
idempotency_key— opaque string supplied by the caller, stable across one user intent. -
side_effect_counter— integer the stub increments only on the first successful apply. -
decision— one ofAPPLY,REPLAY_OK, orREJECT, copied from the stub body.
Decision table (lab oracle)
| Incoming stamp | Store lookup | Required decision | Side-effect delta |
|---|---|---|---|
| Missing key | n/a | REJECT |
0 |
| New key, valid args | miss | APPLY |
+1 |
| Same key, same digest | hit | REPLAY_OK |
0 |
| Same key, different digest | hit | REJECT |
0 |
| Unknown tool name | n/a | REJECT |
0 |
The table is the oracle for the whole session and should stay frozen while students edit code. If a student implementation returns APPLY twice for one key, the lab has failed regardless of how confident the model sounded. Grade the counter and the decision string, then ignore any narrative the model attached to the tool call.
Workshop clock
Keep a visible timer and refuse to open a model client until the curl baseline is green. The times below assume a small group that already reads HTTP status lines and JSON objects. Absolute beginners in JSON should get a longer schema block rather than a compressed demo.
Minutes 0–10 — Frame the failure
Show a timeout-and-retry story without naming a vendor, a model, or a hosted agent brand. A client posted create_lab_order, waited thirty seconds, then posted again with new JSON and no key. Ask students to write the observed inventory delta on paper before they see any code. Collect two or three guessed deltas, then state the oracle: without a stamp, the correct educational answer is undefined, therefore unfit for class credit.
Minutes 10–25 — Freeze the schema
Do not let the model invent argument shapes during the first coding block of the session. Hand out a frozen JSON Schema for one write tool only, and treat extra properties as a failed drill. A workable teaching slice is create_lab_order with sku, qty, and idempotency_key. Students must copy the schema into a file they will not edit until the debrief, because schema drift collapses the oracle table.
Proposed schema fixture, labeled unexecuted until students save it:
{
"$id": "lab://create_lab_order/v1",
"type": "object",
"additionalProperties": false,
"required": ["sku", "qty", "idempotency_key"],
"properties": {
"sku": { "type": "string", "minLength": 1, "maxLength": 32 },
"qty": { "type": "integer", "minimum": 1, "maximum": 20 },
"idempotency_key": { "type": "string", "minLength": 8, "maxLength": 64 }
}
}
Minutes 25–50 — Build the stub and the stamp store
Students implement a local HTTP stub that never talks to a paid API during this block. The store is a process-local dictionary keyed by idempotency_key, guarded by a lock because retries can overlap. On APPLY, persist the digest and increment side_effect_counter before returning the order identifier. On REPLAY_OK, return the original body and leave the counter unchanged so a timeout retry is visible and safe. On REJECT, return HTTP 409 or 400 and leave the counter unchanged so a mutated payload cannot hijack a key.
Minutes 50–70 — Run the four replay drills
Each drill is a command the whole room can rerun from the same working directory. Grade the side_effect_counter, not the model's explanation of what it meant to do. If a pair cannot finish all four drills, keep Drill A and Drill B as the minimum passing packet and park Drill C as homework.
Minutes 70–80 — Debrief with the card
Students mark which drills their stub failed, using the oracle table instead of memory. Typical misses are canonical JSON that still depends on key order, keys generated inside the stub instead of the caller, and silent coercion of qty from string to integer. Record those misses on the stamp card so the next lab starts from evidence rather than from a fresh anecdote.
Worked example students can rerun
The following Python 3 fixture is a lab stub, not a framework and not a durable ledger. Save it as write_once_stub.py and run it on loopback only. Restarting the process resets the store, which is an intended limitation for an 80-minute room.
#!/usr/bin/env python3
"""Lab fixture: fail-closed write-once stub. Not a production store."""
from __future__ import annotations
import hashlib
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Lock
ALLOWED_TOOL = "create_lab_order"
STORE: dict[str, dict] = {}
LOCK = Lock()
SIDE_EFFECTS = 0
def canonical_digest(args: dict) -> str:
body = json.dumps(args, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(body.encode("utf-8")).hexdigest()
def decide(payload: dict) -> tuple[int, dict]:
global SIDE_EFFECTS
tool = payload.get("tool_name")
args = payload.get("arguments")
if tool != ALLOWED_TOOL or not isinstance(args, dict):
return 400, {"decision": "REJECT", "reason": "unknown_tool_or_args"}
key = args.get("idempotency_key")
sku = args.get("sku")
qty = args.get("qty")
extra = set(args) - {"sku", "qty", "idempotency_key"}
if extra or not isinstance(key, str) or not (8 <= len(key) <= 64):
return 400, {"decision": "REJECT", "reason": "schema"}
if not isinstance(sku, str) or not (1 <= len(sku) <= 32):
return 400, {"decision": "REJECT", "reason": "schema"}
if not isinstance(qty, int) or isinstance(qty, bool) or not (1 <= qty <= 20):
return 400, {"decision": "REJECT", "reason": "schema"}
digest_args = {"idempotency_key": key, "qty": qty, "sku": sku}
digest = canonical_digest(digest_args)
with LOCK:
prior = STORE.get(key)
if prior is None:
SIDE_EFFECTS += 1
record = {
"decision": "APPLY",
"arg_digest": digest,
"side_effect_counter": SIDE_EFFECTS,
"order_id": f"lab-{len(STORE) + 1}",
}
STORE[key] = record
return 200, record
if prior["arg_digest"] != digest:
return 409, {
"decision": "REJECT",
"reason": "key_reuse_with_different_args",
"side_effect_counter": SIDE_EFFECTS,
}
replay = dict(prior)
replay["decision"] = "REPLAY_OK"
replay["side_effect_counter"] = SIDE_EFFECTS
return 200, replay
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args) -> None:
return
def do_POST(self) -> None:
if self.path != "/tool":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length)
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
payload = {}
status, body = decide(payload)
encoded = json.dumps(body).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
if __name__ == "__main__":
server = ThreadingHTTPServer(("127.0.0.1", 8088), Handler)
print("write-once stub on http://127.0.0.1:8088/tool")
server.serve_forever()
Start the stub in one terminal and leave it running through every drill:
python3 write_once_stub.py
Run the four drills from a second terminal without restarting the stub between calls. These curl commands are the reproducible artifact students should keep in version control next to the filled stamp card.
# Drill A — first apply (expect APPLY, counter 1)
curl -sS -D - http://127.0.0.1:8088/tool \
-H 'Content-Type: application/json' \
-d '{"tool_name":"create_lab_order","arguments":{"sku":"SKU-9","qty":2,"idempotency_key":"intent-alpha-01"}}'
# Drill B — retry with the same stamp (expect REPLAY_OK, counter still 1)
curl -sS http://127.0.0.1:8088/tool \
-H 'Content-Type: application/json' \
-d '{"tool_name":"create_lab_order","arguments":{"sku":"SKU-9","qty":2,"idempotency_key":"intent-alpha-01"}}'
# Drill C — same key, mutated qty (expect REJECT, counter still 1)
curl -sS -D - http://127.0.0.1:8088/tool \
-H 'Content-Type: application/json' \
-d '{"tool_name":"create_lab_order","arguments":{"sku":"SKU-9","qty":3,"idempotency_key":"intent-alpha-01"}}'
# Drill D — missing key (expect REJECT)
curl -sS -D - http://127.0.0.1:8088/tool \
-H 'Content-Type: application/json' \
-d '{"tool_name":"create_lab_order","arguments":{"sku":"SKU-9","qty":2}}'
A passing room has one APPLY, one REPLAY_OK, two rejects, and side_effect_counter equal to 1 after all four drills. Anything else is a teaching defect in the stub or in the client that minted the stamp. Do not “fix” a failing counter by restarting the process, because that erases the evidence the card is meant to capture.
Optional model lane after the stub is green
Do not invite a model into the loop until Drill A through Drill D pass on curl alone. After that, the model is only a JSON producer sitting behind the same schema and the same stamp card. The host still validates required fields, still computes arg_digest, and still refuses missing keys. That split is the lesson; fluent tool-call prose is not a substitute for the oracle table.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. Classrooms that already have a local model or a vendor key should keep using that path for argument generation. Classrooms that do not can point the same host at MonkeyCode's free model access and free server option, then keep this stub as the only component allowed to mutate state. This article does not claim model names, token quotas, hardware, uptime, or benchmark numbers for that option, because those details must be checked on the project page at publish time. If you need a free-model lane for argument generation, read that current project page before class and keep this stub as the only mutation gate.
A minimal host wrapper, labeled as a proposal, looks like the following sketch. Replace generate_tool_json with whatever client you actually use after the curl baseline is already green.
# Proposal / unexecuted host sketch — wire your own generate_tool_json.
import json, urllib.request
def canonical_digest(args: dict) -> str:
import hashlib
body = json.dumps(args, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(body.encode("utf-8")).hexdigest()
def host_write(tool_json: dict) -> dict:
args = tool_json["arguments"]
if "idempotency_key" not in args:
raise ValueError("fail closed: model omitted idempotency_key")
stamp = {
"tool_name": tool_json["tool_name"],
"arg_digest": canonical_digest(args),
"idempotency_key": args["idempotency_key"],
}
req = urllib.request.Request(
"http://127.0.0.1:8088/tool",
data=json.dumps(tool_json).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as resp:
body = json.loads(resp.read().decode("utf-8"))
body["stamp"] = stamp
return body
If you use a free server to host the stub instead of loopback, bind it to a classroom-only network and keep the in-memory map. The stamp card does not become more true because the process moved off a laptop. Network placement is logistics; the oracle table is still the only passing condition.
Limitations
This lab ignores exactly-once delivery across process crashes, because the store lives in RAM and vanishes on restart. It also ignores partial downstream work, such as a payment capture that succeeded while an inventory write failed. Canonical JSON here does not normalize Unicode homoglyphs in sku, so two visually similar SKUs can mint different digests. Integer qty rejects bool, but a model that emits qty as a string still fails the schema on purpose.
The 80-minute clock assumes students can read HTTP status lines without a long detour into curl flags. Absolute beginners in JSON will overrun the schema block and should get a longer session rather than skipped drills. The drills do not measure latency, token cost, or model quality, and they should not be cited as a benchmark of any hosted product. They also do not prove safety for concurrent distinct keys under a multi-process deployment, because the lock is in-process only.
Who should not use this approach
Skip this outline if you need a real idempotency store for payments, inventory, or medical writes; use a durable key-value store with documented retention instead. Skip it if your tools are read-only, because a stamp card adds ceremony without a mutation to protect. Skip it if you want the model to retry until something happens to succeed, because that goal conflicts with fail-closed teaching. Skip it if you cannot pin a schema for even one tool, because the oracle table then has no meaning.
What to collect before you dismiss the room
Ask each pair for four artifacts only: the filled stamp card, the four curl transcripts, the final side_effect_counter, and one sentence naming the drill they failed first. That packet is enough to see whether the class learned write-once behavior or only learned to paste tool-calling snippets. If you later try the same packet against a model-produced JSON body, compare it to the curl baseline, not the other way around, so the stub remains the source of truth.
Top comments (0)