An agent was asked to define "idempotent" for a glossary. The dictionary tool returned a 404. The model, seeing an error it did not understand, tried again with "Idempotent", then "idempotency", then "idempotent (computing)", then went back to the original spelling. Each call was a few credits; the loop had no ceiling; the user's tab had long since closed.
Nothing in that story is a bug in the API or the model. The tool returned an accurate answer ("no entry for that word") in a form the model could not interpret, and the loop around it had no way to stop. Both of those are your code, and both are fixable.
Errors are now part of the conversation
In the Fundamentals courses, an error from an API went to your code and your code decided: retry, give up, show a message. In an agent there is a second decider. Whatever your tool returns goes back to the model as a tool result, and the model chooses the next step: try other arguments, try another tool, ask the user, or answer without the data.
That gives you a design choice for every failure: does the model need to know about this, and can it do anything useful with it?
- Some failures are about the input, and the model is the one who chose the input. It should hear about them, in words that point at a fix.
- Some failures are transient. The model cannot do anything about a busy minute, so your tool layer should absorb them with a bounded retry and only report if that fails.
- Some failures are fatal for the whole run: a bad key, an empty credit balance. The model cannot fix those either, and telling it invites it to try. Your loop should stop and tell a human.
Model APIs give you a channel for the first group. In the Anthropic Messages API a tool_result can carry is_error: true; in OpenAI's API you return the error text as the tool message content. MCP servers do the same thing: the APIVerve MCP server returns a failed call as tool content with isError: true and the API's own message, rather than a protocol error, precisely so the agent can read it and correct itself.
Map every status to a decision
Here is how APIVerve's statuses translate into agent behaviour. The first column is what the API says; the last is what the model should see.
| Status | What it means | Who handles it | What the model sees |
|---|---|---|---|
400 | Invalid or missing parameter | Model | The API's message plus "fix the argument" |
404 | Unknown API, or no result for this input | Model | "No result for X. Do not retry the same input." |
401 | Missing or unrecognised key | Your loop: halt | Nothing; the run stops |
403 | Valid key, but not allowed for this call (scoping, IP allow-list) | Your loop: disable the tool | "This tool is unavailable; answer without it" |
429 + credits left | Per-minute rate limit | Tool layer: wait retry-after, retry once or twice | Only if retries fail: "temporarily unavailable" |
429 + x-api-remaining-credits: 0 | Monthly credits exhausted | Your loop: halt | Nothing; the run stops |
5xx, timeout, network | Upstream trouble | Tool layer: one retry | If still failing: "temporarily unavailable" |
Three rows deserve a closer look.
404 is often an answer, not a failure. The dictionary returns 404 with "word not found" when it has no entry, and World Time returns 404 with "city not found". That is information: the input was understood and nothing matched. The right model behaviour is to stop asking about that input, tell the user, or try a meaningfully different input (a larger nearby city). Retrying the same string is never right.
curl "https://api.apiverve.com/v1/dictionary?word=retry" \
-H "x-api-key: YOUR_API_KEY"A hit returns definitions and definitionCount. Try a technical term such as idempotent: it returns 404, word not found.
When we tried idempotent, the dictionary returned 404. The word is real; this dictionary has no entry for it. Your error text should say exactly that, "no entry in the dictionary", not "invalid word", or the model will tell the user the word does not exist.
Both 429s look the same until you read a header. A rate-limit 429 clears in seconds. A credits 429 does not clear until the billing cycle renews. Check x-api-remaining-credits: if it is 0, no amount of waiting or retrying helps, and every retry is a wasted request. Halt the run and surface it.
403 is about the key, not the request. If you scoped the agent's sub-key to block an endpoint, a 403 is that scoping working. The model should drop the tool for the rest of the conversation. Sending the user to sign in again or retrying are both wrong.
Inside an agent run, a tool call returns 429 and the response header x-api-remaining-credits is 0. What should happen?
Answer the check above to continue.
Never loop forever
An agent loop ends when the model stops asking for tools. Usually that happens in a few turns. When it does not, it is because the model is stuck: retrying a failure, chasing a goal it cannot reach, or bouncing between two tools. None of the model APIs will stop it for you. Four limits will.
- A turn ceiling. Count model requests per user message and stop at a number that comfortably fits your longest real task, often 6 to 10.
- A repeated-failure guard. Key failures by tool name plus arguments. The second identical failure means the model is not learning from the error; remove that tool for the rest of the run or stop.
- A deadline. A wall-clock limit per user message, after which you return whatever answer you have.
- A halt path. Fatal errors end the run immediately and produce a message for a human, not for the model.
Here is the loop from lesson one with all four, using the error classes above:
import Anthropic from '@anthropic-ai/sdk';
import { ToolError, AgentHalt } from './tool-errors.mjs';
import { TOOLS, definitions } from './tools.mjs'; // tools.mjs now imports apiverve() from tool-errors.mjs
const client = new Anthropic();
const MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-opus-5-5';
const MAX_TURNS = 8;
const DEADLINE_MS = 60_000;
export async function ask(question) {
const messages = [{ role: 'user', content: question }];
const failures = new Map();
const disabled = new Set();
const started = Date.now();
for (let turn = 0; turn < MAX_TURNS; turn++) {
if (Date.now() - started > DEADLINE_MS) return 'That took too long. Please try a narrower question.';
const tools = definitions.filter((d) => !disabled.has(d.name));
const res = await client.messages.create({ model: MODEL, max_tokens: 4096, tools, messages });
messages.push({ role: 'assistant', content: res.content });
if (res.stop_reason !== 'tool_use') {
return res.content.filter((b) => b.type === 'text').map((b) => b.text).join('');
}
const results = [];
for (const block of res.content) {
if (block.type !== 'tool_use') continue;
const key = `${block.name}:${JSON.stringify(block.input)}`;
try {
if (disabled.has(block.name)) throw new ToolError(`${block.name} is disabled for this conversation.`);
const out = await TOOLS[block.name].run(block.input);
results.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(out) });
} catch (err) {
if (err instanceof AgentHalt) {
console.error('[agent halted]', err.message); // alert a human here
return 'This assistant cannot look that up right now. The team has been notified.';
}
const text = err instanceof ToolError ? err.message : `${block.name} failed unexpectedly.`;
failures.set(key, (failures.get(key) ?? 0) + 1);
if (failures.get(key) >= 2 || /not available to this agent/.test(text)) disabled.add(block.name);
results.push({ type: 'tool_result', tool_use_id: block.id, content: text, is_error: true });
}
}
messages.push({ role: 'user', content: results });
}
return 'I could not finish that within the step limit. Here is what I can say without more lookups.';
}Two details are easy to miss. Every tool_use block must get a matching tool_result, even when it fails or the tool is disabled; dropping one makes the next request invalid. And when you disable a tool you also remove its definition from the next request, so the model stops considering it rather than calling it and being refused.
Next: Control cost and latency, where you count what every turn costs in tokens, credits and seconds, and put a budget on each conversation.