Every other pattern in this series guards the boundary around a tool. This one is
about the tool itself, because a badly designed tool cannot be governed into being
useful. No before_tool hook fixes a function that returns 40,000 tokens, and no
system prompt fixes an error message that says 400.
Here is what a tool layer looks like when it starts as a thin wrapper over an existing REST API — which is how nearly all of them start:
get_data(id, status, type, format, page, sort, filter) -> str # "Gets data"
list_invoices(customer) -> str # returns all 40 rows
get_invoice(id) -> str
get_invoice_details(id) -> str
Run the auditor over that and you get 30 findings, 6 of them errors.
TL;DR
Four mistakes, in order of what they cost you:
- Unbounded responses. The single most expensive mistake in agent engineering, and invisible in code review because the function looks fine. Measured on 40 rows of a realistic API payload: ~3,530 tokens raw, ~350 shaped to three fields, ~201 shaped and paginated. 17× cheaper for the same answer.
- Errors that are not instructions.
Error: 400teaches the model nothing, so it tries variations until your budget guard stops it.Unknown status 'pending'. Use one of: open, paid, overdue.is repaired on the next turn. - Free-form parameters where a closed set exists.
status: strinvites"in progress","In-Progress","WIP". Anenummakes the wrong call unrepresentable rather than merely discouraged — the same principle as fail-closed policy, applied to arguments. - Fragmented tools forcing multi-call choreography.
listthengetthenget_detailsis three round trips and three chances to lose the thread.
Runnable code:
patterns/harness/tool_design/
Run it like a linter
uv run python -m patterns.harness.tool_design.demo
=== BEFORE: a thin wrapper over the REST API ===
30 finding(s), 6 error(s):
[error] get_data: thin-description — 9 characters. The description is the only
thing telling the model when to reach for this...
[warn] get_data: unconstrained-enum — 'status' is a free-form string but almost
certainly has a closed set of values...
[error] get_data: unbounded-response — no declared bound on response size...
[warn] get_invoice, get_invoice_details, list_invoices: fragmented-tools —
3 separate read tools for 'invoice'...
=== AFTER: tools designed for a model to use ===
No findings.
=== what unbounded, unshaped responses actually cost ===
raw API payload, 40 rows: ~ 3,530 tokens
shaped to 3 fields: ~ 350 tokens (10x cheaper)
shaped + first page of 20: ~ 201 tokens (17x cheaper)
The pattern
from patterns.harness.tool_design.pattern import audit, report
print(report(my_registry)) # run it in CI, like a linter
And the helpers for writing tools that pass:
@registry.tool(
"Search invoices for a customer, optionally filtered by status. Returns up to "
"20 invoices with id, total and status, newest first, plus a cursor for the "
"next page. Use this instead of listing everything.",
bounded_response=True, actionable_errors=True, schema_documented=True,
)
def search_invoices(customer_id: str, status: str = "open", offset: int = 0) -> str:
if status not in ("open", "paid", "overdue"):
return actionable_error(f"Unknown status {status!r}.",
"Use one of: open, paid, overdue.",
example="status='overdue'")
page = paginate(matching, offset=offset, limit=20)
...
Note what the good description does that the bad one does not: it says what comes back and when to prefer this tool. The model cannot plan the next step without knowing the shape of the result, so if you do not tell it, it calls the tool to find out — and you pay for that turn.
When to use it
- Before shipping any new tool. This is a five-second CI check, not a project.
- When adopting third-party tools or MCP servers you did not write — the audit is a fast read on whether anyone thought about the model, and on where your context budget is about to go (OWASP ASI04).
- When an agent is inexplicably expensive or unreliable. Nine times out of ten it is one tool returning too much, and the audit finds it faster than reading traces.
When NOT to use it
- Do not treat findings as a gate to satisfy mechanically.
bounded_responseis a flag you set; setting it without adding pagination is lying to a linter. The audit is a prompt for a conversation, not a compliance artefact. - Do not over-apply the consolidation rule. Some tools are separate because they have
genuinely different risk profiles.
read_invoiceandvoid_invoiceshould never merge no matter how much choreography it saves, because the privilege broker needs to police them separately. - Do not chase the parameter-count rule into a single
params: objectcatch-all. Six well-named parameters beat one opaque blob the model has to guess the shape of. The finding means “this tool does too much,” not “hide the arguments.” - Skip it for prototypes. A three-tool proof-of-concept does not need a tool-design review, and pretending otherwise is how linters get disabled.
Trade-offs and failure modes
bounded_responseandactionable_errorsare declarations, not verifications. The auditor cannot execute your tool to check that it paginates. That is the honest limitation: these flags encode a reviewed promise. Pair with a test that calls the tool and asserts a size ceiling.- Static analysis cannot see the important thing. Whether a description helps a specific model choose correctly is an empirical question. Tool descriptions are prompts, which means they need evals, not just a linter. The audit catches the obvious floor.
- Shaping loses fields someone will eventually need. Get it wrong and the agent makes an extra call for the missing field, which costs more than including it. Measure the follow-up call rate.
- Pagination invites loops. A model that keeps requesting the next page is the runaway from cost budgeting. Bound total pages per run and make the last-page message unambiguous.
- Fifteen tools is an arbitrary threshold. Selection accuracy degrades with toolset size, but where depends on the model and how distinct the tools are. Treat it as a prompt to split by agent role.
- Consolidation moves complexity into the tool. One
search_invoiceswith a rich filter is easier for the model and harder for you to test. That is usually the right trade, and it is a trade.
Frequently asked questions
How long should a tool description be?
Long enough to say what it does, when to prefer it over alternatives, and what it returns — typically two to three sentences. The failure mode is not verbosity, it is omission: a nine-character description like “Gets data” is the actual problem.
Should I return JSON or prose from a tool?
Shaped lines, usually. Raw JSON spends tokens on braces, nulls, and nested objects nobody will use — that is most of the 10× in the measurement above. Return the fields the agent needs, compactly, and keep JSON for when the structure genuinely matters.
How does this relate to MCP?
Directly: an MCP server is a tool layer someone else wrote, and the audit is how you find out what it will cost you before you mount it. The fragmentation and unbounded- response findings are extremely common in third-party servers.
Does this matter less with cheaper models or bigger windows?
It matters differently. Cheaper tokens reduce the cost penalty but not the reliability penalty — a model given 40,000 tokens to answer a three-row question is more likely to lose the thread, whatever the price per token.
References
- Anthropic, Writing effective tools for agents — descriptions as prompts, response shaping, consolidating tools around tasks
- Anthropic, Building Effective Agents — the agent–computer interface deserves most of your design attention
- OpenAI, A Practical Guide to Building Agents — tool definitions and structured parameters
- OWASP GenAI Security Project, Top 10 for Agentic Applications 2026 — ASI02, ASI04
- Runnable code and tests:
patterns/harness/tool_design/
Part of the agent harness and governance series. Next: agent evaluations — why scoring the final answer passes an agent that leaked your credentials.