Tool Design for LLM Agents: APIs for a Consumer That Can't Read the Docs
Your tool's description is its documentation, its type system, and its onboarding.
When you design an API for humans, you get to assume a lot. The developer will read the docs. They'll hit an error, search Stack Overflow, and learn. They'll remember next time. They'll notice that sort takes "asc" | "desc" and not pass "ascending" twice.
None of that holds for a language model. Your only consumer reads the schema once per call, has no memory across sessions, cannot look anything up, and — critically — will produce a plausible guess rather than an error when the schema is ambiguous.
Tool design under those constraints is a real discipline, and it's the highest-leverage part of agent work I know. Fixing a badly designed tool routinely does more than fixing the prompt that calls it.
The description is the whole contract
A tool definition is a name, a description, and a parameter schema. That's the entire surface the model has. There's no README, no examples directory, no colleague to ask.
Which means the description isn't documentation about the tool. It's the interface. Treat it with the seriousness you'd give a public API signature.
A weak description:
{
"name": "search",
"description": "Search for items",
"parameters": { "query": { "type": "string" } }
}
The model has to infer what's searchable, what syntax the query accepts, what comes back, and when to use this instead of some other tool. Each inference is a coin flip.
A description that works:
{
"name": "search_customer_records",
"description": "Search customer records by name, email, or company. Returns up to 20 matches with id, name, email, company, and signup date — not billing history or support tickets. Plain text only; no boolean operators or wildcards. Use get_customer with an id for full details on one record.",
"parameters": {
"query": {
"type": "string",
"description": "Plain text. Examples: '[email protected]', 'Acme Corp', 'Jane Doe'"
}
}
}
The difference is four things, and they're the checklist I'd apply to every tool:
- What it does, specifically enough to distinguish it from neighbors
- What it returns, including the shape and the cap
- What it does not do — the negative space is where hallucinated usage lives
- What to use instead when this isn't the right tool
That third point is underrated. "Returns customer records, not billing history" prevents an entire class of failure where the model calls your tool, gets a result that lacks what it needed, and then fabricates the missing field because the tool "should have" had it.
Name tools for the decision, not the implementation
The model picks tools by semantic match between the request and the name. So names have to carry meaning at the point of selection.
execute_query tells the model nothing about when to use it. search_customer_records tells it everything. process is worse than useless — it matches everything weakly.
Some rules that hold up:
- Verb plus specific noun.
create_invoice, notinvoiceorhandle_invoice. - Distinct prefixes for distinct domains.
calendar_*,email_*,file_*. Namespacing helps selection more than you'd expect when the tool count grows. - Never rely on parameters to disambiguate two names. If
get_data(type: "user" | "order")exists, you have two tools wearing a trench coat. Split them. The model chooses names far more reliably than it chooses enum values.
That last one is the most common structural mistake. A generic tool with a mode parameter is convenient for you and hostile to the model — you've moved the decision from the place it's good at (name matching) to the place it's mediocre at (filling in a field it half-understands).
Granularity: one action, one tool
The pull toward mega-tools is strong. Fewer definitions, less schema, less context spent. manage_calendar(action, event_id?, title?, start?, end?, attendees?, ...) handles everything.
It also fails constantly, because the model has to get the action right and the conditional parameter set right and know which parameters apply to which action — from a flat schema that can't express the dependency.
Split it:
create_event(title, start, end, attendees?)
update_event(event_id, title?, start?, end?)
cancel_event(event_id, notify_attendees)
list_events(start_date, end_date)
Each has a schema that's fully valid on its own. There are no fields that matter only sometimes. The model can't pass attendees to a cancel operation because the parameter doesn't exist there.
The counter-pressure is real: every tool definition costs context on every call, and 60 tools is genuinely worse than 15. But the answer to too many tools isn't merging them into modal monsters — it's progressive disclosure. Expose the tools relevant to the current phase, and let a search-and-load tool pull in others on demand. Scope beats compression.
The line I'd draw: split when the parameter sets differ, merge when they're identical and only the target changes.
Errors are your highest-bandwidth teaching channel
The model has no memory of past mistakes. The only correction it ever gets is the error message you return, in that context, in that moment. This is the single most under-invested surface in tool design.
Error: Invalid date format
The model doesn't know what format is valid. It has one piece of information — the thing it tried is wrong — and roughly infinite alternatives. It will guess, often the same wrong way.
Invalid date format: "next Tuesday"
Expected ISO 8601 date: YYYY-MM-DD (example: 2026-08-04)
Today is 2026-07-26, so next Tuesday is 2026-08-04.
That's a correction the model can act on in one turn. It costs you thirty tokens and saves several iterations plus a possible loop failure.
The pattern generalizes:
- State what was received, literally. The model needs to see its own bad input.
- State the constraint, with a concrete valid example. Not "must be valid" — show one.
- Compute the fix when you can. If your validator can resolve the intent, offer the resolution. You have the domain knowledge; the model doesn't.
- Distinguish "you did it wrong" from "the system is broken." A malformed argument should be corrected and retried. A downstream outage should not be — that's a fatal the loop needs to handle, and dressing it up as a validation error sends the agent into a retry spiral.
A related trap: never return 401 or 403 semantics for a transient condition. A permission-shaped error tells the agent its credential is bad, and a well-built agent responds by dropping privileges or exiting. Serve that during an outage and you've told every running agent to give up at once. Status codes are load-bearing when your consumer acts on them literally.
Shape the response for reasoning, not for parsing
Human-facing APIs return complete data because the client will pick what it needs. Agent-facing tools return into a context window, where every field costs budget and every irrelevant field is a distraction the model might latch onto.
Raw pass-through is the failure mode:
{ "id": "cus_9d8f7", "object": "customer", "created": 1753488000,
"livemode": false, "metadata": {}, "preferred_locales": [],
"invoice_settings": { "custom_fields": null, "footer": null },
"tax_exempt": "none", "test_clock": null, ... }
Forty fields, three of which matter. Shape it:
{ "id": "cus_9d8f7", "name": "Jane Doe", "email": "[email protected]",
"plan": "pro", "status": "active", "signed_up": "2026-07-26" }
Beyond trimming, three habits:
Return what the model would otherwise have to compute. If a follow-up call needs customer_id, include customer_id, not just a nested object the model has to remember to traverse. If the next reasonable step is bounded by a count, include the count.
Make truncation explicit and actionable. "showing 20 of 847 matches — narrow the query or use list_customers with pagination" is far better than silently returning 20 and letting the model conclude that's all of them. Silent truncation produces confidently wrong summaries.
Prefer stable, human-readable values. "status": "active" reasons better than "status": 2. The model has priors about English words and none about your enum encoding.
Idempotency and the retry problem
Agents retry. The loop retries on transient errors, the model retries when confused, and a network timeout means neither of you knows whether the call landed.
For any tool with side effects, that has to be safe:
- Accept an idempotency key on writes, and dedupe on it server-side.
- Return the current state, not just success.
{ "status": "already_sent", "sent_at": "..." }tells a retrying agent exactly what happened without it needing to check. - Make destructive operations require a specific identifier, never a filter.
delete_records(filter)is a tool that will eventually delete something it shouldn't.delete_record(id)can only ever be wrong once.
And separately from idempotency: consequential tools should be gated in code, not by instruction. "Only send emails the user approved" in a description is a hope. A permission check in the harness is a guarantee.
The test that tells you the truth
Give a competent engineer only your tool definitions — no source, no docs, no conversation — and a task. Watch what they do.
Where they hesitate, the model will guess. Where they ask "does this include X?", the model will assume yes. Where they pick the wrong tool, the model will pick it more often.
Every ambiguity a human notices is one the model has already been silently failing on.
Then run the version of that test that scales: log every tool call your agent makes in production, and sort by which ones get retried, corrected, or abandoned. Retry rate per tool is the cleanest quality signal in the whole system. The tool at the top of that list is your next fix, and it will be a bigger win than another pass at the prompt.
"Your tool's description is its documentation, its type system, and its onboarding."
Notes from the build
More from the AI engineering trenches
Evals, observability, prompt design, and real lessons from shipping AI products. No fluff.
By subscribing, you agree to receive Orlando's emails. No spam. Unsubscribe anytime.