Tool use is the feature that turns a language model into something that can do work. It is also where most production incidents originate, because the failure modes only appear at volume and none of them show up in a tutorial.
This walkthrough covers the four patterns we apply on every engagement: strict schemas, idempotency, graceful degradation, and observability.
Define tools with strict schemas
Every parameter gets a type, a constraint, and a description written for a reader who has no other context. Validate the arguments before execution and return a structured error the model can act on, rather than raising and losing the turn.
tools = [{
"name": "issue_refund",
"description": (
"Refund a completed order. Requires an idempotency key. "
"Amount may not exceed the original order total."
),
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string", "pattern": "^ord_[a-z0-9]{12}$"},
"amount_cents": {"type": "integer", "minimum": 1},
"idempotency_key": {"type": "string"},
"reason": {"type": "string", "maxLength": 500},
},
"required": ["order_id", "amount_cents", "idempotency_key"],
},
}]The description field is doing real work here. Constraints stated in prose are respected far more consistently than constraints that only exist in the schema, so state them in both places.
Make retries safe
Networks fail, requests time out, and your orchestration layer will retry. If your tool is not idempotent, a retry becomes a double refund. Require an idempotency key on every state-changing tool and enforce it in the handler, not in the prompt.
def issue_refund(order_id, amount_cents, idempotency_key, reason=""):
existing = refunds.get(idempotency_key)
if existing:
return {"status": "ok", "refund_id": existing.id, "replayed": True}
order = orders.get(order_id)
if order is None:
return {"status": "error", "code": "order_not_found"}
if amount_cents > order.total_cents:
return {"status": "error", "code": "amount_exceeds_total",
"max_cents": order.total_cents}
refund = payments.refund(order, amount_cents, key=idempotency_key)
return {"status": "ok", "refund_id": refund.id, "replayed": False}Return errors the model can use
Notice that the failure paths return structured results rather than raising. A model that receives amount_exceeds_total with a max_cents field can correct itself on the next turn. A model that receives a stack trace, or nothing at all, cannot.
Every error your tool returns is a prompt. Write it for the model that has to recover from it.
Instrument everything
Log the tool name, arguments, result, latency, and the model version for every call. When a customer reports a wrong outcome six weeks later, this is the only thing standing between you and a theory you cannot test.
- Trace ID linking the full turn: prompt, tool calls, and final response
- Argument capture with PII redaction applied before storage
- Latency and error rate per tool, alerted independently
- Model and prompt version stamped on every record
Add a dry-run mode
During rollout, the most useful thing a system can do is tell you what it would have done without doing it. A dry-run flag on every state-changing tool lets you run against production traffic for a week and review the decisions before granting write access. We have never regretted the week.
