Technovate AI
Tutorials

Tutorial: production-grade tool use with Claude

A walkthrough of error handling, schema validation, and observability patterns that survive contact with users.

AS

Anand Subramanian

Senior Engineer

18 min read
  • Tutorials
  • Tool use
  • Claude

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.

Working on something like this?

Talk to the engineers who wrote it.

Thirty minutes with the senior team. Bring the problem — we will give you an honest read on what it would take.