When an agent misbehaves, everyone stares at the prompt.
Add another "IMPORTANT:", beg the model in capital letters, ship it, and hope.
Most of those bugs aren't prompt bugs. They're tool bugs. A fuzzy description, two tools that overlap, an error message the model can't act on.
Here's the mental model that changed how I build these:
A tool definition is an API you publish for a client that guesses.
A human developer reads your docs and checks the types. The model reads the name, the description, and the schema, then infers the rest. It never sees your source. So the contract does all the work, and every ambiguity in it becomes behavior.
The model decides. Your code executes.
Don't ask it to perform a change; ask for a small structured decision and let deterministic code apply it.
Share this post & I’ll send you some rewards for the referrals.
Run Your Auth With AI Agents (Partner)
Coding agents are here to stay, but vibe-coding auth is dangerous business. Connect your AI agents to the Descope MCP server instead!
This remote MCP server gives agents the ability to read documentation, manage users and tenants, configure auth flows, generate FGA schemas, review audit logs, and more…all through natural language.
(Thanks to Descope for partnering on this post.)
1. One tool, one job
The tempting design is the multiplexer: manage_expense(action: "create" | "update" | "submit" | "delete", ...).
It feels tidy. You've stacked three decisions, though.
The model picks the tool, then picks the action, then works out which of the umbrella's parameters apply to this action. Every layer is a place to guess wrong, and the schema, necessarily the union of all modes, can no longer say what's actually required.
Four sharp tools, each with exactly the parameters that mode needs, turn a three-step guess into one clean selection.
Same reason your REST API doesn't have a single /do endpoint with an action field.
2. The description is a prompt, not documentation
Most tool descriptions read like docstrings: "Fetches expense data".
Technically true, behaviorally useless. The model uses descriptions to choose, and that sentence doesn't say when this tool wins over its neighbors.
Write the description as routing guidance:
"Get the full details of ONE expense when you already have its ID (from list_expenses or the user). Do NOT use this to search. Use search_expenses for finding expenses by description, date, or amount."
Three things in there earn their tokens.
What it does, when to reach for it, and when not to, with a pointer to the right alternative.
That negative space is the highest-leverage sentence in tool design, because overlapping tools are where selection accuracy goes to die.
If you can't write the "do not" line without contradicting another tool's, the tools overlap, and one of them should change.
3. Type the parameters like you mean it, then show one call
The parameter schema isn't decoration.
Set strict: true on the tool definition, and the API enforces it, so the model can't hand you a value the schema forbids. That makes the schema the strongest instruction you have. Loose schemas leak guesses:
from datetime import date
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, PositiveInt
# ⛔ An invitation to improvise
class LooseParams(BaseModel):
status: str # model sends "Approved", "APPROVED", "approved ✅"
amount: str # "100", "$100", "one hundred"
expense_date: str # "tomorrow"
# ✅ The schema does the instructing
class StrictParams(BaseModel):
model_config = ConfigDict(extra="forbid") # additionalProperties: false, which strict mode requires
status: Literal["pending", "approved", "rejected"]
amount_cents: PositiveInt # the > 0 bound isn't in the strict subset; the SDK checks it client-side
expense_date: date = Field(description="Posting date, e.g. 2026-07-02") # emits format "date", which the API enforcesAn enum isn't just validation. The model sees the allowed values and picks from them instead of inventing, so correction moves from runtime (parse, fail, retry, pay tokens) to selection time.
Then add the part a schema can't express. It can say labels is an array of strings; it can't say these two fields travel together, or that your IDs look like USR-12345, or that parent_id only makes sense for sub-tickets. That's where calls go wrong on complex inputs, and it's why tool definitions take an input_examples field. Anthropic measured it: one or two sample calls took accuracy on complex parameter handling from 72% to 90%. For a few lines of JSON, that's the cheapest reliability you'll ever buy.
4. Return errors the model can act on
Here's the asymmetry teams miss.
The tool's return value is part of the conversation too, and the model reasons over it next. A raw stack trace, a bare 500, or {"error": true} is a dead end. The model can't fix what it can't interpret, so it flails. It retries the same call, hallucinates an apology, or worse, pretends the call worked.
Write error returns for the caller who guesses:
{
"error": "expense_not_found",
"message": "No expense with ID 'exp_9x2'. Expense IDs come from list_expenses; call it first if you don't have a current ID.",
"retryable": false
}A typed code (so your deterministic layer can count failures), a human explanation of what to do differently, and a retry hint.
We've fixed entire misbehavior classes by rewriting error messages, with zero prompt changes and zero model changes. The error message is prompt engineering. It just arrives late.
Parallel calls have their own trap. When the model fires three tools in one turn, all three results go back in a single message, and the tool that failed still needs a result block flagged as an error. Drop it, or split the results across messages, and you quietly teach the model to stop calling tools in parallel at all.
5. Bound every output
A tool that returns an unbounded list is a context-window bomb.
list_expenses happily returns 4,000 rows, that JSON lands in the conversation, and the actual instructions drown in table data. Or you blow past the window and silently truncate something that mattered (failure #5 from the 7 things list).
So every tool return gets a budget. Default limits (limit: 20 unless asked), pagination cursors so the model asks for more deliberately, and summarized shapes.
The model rarely needs every field of every row. It needs enough to pick the next step, and it can fetch one record in full when it commits.
6. Make destructive tools safe to fumble
The model will eventually call a destructive tool at the wrong moment.
It'll double-fire a payment on a retry, or delete a record off a hallucinated ID. Design for the fumble instead of hoping against it:
Idempotency for the double-fire. An idempotency key on execute-style tools makes the retry harmless, the same mechanism that keeps payment APIs safe to retry.
Two-step commit for the big red buttons.
prepare_refundreturns a summary and a confirmation token;execute_refund(token)moves the money. Make that token single-use and short-lived, or the replay you built this to prevent walks right back in. The prepare step also gives a human a checkpoint while everything is still reversible.Validate referenced IDs exist before acting. An ID in a tool call is a claim until you've checked it.
7. Fewer tools beat more tools
Every tool you register is another candidate in every selection, and it costs tokens on every call.
Anthropic's own example is five MCP servers, 58 tools, about 55K tokens of definitions before any work begins. The accuracy cost is measurable too. On the same benchmark, letting the model search for tools instead of loading them all took one model from 49% to 74%, and a newer one from 79.5% to 88.1%.
So audit like you'd audit dependencies. Merge near-duplicates, delete the tool nobody's flow needs, move rare admin operations to a separate agent. Then mark the long tail defer_loading: true and register a tool-search tool, so the model sees the handful that matter this turn and pulls in the rest on demand.
Don't defer everything, though; the search tool itself has to stay loaded or the request errors out.
One cost detail before you touch that list. Tool definitions sit at the very front of the prompt, ahead of the system prompt and the conversation, so editing them mid-conversation throws away your whole cached prefix. Tool search dodges that by appending what it finds instead of swapping the list.
When the active list still can't shrink, the domain is genuinely that wide, and that's not a tool problem anymore. It's the signal your agent wants to be several agents, each with a short list again.
Conclusion
Prompt engineering gets the conference talks, but tool design is where agent reliability lives. The contract is enforced, read on every call, and immune to the model's mood.
None of it is new. It's API design for the strangest client you've ever had, one that reads the contract and guesses the rest. Write it as the guessing depends on it. It does.
📌 TL;DR
One tool, one job. A
manage_expense(action, ...)swiss-army tool turns one selection into three guesses.The description is a prompt. Say when to use it, and when not to, with a pointer to the right alternative.
Type the parameters like you mean it, then add one example call. Enums plus strict mode make bad calls impossible; Anthropic measured examples taking complex parameter handling from 72% to 90%.
Return errors the model can act on. "Expense exp_123 not found; IDs come from list_expenses" beats a stack trace.
Bound every output. A tool that can return 10,000 rows can eat the conversation.
Make destructive tools safe to fumble. Idempotent where possible, single-use confirmation token where not.
Fewer tools beat more tools. 58 tools across five MCP servers burn ~55K tokens before any work starts.
Follow me on LinkedIn | Twitter(X) | Threads
Thank you for supporting this newsletter.
Consider sharing this post with your friends and get rewards.
You are the best! 🙏




