
I spend a lot of my week on both ends of this. I build services that an LLM drives, including a Go on-call service that pulls source, runs root-cause analysis against it and posts to chat. And I spend most days driving coding agents against other people’s tools over MCP.
The two sides taught me the same thing from opposite directions: a tool built for a human is a bad tool for an agent, and the reason is not intelligence. It is that the agent cannot leave the conversation to go and find out.
A human reads the docs. An agent reads your response.
A person who gets 400 Bad Request: invalid parameter opens your documentation, finds the parameter, and fixes the call. Total cost: one round trip and some annoyance.
An agent has your response bytes and nothing else. It cannot open a browser tab. So when the response does not say which parameter was invalid or what a valid value looks like, the agent does the only thing available to it: it changes something and tries again. Then it changes something else. Each attempt costs a round trip, tokens, and a slice of the context window that it needed for the actual task.
Compare two errors for the same mistake:
invalid request
limit cannot exceed 10
The second one names the field and states the bound. It ends the loop on the next call. The first one is an invitation to guess, and the agent will accept.
That is the whole shift in mindset. For an agent, your error message is not diagnostics. It is control flow. It is the instruction that decides what happens next, and vagueness in it is not a cosmetic problem, it is a bug with a token cost attached.
Say what to do, not only what went wrong
The strongest version of this goes past naming the problem and names the next action.
A cursor expired. 410 Gone is technically correct and useless. “This cursor has expired; restart the listing without a cursor” tells the agent exactly what to do and costs you one sentence.
A tool was called before authentication. “Not authorized” sends the agent hunting. “Not authorized; call auth_status first and retry” resolves it in one hop.
A required field was missing in a multi-step flow. Say which step supplies it.
None of this is clever. It is just writing the response for a caller who has no other source of truth, which is a caller you may not have had before.
Agents retry harder than people do
The second thing that changes is write safety, and it changes because agent retries are cheap and frequent.
A human whose request times out will usually check whether it landed before trying again. An agent will not. It will see no response, assume failure, and call again. So any tool that mutates state needs an identity for the operation that survives being called twice.
The instinct is to generate that key yourself on receipt. That produces a different key per attempt, which is exactly no protection. The key has to come from something stable about the request, ideally something the caller already committed to, and the write has to be an upsert on it. I have written about this from the webhook side, where the sender’s own retry semantics force the same conclusion, and the general form holds: when you need an identity for an operation, look for one the upstream has already fixed before you invent your own.
Response size is a correctness property

This is the one I did not expect, and it is the one I hit most often as a consumer.
A tool that returns five thousand rows has not been thorough. It has denied service to the thing that called it. The context window is the agent’s entire working memory, and a response that fills it destroys the task the agent was doing when it made the call. Being complete and being usable are in direct tension, and completeness loses.
So: cap the response, page it, and this part matters most, say out loud what you left out. A truncated list with no marker reads as the whole list. The agent will confidently conclude there are seven results when there are seven hundred, and every downstream step inherits that error. A single line saying “showing 10 of 743, pass this cursor to continue” turns a silent wrong answer into a correct partial one.
The same logic applies to shape, not just length. Return the fields needed for the decision the tool exists to support. Every extra nested object is context the agent spends and does not get back.
The loop I run on the other side
I also drive an agent, which is where the symmetry gets uncomfortable.
The code generator on the platform I work on runs a model through a tool-calling loop: the model proposes a set of Go files, my service compiles and runs them for real in a sandbox, and the result goes back as a tool message for the next turn. I wrote that loop up separately, including why the compile gate has no retry cap while the run gate caps at five: https://tech.indra.codes/posts/the-model-wrote-the-code-the-build-gate-was-the-product/
Three details from it belong here, because they are the rules above pointed the other way.
Every tool result carries the model’s own call ID. The tool message I append echoes tool_call_id from the request that asked for it. That is upstream-supplied identity again. The model already decided what to call this, and correlating on anything I generated myself would break the moment two tool calls come back in the same turn.
An unknown tool name is a hard error, not a shrug. If the model asks for a function I do not have, the loop stops and puts the name in the message. Silently dropping it would leave the model waiting for a result that is never coming, and it would spend its remaining turns trying to work out why.
And the branch I care about most:
if choice.FinishReason == "length" {
return parsed, fmt.Errorf("AI response truncated by max_tokens")
}
Four lines, guarding against exactly the failure in the section above. When a model runs out of output budget mid-answer, what comes back still parses. The envelope is there, the fields are there, and one file’s content simply stops. Falling through and returning it would hand my caller something indistinguishable from a complete answer, so the loop refuses and says which limit it hit.
That is the same rule as capping a tool response and declaring the cap, applied to myself. I will not accept a silently truncated answer from a model. I should not hand one to an agent either.
There is a cap on the loop as a whole too, and it exists for a different reason: not correctness, but a model that will not stop calling tools. Twenty-five rounds, then the loop gives up with the count in the error. A loop without an exit is not autonomous, it is a hang.
A tool is an interface for a caller that cannot ask a question
That is the sentence I would put above all of it.
Every convention we rely on for human-facing APIs assumes an out-of-band channel: documentation, a changelog, a support engineer, a colleague who has hit this before. Agents have none of those. Whatever they need in order to proceed correctly has to be inside the response, or it does not exist.
Which is a strange amount of leverage, because it means the quality of an agent integration is mostly determined by how you write a few dozen strings.
What I would tell someone building this
Write errors as instructions. Name the field, state the constraint, and say what to call next. You are not describing a failure to a log reader, you are steering the next tool call.
Take idempotency keys from upstream. Agents retry on timeouts without checking. A key you generate on receipt is an artefact of your transport and protects nothing.
Cap every response and declare the truncation. Silent truncation is the most expensive bug in this category, because it is indistinguishable from a correct answer and it propagates.
Return the fields the decision needs. Context is the caller’s budget. Spending it on nested objects nobody reads is a cost you are imposing on someone else’s task.