Tool design best practices for production agents
Tools are the action surface of an agent. The quality of your tool design determines whether your agent is reliable or fragile in production.
A poorly designed tool is invisible in demos and catastrophic in production. The model calls it with unexpected inputs. It raises an unhandled exception. The agent retries with the same bad call. You burn tokens, produce wrong results, and cannot debug it.
Good tool design is boring, explicit, and defensive.
Schema design with JSON Schema
Every tool should have a machine-readable schema that constrains the model's inputs. Provider SDKs (OpenAI, Anthropic) accept JSON Schema for function definitions.
get_invoice_tool = {
"name": "get_invoice",
"description": "Retrieve a single invoice by ID. Returns the invoice record or an error object if not found.",
"input_schema": {
"type": "object",
"properties": {
"invoice_id": {
"type": "string",
"description": "The invoice ID. Format: inv_ followed by alphanumeric characters.",
"pattern": "^inv_[a-zA-Z0-9]+$"
},
"include_line_items": {
"type": "boolean",
"description": "Whether to include individual line items in the response. Defaults to false.",
"default": False
}
},
"required": ["invoice_id"],
"additionalProperties": False
}
}
Design rules:
- Be specific in descriptions. The model uses the description to decide when and how to call the tool. Vague descriptions produce wrong calls. Describe the tool's purpose, inputs, and what success looks like in 1–3 sentences.
- Include format hints in descriptions. If an ID has a prefix format, say so. If a date expects ISO 8601, say so. The model will follow these hints.
- Use
additionalProperties: false. This prevents the model from inventing fields that do not exist in your schema. - Set
requiredexplicitly. Do not rely on the model knowing which fields are optional. - Prefer enums over open strings for constrained values. If a
statusfield accepts only"open","closed","pending", use an enum. This eliminates a class of invalid inputs.
Structured error contracts
The worst tool contract is one that raises an unhandled exception. The agent's executor crashes, the loop breaks, and you have no actionable information.
Better: return a typed error object the model can reason about.
from typing import TypedDict, Literal, Union
class InvoiceResult(TypedDict):
invoice_id: str
amount: float
status: str
line_items: list
class ToolError(TypedDict):
error: bool
error_code: Literal["not_found", "permission_denied", "invalid_input", "upstream_failure"]
message: str
retryable: bool
def get_invoice(invoice_id: str, include_line_items: bool = False) -> Union[InvoiceResult, ToolError]:
if not invoice_id.startswith("inv_"):
return {
"error": True,
"error_code": "invalid_input",
"message": f"Invoice ID must start with 'inv_'. Received: {invoice_id}",
"retryable": False
}
invoice = db.invoices.get(invoice_id)
if invoice is None:
return {
"error": True,
"error_code": "not_found",
"message": f"No invoice found with ID {invoice_id}",
"retryable": False
}
result = {"invoice_id": invoice.id, "amount": invoice.amount, "status": invoice.status}
if include_line_items:
result["line_items"] = invoice.line_items
return result
Error contract design rules:
- Never raise raw exceptions from tools. Catch all exceptions at the tool boundary and return an error object.
- Include
retryable. The model or executor can use this to decide whether to try again or surface the failure. - Include
error_codeas an enum string, not a freeform message. The model can reason about structured codes. It cannot reliably pattern-match error messages. - Include enough context in
messageto debug without logs. What was the input? What was expected? - Keep the shape consistent. An error from
get_invoiceand an error fromlist_ordersshould have the same envelope. The executor can handle them uniformly.
Idempotency
Production agents retry. Network calls fail. The model sometimes calls the same tool twice. If your tools are not idempotent, retries cause double-writes, duplicate charges, or inconsistent state.
Read operations are naturally idempotent. Focus your effort on write operations.
def create_invoice(
customer_id: str,
amount: float,
idempotency_key: str # Caller-supplied key — agent generates UUID per logical operation
) -> Union[InvoiceResult, ToolError]:
existing = db.invoices.find_by_idempotency_key(idempotency_key)
if existing:
# Return the existing result, same as if we created it now
return {"invoice_id": existing.id, "amount": existing.amount, "status": existing.status}
invoice = db.invoices.create(
customer_id=customer_id,
amount=amount,
idempotency_key=idempotency_key
)
return {"invoice_id": invoice.id, "amount": invoice.amount, "status": invoice.status}
Idempotency design rules:
- Include an
idempotency_keyparameter on all write tools. The agent generates a UUID at the start of each logical operation. Retries reuse the same key. - Store the key with the record. Look it up before writing.
- Return the original result on duplicate calls. Do not signal an error for idempotent duplicates — the model should not need to know.
Testing tools in isolation
Tools must be testable without running the full agent loop. If you can only test a tool inside a live agent session, you cannot iterate quickly and you cannot catch regressions.
# test_tools.py
import pytest
from app.tools.invoices import get_invoice, create_invoice
class TestGetInvoice:
def test_valid_id_returns_invoice(self, db_with_fixture):
result = get_invoice("inv_abc123")
assert result["invoice_id"] == "inv_abc123"
assert result["amount"] == 49.0
def test_invalid_id_format_returns_structured_error(self):
result = get_invoice("abc123") # Missing inv_ prefix
assert result["error"] is True
assert result["error_code"] == "invalid_input"
assert result["retryable"] is False
def test_nonexistent_id_returns_not_found(self, db_empty):
result = get_invoice("inv_missing")
assert result["error"] is True
assert result["error_code"] == "not_found"
class TestCreateInvoice:
def test_idempotent_on_duplicate_key(self, db_with_fixture):
key = "idem-key-001"
first = create_invoice("cust_1", 49.0, key)
second = create_invoice("cust_1", 49.0, key)
assert first["invoice_id"] == second["invoice_id"]
assert db_with_fixture.invoices.count() == 1 # Not duplicated
Testing rules:
- Test happy path, invalid input, not-found, and permission denied for every tool.
- Test idempotency explicitly — call the tool twice with the same key and assert the record count.
- Use the structured error envelope in assertions, not exception checks.
- Keep fixtures minimal. A tool test should need at most a handful of rows.
Versioning
Tools evolve. The model's cached schema may differ from the deployed tool. Field renames break agents silently.
# Version in the tool name when breaking changes are necessary
list_orders_v2 = {
"name": "list_orders_v2", # Explicit version suffix
"description": "...",
...
}
# Or version in a wrapper with deprecation handling
def get_customer(customer_id: str, _version: str = "v1") -> dict:
if _version == "v1":
return _get_customer_v1(customer_id)
elif _version == "v2":
return _get_customer_v2(customer_id)
else:
return {"error": True, "error_code": "invalid_input", "message": f"Unknown version: {_version}"}
Versioning rules:
- Additive changes (new optional fields) are safe. Just add them.
- Renaming fields, removing fields, or changing types requires a new version.
- Keep old versions alive until all agents using them are redeployed.
- Log which tool version each agent call used. This makes rollback decisions tractable.
Summary checklist
When designing a new tool, ask:
- Does the JSON Schema description tell the model when, why, and how to call this tool?
- Does
additionalProperties: falseprevent the model from inventing inputs? - Do all error paths return a structured error object, never a raw exception?
- Do write operations accept an idempotency key?
- Is the tool testable in isolation without a live agent session?
- Are breaking changes handled with versioning?
If all six are yes, the tool is production-ready. If any are no, you have a known failure mode waiting for load.