C.W.K.
Stream
Lesson 04 of 05 · published

Side-Effect Boundaries

~22 min · read-tool, write-tool, idempotency, human-in-the-loop

Level 0Curious Reader
0 XP0/48 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete

Not all tools are equal. A read tool — list orders, look up a customer, fetch a price — has no consequences if the model calls it ten times by accident. A write tool — issue a refund, send an email, schedule a deploy — can ruin somebody's day if the model gets enthusiastic. The protocol does not protect you from this distinction; you have to build it in.

The first habit is category by name. Read tools should sound like queries: search_orders, get_customer, list_invoices. Write tools should sound like verbs with a target: refund_order, send_email, schedule_deploy. Naming is one of the few signals the model sees from across the schema; using verbs for writes is a quiet bias toward correctness.

The second habit is idempotency keys for write tools. If a network blip causes the same write to be called twice, the system should detect it and refuse the duplicate. Most APIs you would call already support this (Stripe's Idempotency-Key, for example); your tool should accept and forward such a key generated from the conversation context.

The third habit is human-in-the-loop on the dangerous edge. For irreversible writes — money out, messages sent, infrastructure changed — the right pattern is a two-step tool: propose_refund returns a structured proposal, the agent shows it to the human, and only after explicit approval does the agent call execute_refund with the proposal ID. The protocol gives you the structured proposal; you owe the user the approval.

This is also the lens MCP brings explicitly: the spec marks tools that may have effects on the world and asks clients to surface that to users. The lens is identical to what good API designers were already doing; MCP just made it part of the contract.

Code

Two-step write — propose then execute·python
{
  "name": "propose_refund",
  "description": "Build a refund proposal for human review. Does NOT execute.",
  "input_schema": {
    "type": "object",
    "properties": {
      "order_id": {"type": "string"},
      "amount_cents": {"type": "integer"},
      "reason": {"type": "string"}
    },
    "required": ["order_id", "amount_cents", "reason"]
  }
},
{
  "name": "execute_refund",
  "description": (
    "Execute a previously-approved refund. Requires a proposal_id from "
    "propose_refund AND the human's explicit approval. Do not call without "
    "approval — the user-facing client will reject it."
  ),
  "input_schema": {
    "type": "object",
    "properties": {
      "proposal_id": {"type": "string"},
      "approved_by_user": {"type": "boolean"}
    },
    "required": ["proposal_id", "approved_by_user"]
  }
}
Idempotency at the boundary·python
import hashlib, requests

def execute_refund(proposal_id, approved_by_user, conversation_id):
    if not approved_by_user:
        return {"error": "Refund requires explicit user approval."}
    idem_key = hashlib.sha256(f"{conversation_id}:{proposal_id}".encode()).hexdigest()
    r = requests.post(
        "https://api.stripe.com/v1/refunds",
        data={"payment_intent": proposal_id_to_intent(proposal_id)},
        headers={"Idempotency-Key": idem_key, "Authorization": "Bearer ..."},
    )
    return r.json()

External links

Exercise

List every tool in a project you maintain (real or imagined). For each, write a single letter: R for pure read, W for write, D for irreversibly destructive. For every D, design the two-step propose/execute split. The number of D tools without a two-step is your remaining homework.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.