Tool design for agents: the interface is the product
Names, schemas, error messages the model can act on, pagination, idempotency keys and confirmation for side effects, with runnable examples of a bad tool, a good one and an idempotent write.
When an AI agent fails, teams reach for the prompt. Often the fault is the tool: a vague name, a parameter called q, an error that says 400, a search that returns 4,000 rows into the context. Anthropic's “Building effective agents” (2024) names this the agent–computer interface and argues it deserves the same care as a human UI. The model reads your tool definitions the way a new engineer reads an API reference, except it reads them on every call and cannot ask a colleague.
Names and descriptions
- Namespace by service and resource:
tickets_search,tickets_create,orders_get. A host may load tools from several servers;searchwill collide and the model will pick the wrong one. - Describe what it returns and when not to use it. “Do not use for order status: call orders_get instead” prevents the most common misroute in one sentence.
- Name parameters like a careful engineer would:
query,customer_email,limit, neverq,e,n. Put an example value in each description. - Constrain with the schema, not prose:
enumfor statuses,minimum/maximumfor limits,required, andadditionalProperties: false. Providers with strict tool modes (structured decoding) enforce it at generation time. - Return names, not ids alone. The 2025 Anthropic tools post reports fewer hallucinated follow-up calls when tools return readable fields alongside UUIDs.
Bad tool
- name: search
- description: “Searches.”
- params q, n: no types or limits
- returns everything
- errors: 400 / stack trace
- writes on first call
Good tool
- name: tickets_search
- says returns + when not to use
- enum, min/max, required
- limit + next_cursor
- errors say how to fix
- writes need key + confirm
Errors the model can act on
A tool error is a prompt for the next step. Write it as one: say what was wrong, what is allowed, and an example of a valid call. “status 'opened' is not valid. Use one of: open, pending, closed, or omit it” gets a correct retry on the next turn. ValidationError at line 88 gets a guess. Return errors as tool results (MCP's isError result), not as transport failures, so the model sees them.
Pagination and response size
Every list or search tool needs a default limit, a hard maximum, and an opaque next_cursor. Return total so the model can decide whether to narrow the query instead of paging. When you truncate, say so and suggest a narrower call. Claude Code caps tool responses at 25,000 tokens by default (per Anthropic's 2025 tools post); your own budget should be far smaller, because every row returned is re-read on every later turn of the loop.
The runnable example
The script defines the bad and good versions of a support-ticket search, validates arguments with actionable errors, pages with a cursor, and implements an idempotent, confirmation-gated create. Stdlib only; run it with python3 -I tools.py.
"""Bad vs good tool definitions, actionable errors, cursor pagination, idempotency, confirmation."""
import hashlib
import json
# --- Two definitions of the same capability, as a model would see them ---------------------
BAD_TOOL = {
"name": "search",
"description": "Searches.",
"input_schema": {"type": "object", "properties": {"q": {"type": "string"}, "n": {"type": "integer"}}},
}
GOOD_TOOL = {
"name": "tickets_search",
"description": (
"Search support tickets by free text. Use for questions about past customer issues. "
"Returns at most limit tickets plus next_cursor; pass it back to get the next page. "
"Do not use for order status: call orders_get instead."
),
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Words to match, e.g. 'login loop after password reset'."},
"status": {"type": "string", "enum": ["open", "pending", "closed"], "description": "Optional filter."},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
"cursor": {"type": "string", "description": "Opaque value from a previous next_cursor."},
},
"required": ["query"],
"additionalProperties": False,
},
}
TICKETS = [{"id": f"T-{1000 + i}", "title": f"login loop case {i}", "status": "open" if i % 3 else "closed"}
for i in range(23)]
def tickets_search(args):
schema = GOOD_TOOL["input_schema"]
unknown = set(args) - set(schema["properties"])
if unknown:
return {"error": f"Unknown argument(s) {sorted(unknown)}. Allowed: {sorted(schema['properties'])}."}
if not str(args.get("query", "")).strip():
return {"error": "query is required, e.g. {\"query\": \"password reset email missing\"}."}
status = args.get("status")
if status is not None and status not in ("open", "pending", "closed"):
return {"error": f"status '{status}' is not valid. Use one of: open, pending, closed, or omit it."}
limit = args.get("limit", 10)
if not 1 <= limit <= 50:
return {"error": f"limit {limit} is out of range 1-50. Use a smaller page and follow next_cursor."}
hits = [t for t in TICKETS if args["query"].split()[0] in t["title"] and (status is None or t["status"] == status)]
start = int(args.get("cursor") or 0)
page = hits[start:start + limit]
nxt = start + limit if start + limit < len(hits) else None
return {"tickets": page, "total": len(hits), "next_cursor": None if nxt is None else str(nxt)}
# --- An idempotent side-effecting tool with a confirmation step ------------------------------
CREATED = {} # idempotency_key -> (args fingerprint, result); a durable table in production
def fingerprint(args):
body = {k: v for k, v in args.items() if k not in ("idempotency_key", "confirm")}
return hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:12]
def tickets_create(args):
key = args.get("idempotency_key")
if not key:
return {"error": "idempotency_key is required. Generate one per intended ticket and reuse it on retry."}
fp = fingerprint(args)
if key in CREATED:
old_fp, result = CREATED[key]
if old_fp != fp:
return {"error": "idempotency_key was already used with different arguments. Use a new key for a new ticket."}
return {**result, "replayed": True}
if not args.get("confirm"):
return {"needs_confirmation": True,
"preview": f"Create ticket '{args.get('title')}' for {args.get('customer_email')}",
"how_to_proceed": "Show the preview to the user; call again with confirm=true if they approve."}
result = {"id": f"T-{2000 + len(CREATED)}", "status": "open"}
CREATED[key] = (fp, result)
return {**result, "replayed": False}
if __name__ == "__main__":
print("bad:", json.dumps(BAD_TOOL)[:60], "...")
print(tickets_search({"q": "login"}))
print(tickets_search({"query": "login", "status": "opened"}))
p1 = tickets_search({"query": "login", "status": "open", "limit": 5})
print([t["id"] for t in p1["tickets"]], "total", p1["total"], "next", p1["next_cursor"])
print(tickets_search({"query": "login", "status": "open", "limit": 5, "cursor": p1["next_cursor"]})["next_cursor"])
req = {"title": "Login loop on SSO", "customer_email": "a@example.com", "idempotency_key": "k-7f3a"}
print(tickets_create(req))
print(tickets_create({**req, "confirm": True}))
print(tickets_create({**req, "confirm": True})) # network retry: same ticket, no duplicate
print(tickets_create({**req, "title": "Other", "confirm": True}))bad: {"name": "search", "description": "Searches.", "input_schema ...
{'error': "Unknown argument(s) ['q']. Allowed: ['cursor', 'limit', 'query', 'status']."}
{'error': "status 'opened' is not valid. Use one of: open, pending, closed, or omit it."}
['T-1001', 'T-1002', 'T-1004', 'T-1005', 'T-1007'] total 15 next 5
10
{'needs_confirmation': True, 'preview': "Create ticket 'Login loop on SSO' for a@example.com", 'how_to_proceed': 'Show the preview to the user; call again with confirm=true if they approve.'}
{'id': 'T-2000', 'status': 'open', 'replayed': False}
{'id': 'T-2000', 'status': 'open', 'replayed': True}
{'error': 'idempotency_key was already used with different arguments. Use a new key for a new ticket.'}Idempotency keys for every write
Agents retry. The host retries on timeout, the model retries when it is unsure the first call worked, and a durable workflow replays steps after a crash. Without idempotency, each retry creates another ticket, another email, another charge. The pattern is the one payment APIs such as Stripe have used for years:
- The caller generates a key per intended action and reuses it on every retry of that action.
- The server stores key → (hash of arguments, result). A repeat with the same arguments returns the stored result and creates nothing.
- A repeat with different arguments is an error, which catches the model reusing a key for a new action.
- Store keys durably with a TTL (24 hours is a common window) in the same transaction as the write, not in process memory as the toy does.
Confirmation for side effects
Split reads from writes into separate tools, so the host can auto-approve reads and gate writes. For a write, return a preview first (needs_confirmation plus a human-readable summary) and execute only on an explicit second call. In the toy, confirm=true is an argument the model sets, which is fine for catching the model's own mistakes but is not a security control: a prompt-injected model will happily set it. Real confirmation belongs to the host, as human approval the model cannot forge, and should be required for anything irreversible or external: sending messages, moving money, deleting data. Size the gate by blast radius, not by how confident the model sounds.
Key takeaways
- The agent–computer interface is a product surface: names, schemas and descriptions decide most tool-call quality.
- Namespace tools, constrain inputs with the schema, and say what each tool returns and when not to use it.
- Errors are instructions for the next call: what was wrong, what is allowed, an example.
- Every list tool pages with a default limit and a cursor; every write takes an idempotency key bound to its arguments.
- Separate reads from writes and put real confirmation in the host, sized by blast radius.
Sources
- Building effective agents — Anthropic, 2024blog
- Writing effective tools for agents — with agents — Anthropic, 2025blog
- Idempotent requests — Stripe, 2026docs
- Model Context Protocol specification — Model Context Protocol, 2026spec