Most agent failures I've debugged were not model failures — they were tool-definition failures. The model picked the wrong tool, filled a parameter with a plausible-looking guess, or called something twice because the first response didn't tell it whether the action had succeeded. Tool design is API design for an audience that can't read your source code, can't set a breakpoint, and only gets one shot at inferring intent from a name and a description string. Get that contract wrong and no amount of prompting fixes it.
Fewer, coarser tools beat many narrow ones
The instinct coming from traditional API design is to expose one tool per CRUD operation: get_customer, get_customer_orders, get_customer_invoices, get_customer_balance. That's the right shape for a REST client with a compiler checking call sites. It's the wrong shape for a model choosing from a flat list of names with no type system backing it up. Every additional tool in the schema is another row the model has to disambiguate at selection time, and accuracy on tool selection degrades measurably once you're past a few dozen options — the model starts confusing similarly-named tools or picking the "closest" one instead of the correct one.
The fix is usually to collapse related fine-grained operations into one coarser tool with a mode or query parameter, doing the equivalent of a join server-side instead of asking the model to orchestrate four calls and stitch the results together itself. A single get_customer_summary tool that returns orders, balance, and contact info in one response is both easier for the model to select and cheaper in round trips than four separate lookups it has to remember to chain correctly.
Write descriptions for the model, not for your team
A tool description is the only documentation the model ever sees — it has no access to your internal wiki, your code comments, or the tribal knowledge of what "active" means in your schema. Descriptions copied from an internal API doc ("Returns the Customer entity") tell the model nothing about when to call it, what it returns on a miss, or how it differs from the three other tools with similar names. A description written for the model states the purpose, the expected inputs in plain language, what a successful result looks like, and — critically — when *not* to use it if there's a similarly named neighbor tool that could be confused for it.
If two people on your team would guess differently about which tool to call from the descriptions alone, the model will too. Run a handful of ambiguous prompts against the tool set before shipping and see which tool actually gets picked.
Designing parameters that can't be wrong
Every optional parameter combination you allow is a combination the model will eventually produce, including the invalid ones. A search tool that accepts both customer_id and customer_email as optional filters, with no guidance on what happens when both are set or neither is, will get called with both, or neither, or a fabricated ID that looks right but isn't. Prefer required parameters over optional ones when a sensible call genuinely can't omit them, use enums instead of free-text strings wherever the valid values are a closed set, and avoid parameters that mix concerns — a single status enum beats a status string plus a separate is_archived boolean that can contradict it.
The other common trap is requiring an internal ID the model has no way to have obtained — a database primary key, an internal ticket UUID, anything that was never surfaced in a prior tool response. If a tool needs an ID, either look it up by a human-readable field (email, order number, name) inside the tool itself, or make sure a prior tool call in the same conversation actually returned that ID so the model has something real to pass forward instead of inventing one.
A tool schema worth copying
Below is the shape I use for a lookup tool that resolves by human-readable identifiers instead of internal IDs, and returns a bounded result set rather than an unbounded dump — both details matter more to reliability than anything in the prompt around it.
{
"name": "find_customer_orders",
"description": "Look up recent orders for a customer, identified by email or order number (never an internal customer ID, which you will not have). Returns up to 20 most recent orders with status, total, and order date. Use this before refund_order or cancel_order to confirm the order exists and check its current status — do not guess an order number.",
"input_schema": {
"type": "object",
"properties": {
"customer_email": {
"type": "string",
"description": "Customer's email address. Provide this or order_number, not both."
},
"order_number": {
"type": "string",
"description": "Order number as shown to the customer, e.g. ORD-48213. Provide this or customer_email, not both."
},
"status_filter": {
"type": "string",
"enum": ["any", "open", "shipped", "cancelled", "refunded"],
"description": "Restrict results to orders in this status. Defaults to any."
}
},
"required": []
}
}
Note what's absent: no internal customer ID parameter, no free-text status field, and a description that tells the model the intended call order relative to other tools — not just what this one tool does in isolation.
Return errors the model can act on, not stack traces
When a tool call fails, the temptation is to pass the raw exception back — a database constraint violation, an HTTP 500 body, a null-reference message. The model has no way to act on that; it either retries blindly or apologizes to the user and gives up. A tool's error surface should be as deliberately designed as its success surface: a structured reason code plus a short human-readable explanation the model can either relay or use to pick a different action. "Order ORD-48213 not found — check the order number" lets the model ask the user to double-check. "System.NullReferenceException at line 214" does not.
A rate limit or timeout is worth a retry; a validation error on a malformed order number is not. If every failure looks the same to the model, it either retries things that will never succeed or gives up on things that would have worked a second later.
Good vs. bad tool design, side by side
| Concern | Bad pattern | Better pattern |
|---|---|---|
| Tool count | One tool per DB table/endpoint | One tool per task the model actually needs to do |
| Description | Copied from internal API docs | States purpose, inputs, and when not to use it |
| Identifiers | Requires an internal ID the model never saw | Accepts human-readable lookup fields |
| Parameters | Free-text fields for closed value sets | Enums with explicit valid values |
| Errors | Raw exception text | Structured reason code + actionable message |
Wrapping up
Good tool design for an LLM agent looks a lot like good API design for a junior developer with no access to your codebase: name things plainly, document intent rather than mechanics, make invalid states unrepresentable in the schema, and fail loudly with something actionable. The difference is that the model reads the whole tool list every single call, so bloat and ambiguity cost you on every turn, not just at integration time. Trim the tool set before you tune the prompt — it's usually the higher-leverage fix.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.