I was writing out the steps for a store price change as a tool one evening, and I stopped right after typing the name apply_price_change. The input schema was already there. All that remained was adding it to the tools array. Once I did, Claude would call it the moment it decided the change was needed.
As an indie developer I ship a handful of apps, and I've always touched pricing and release settings by hand in the store console. The procedure itself is short — maybe a dozen lines if you write it out — and that shortness is exactly what makes it tempting to turn into a tool. What stopped me wasn't difficulty. It was the mismatch between how easy the call would be and how much work undoing it would take.
On September 2nd, Anthropic published blueprints for commerce agents: reference implementations for retail, travel, telecom, and ticketing, split into a buyer-facing shopping agent and a merchant-facing agent for store operators. What caught my eye wasn't the feature list. It was that the shopping agent stops at handing a completed cart to checkout. Completing the payment isn't counted among the agent's possessions.
Decide What You Won't Hand Over, Before You Add Approvals
The usual answer for irreversible actions is an approval gate: the tool gets called, execution pauses, a human looks at it and lets it through. I use gates too, and there are places that genuinely need them.
But a gate is a mechanism that stops things after they're called. It assumes the stopping side is working. When the notification doesn't arrive, when the hold expires, when a branch in the gate logic was never written — what happens next depends on which way the default falls. If it falls toward execution, the accident looks exactly the way it would have without the gate.
A tool that isn't in the list, on the other hand, can't be called at all. That single property — not depending on the health of the stopping mechanism — has done more for my sleep than any amount of gate logic.
There's a second thing I like about leaving the name out. A gate has to be re-read every time the tool set changes, because the branch that protects a call is written somewhere other than the call itself. An absent tool needs no maintenance. Nothing about next month's refactor can quietly reconnect it.
If undoing it requires a human decision, its name doesn't go in the tool list. I place that line before I think about gates at all. Gates are for fine-grained control over what's left inside the line.
The gate mechanics themselves — pausing and resuming, asynchronous notifications, expiry handling — are covered in Inserting Approval Gates Into Your Agents. What I want to work through here is the step before that.
Three Tiers: Advise, Prepare, Commit
For a while I sorted tools into two buckets: reads and writes. That didn't work well. A single "writes" bucket ends up holding both saving a draft and pushing to production. Both are writes. One of them can be erased, and the other can't.
Three tiers hold up better.
| Tier | What the agent owns | Shape of the return value | Examples |
|---|---|---|---|
| Advise | Look up, compare, recommend | Candidates, comparisons | Catalog search, inventory checks, pricing suggestions |
| Prepare | Assemble, draft | A draft with an id and a status | Cart assembly, reply drafts, a pending price change |
| Commit | Not handed over | — | Payment, sending, publishing, deleting, refunding |
Mapping the two blueprint agents onto these tiers makes the pattern easy to see. The shopping agent advises through search and comparison, prepares by assembling a cart, and then hands off to checkout. The merchant agent answers questions about sales performance, flags inventory problems, recommends pricing and promotions, and drafts campaigns. Neither of them steps into the third tier.
When a borderline case comes up, I ask myself one question: to undo this, would I need someone other than me? A refund request, retracting an email that already went out, rolling back a price that's already live — none of those close with my hands alone. What doesn't close is tier three.
Reversibility also depends on where an action lands, not just what it's named. The same delete can be recoverable in one place and permanent in another. I wrote up how I measure that in The Same rm -rf Was Recoverable in Ten Places and Unrecoverable in Five.
Rewriting the Definition So Commit Becomes a Draft
The inventory shows up as a change to the tool definition. Here's what I had started writing that evening.
# Before: the outside world changes the moment this is called
tools = [
{
"name": "apply_price_change",
"description": "Change the store price of the given SKU to a new price.",
"input_schema": {
"type": "object",
"properties": {
"sku": {"type": "string"},
"price_jpy": {"type": "integer"},
},
"required": ["sku", "price_jpy"],
},
}
]And here's what it became. The name changed, the description changed, and — most importantly — the character of the return value changed.
# After: calling it only changes a review queue
tools = [
{
"name": "draft_price_change",
"description": (
"Create a draft price change and queue it for review. "
"Does not change the store price. A human applies it separately."
),
"input_schema": {
"type": "object",
"properties": {
"sku": {"type": "string"},
"price_jpy": {"type": "integer"},
"reason": {"type": "string", "description": "Why this price"},
},
"required": ["sku", "price_jpy", "reason"],
},
}
]The handler returns a draft without touching anything outside.
import uuid
PENDING = [] # a database or a queue in production
def draft_price_change(sku: str, price_jpy: int, reason: str) -> dict:
current = load_current_price(sku) # read only
draft = {
"draft_id": str(uuid.uuid4()),
"status": "awaiting_review",
"sku": sku,
"from_jpy": current,
"to_jpy": price_jpy,
"reason": reason,
}
PENDING.append(draft)
return draftI made reason required for my own sake, later. When I open the review queue in the morning, a column of price deltas tells me nothing I can act on. One line explaining why the number is what it is makes approving or rejecting a much shorter decision.
The point of the rewrite is that the return value carries only a draft_id and a status. From the model's side, all that comes back is the fact that a draft now exists. The store price stays where it was until I open another screen and apply it myself.
Check the Return Value, Not the Tool Name
Once you have more than a handful of tools, definitions start drifting between tiers without anyone noticing. You can catch them by reading, but I wanted a machine to look too. This script just picks out hard-to-undo verbs from names and descriptions.
# tool_commit_lint.py — look for commits hiding in the tool list
import json
import re
import sys
COMMIT_VERBS = ["place", "submit", "charge", "capture", "refund",
"cancel", "publish", "send", "delete", "deploy", "transfer", "apply"]
DRAFT_MARKERS = ["draft", "proposal", "preview", "plan", "quote", "candidate"]
def lint(tools):
findings = []
for t in tools:
text = f"{t.get('name','')} {t.get('description','')}".lower()
hit = [v for v in COMMIT_VERBS if re.search(rf"\b{v}\w*", text)]
drafty = any(m in text for m in DRAFT_MARKERS)
if hit and not drafty:
findings.append((t.get("name", ""), hit))
return findings
if __name__ == "__main__":
tools = json.load(open(sys.argv[1], encoding="utf-8"))
found = lint(tools)
for name, verbs in found:
print(f"COMMIT? {name} <- {', '.join(verbs)}")
print(f"{len(found)} / {len(tools)} tools need a second look")
sys.exit(1 if found else 0)Running it over six definitions on my machine gave this.
COMMIT? place_order <- place, charge
COMMIT? apply_price_change <- apply
COMMIT? send_review_reply <- send
3 / 6 tools need a second look
build_cart passed because its description says preview, and draft_price_change passed too. send_review_reply got flagged, which is what I wanted: once a reply to a store review goes out, it goes out.
I wouldn't take much comfort from a pass, though. Verb choices vary from person to person, and a calm-sounding update_status can finalize a payment without a single suspicious word in its name. The script is a set of markers, not a net.
What I do trust is the pass rate over time. When a run that used to report zero starts reporting one, something changed in the last few commits, and I'd rather read a small diff today than reconstruct a week of them after an order goes out on its own.
The last check is the return value. If what comes back is a draft id and a status, you're in tier two. If it's a report of something that already happened — an order number, a delivery confirmation, a published URL — you're in tier three. Names can be rewritten. Return values don't lie.
What You Can Do Tomorrow Morning
Open one tools array, read the definitions from the top, and say out loud whether each return value describes something about to happen or something that already has. I keep the three tiers on a sticky note at the edge of my screen, and I still put a finger on it every time I add a tool.
Not handing over irreversible actions is less about caution than about being able to explain yourself afterward. The range of what we hand to agents will keep widening — I'm sure of that. What I'd rather protect is the habit of counting what stops being undoable before I widen it.
Thank you for reading. If even one entry in your own list turns out to be worth a second look, this was worth writing.
Reference: Building Commerce Agents with Claude