Tool Interfaces: Errors the Model Can Recover From
A tool is the only thing the model sees of your code — its name, schema, and description. Designing that interface so the model selects the right tool, splitting overlapping tools, and returning structured errors (categories, retryable flags, the empty-result-vs-failure distinction) so the agent can recover intelligently.
A customer supplies an email address, but the agent calls a lookup that requires an exact order ID. Later, a refund times out and the agent cannot tell whether to retry. Both failures begin at the tool interface: what the model is told before the call and what it receives afterward.

Exam objectives covered here. 2.1 Design effective tool interfaces with clear descriptions and boundaries. 2.2 Implement structured error responses for MCP tools. Numbering follows the Claude Certified Architect – Foundations Exam Guide, version 1.0, effective July 2026.
What makes a tool different from an ordinary function is what the model can see of it. It never sees your code. It sees a name, an input schema, and a description, and from those three strings it decides whether to call the tool and how far to trust what comes back. A tool is the seam where the model meets the world, and the quality of that seam decides whether the whole agent works. For the order lookup, the description must distinguish searching by customer from looking up a known ID. For the refund, the result must distinguish rejection from an unknown outcome.
Two problems, both solved in what you write
A correct implementation still needs an interface the model can use. If two tools appear interchangeable, make their boundaries explicit. If an empty search result looks like a failed request, give those outcomes different representations.
- Selection is a description problem: get the model to pick the right tool by writing descriptions that differentiate.
- Recovery is an error-shape problem: let the agent respond intelligently to a failure by returning errors structured enough to act on.
Start with the lookup interface, then consider how a refund tool reports errors. The SDK examples publish tools over MCP; the chapter distinguishes that protocol from the Claude API wherever their fields differ.
The description is the interface
A tool’s description is the primary mechanism the model uses to select it. The model does not see your implementation. It sees the tool’s name, its input schema, and its description, and from those it decides whether and when to call it. A thin description (“searches documents”) gives the model nothing to distinguish this tool from three similar ones, and selection becomes a coin flip.
Describe the expected input, show a value, explain important edge cases, and name the alternative to use when the request falls outside this tool’s purpose.
from claude_agent_sdk import tool
@tool(
"lookup_order",
"Look up a single order by its exact order ID (e.g. 'A17'). "
"Returns status, line items, totals, and the delivery date. "
"Use this when you have a specific order ID; to find orders by customer "
"name or email, use search_orders instead. A well-formed ID that matches "
"nothing returns a valid empty result, not an error.",
{"order_id": str},
)
async def lookup_order(args): ...
Four jobs, four clauses. The input is an exact ID. The example is A17. The boundary sends the by-customer case to search_orders. And the edge case tells the model in advance what an unknown ID looks like coming back. So it doesn’t read an empty result as a broken tool and retry it.
Defining an empty result in advance helps the model interpret it without another call. The description itself also consumes input tokens; the cost section below measures that trade.
The input schema is the other half of the interface
Descriptions get the attention, but the schema is what the model actually fills in, and it is where a surprising amount of misuse originates. Every field name, every type, every enum, and every per-parameter description is a constraint the model reads before it writes an argument.
The SDK’s @tool decorator accepts three different shapes for the schema, and they are not interchangeable. Run against claude-agent-sdk 0.2.128, here is what each one actually produces on the wire.
from typing import Annotated
from claude_agent_sdk import tool
# 1. A dict of Python types — the shortest form.
@tool("simple", "dict of types", {"order_id": str, "limit": int})
async def simple(args): ...
# 2. The same dict, with per-parameter descriptions.
@tool("annotated", "Annotated descriptions", {
"order_id": Annotated[str, "Exact order ID, e.g. 'A17'."],
"include_items": Annotated[bool, "Include line items. Defaults to true."],
})
async def annotated(args): ...
# 3. A full JSON Schema, passed through.
@tool("jsonschema", "full JSON Schema", {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "Exact order ID, e.g. 'A17'."},
"currency": {"type": "string", "enum": ["USD", "EUR", "GBP"],
"description": "Currency for totals."},
},
"required": ["order_id"],
})
async def jsonschema(args): ...
Asking the server what it will advertise for each, via a tools/list request:
== simple
inputSchema: {"type": "object", "properties": {"order_id": {"type": "string"},
"limit": {"type": "integer"}}, "required": ["order_id", "limit"]}
== annotated
inputSchema: {"type": "object", "properties": {"order_id": {"type": "string",
"description": "Exact order ID, e.g. 'A17'."}, "include_items":
{"type": "boolean", "description": "Include line items. Defaults
to true."}}, "required": ["order_id", "include_items"]}
== jsonschema
inputSchema: {"type": "object", "properties": {"order_id": {"type": "string",
"description": "Exact order ID, e.g. 'A17'."}, "currency":
{"type": "string", "enum": ["USD", "EUR", "GBP"], "description":
"Currency for totals."}}, "required": ["order_id"]}
Annotated[type, "..."] is how you attach a per-parameter description in the dict form, and the string lands in the schema as a real description field. Use it. A parameter named limit with no description is a parameter the model will guess at.
enum is the strongest constraint in the schema, and it only exists in the full JSON Schema form. If a parameter has three legal values, spelling them out is the cheapest way to stop the model inventing a fourth. A field documented as “the currency” invites "dollars"; a field whose enum is ["USD", "EUR", "GBP"] mostly does not.
Mostly is doing real work in that sentence, and it is worth being exact about how much. A schema is an instruction the model reads, and by default it is not a gate the request has to pass. The model can still produce a value outside the enum, a missing required field, or JSON truncated halfway by a max_tokens stop. Writing a good enum lowers the rate; it does not make the case impossible.
There is a lever that changes that, on the Claude API side. anthropic.types.ToolParam carries a strict flag, and its docstring is the whole story in one line:
strict: bool
"""When true, guarantees schema validation on tool names and inputs"""
Note the two surfaces, because they are not the same one. strict is a field on a tool you pass to messages.create. mcp.types.Tool — the definition an MCP server advertises, which is what the @tool decorator produces — has no such field at all; its members are name, title, description, inputSchema, outputSchema, icons, annotations, meta and execution. So a tool you publish over MCP is validated by exactly one thing, which is your handler.
Which gives the rule. Write the schema for the model and validate for yourself. The enum, the types and the per-parameter descriptions are how you get a well-formed call on the first attempt, and they are worth every character. The check at the top of your handler is how you stay correct on the attempt that goes wrong anyway — and it also catches the failures a schema was never going to see, like a well-typed order_id for an order belonging to somebody else. When it fires, return the validation error from later in this chapter, naming the field and the legal values, so the model’s next attempt is informed rather than another guess.
And the dict form marks every parameter as required. Look at simple and annotated: both list every key in required. There is no way to declare an optional parameter using the dict-of-types shape. The moment your tool has a parameter with a sensible default, the short form is wrong — and the compiler will not tell you. It shows up as a model dutifully inventing a value for a field you meant to be optional. If a tool has any optional input, write the full JSON Schema.
Overlap is the enemy of selection
If analyze_content and analyze_document have near-identical descriptions, the model has little basis for choosing between them. The mechanism is worth naming plainly. Every tool on the roster is a discrimination the model has to make on every turn: read this description, compare it to the request, decide yes or no. Two tools whose descriptions overlap turn one discrimination into a guess, and a tool with three unrelated modes adds a mode choice hidden inside an argument, where you cannot see it. Fewer, sharper discriminations is the whole design principle. How much a given split improves selection depends on the model and the task, and was not measured for this book; the principle does not depend on the magnitude. Resolve the overlap in the names and contracts:
Rename and re-scope to eliminate the overlap. analyze_content with a vague description becomes extract_web_results with a web-specific one. Now the name and description together make its purpose unambiguous, and it stops competing with the document analyzer.
Split a generic tool into purpose-specific ones with defined input and output contracts. A catch-all analyze_document is three tools wearing one name. Split it into extract_data_points, summarize_content, and verify_claim_against_source, each with a tight description. Now the model selects among clear choices instead of guessing which mode of analyze_document you meant.
The system prompt can override a good description. Keyword-sensitive instructions (“always analyze the content first”) create an unintended association. It pulls the model toward a particular tool regardless of what that tool’s description says. So when tool selection misbehaves, review the system prompt for keywords, not only the tool descriptions. A well-written description can be defeated by a stray instruction three hundred lines away.
What a tool definition costs
Tool definitions are not free and they are not paid once. They are serialized into the request on every turn of the loop, ahead of the conversation. The model has to see the menu each time it decides to act. A roster of twelve richly-described tools is a fixed toll on every single call the agent makes.
That reframes “write a longer description” as a trade rather than a free win. The instrument for pricing it is client.messages.count_tokens, which takes the same tools and messages you would send to messages.create and returns the input token count without generating anything. It is a server call rather than a local tokenizer: the SDK posts to /v1/messages/count_tokens.
Here are the two lookup_order variants from earlier, priced against claude-haiku-4-5:
baseline (no tools) : 8 tokens
n thin_tokens rich_tokens thin_bytes rich_bytes
1 562 657 162 547
2 620 810 323 1093
4 736 1116 645 2185
8 968 1728 1289 4369
Read the first row and the byte columns together, because they disagree. In bytes the rich definition is 3.4 times the thin one. In tokens, a request carrying one rich tool is only 1.17 times one carrying a thin tool. Bytes are not a proxy for tokens here, and it is worth knowing why before you optimise the wrong thing.
The gap is a fixed cost. Turning tools on at all costs about 496 tokens before the first schema is counted: the API installs its own tool-use instructions into the prompt. After that the roster is linear, and the per-tool marginal costs are exact:
marginal per thin tool over 8 : 58.0 tokens
marginal per rich tool over 8 : 153.0 tokens
So the honest version of the trade is not “a rich description costs 3.4x”. It is: the first tool is dominated by a one-time toll you pay for having any tools at all, and each tool after that costs 58 tokens written thinly or 153 written richly. The marginal ratio is 2.6x, not 3.4x and not 1.17x. Eight richly-described tools come to 1,728 tokens of prefix before the user has said anything, against 968 written thinly.
That is still the right trade in almost every case, because a wasted tool call costs a whole round trip plus the tokens of the wrong result. But it is a trade, and it is the arithmetic behind the roster-sizing argument in the next chapter. It also carries a caution about measurement: I originally reasoned about this cost from serialized bytes, which was convenient and wrong. Bytes ignore the fixed toll entirely and misstate the ratio. Price the thing you actually pay for.
Annotations: the part of the interface that is not prose
A description tells the model what a tool is for. It does not tell the surrounding system anything it can act on programmatically. Two questions come up constantly in production, and neither has a prose answer. Is it safe to run this without asking? And is it safe to run it again after a timeout?
MCP answers both with tool annotations, structured hints attached to the tool definition alongside the schema. They are the fourth parameter of the same @tool decorator used above, which is easy to miss.
inspect.signature(claude_agent_sdk.tool)
(name: str, description: str, input_schema: type | dict, annotations: ToolAnnotations | None = None)
mcp.types.ToolAnnotations carries five fields. The documented defaults come from the source of mcp 1.28.1, and two of them are pessimistic on purpose:
| Field | Type | Default | Meaning |
|---|---|---|---|
title | str | none | A human-readable title for the tool. |
readOnlyHint | bool | false | If true, the tool does not modify its environment. |
destructiveHint | bool | true | If true, the tool may perform destructive updates. Meaningful only when readOnlyHint is false. |
idempotentHint | bool | false | If true, repeated calls with the same arguments have no additional effect. Meaningful only when readOnlyHint is false. |
openWorldHint | bool | true | If true, the tool interacts with an open world of external entities. A web search is open; a memory store is closed. |
Read the defaults as a policy. Unannotated, every tool you write is assumed to be a writing, destructive, non-idempotent tool reaching out to the open internet. That is the correct assumption for a system deciding what to auto-approve. It also means annotation is how you earn less friction, rather than how you add safety. A read-only lookup that never leaves your database is three booleans away from a policy being willing to auto-approve it — and if you don’t set them, nothing can know that.
Annotations can inform a policy, but cannot establish that the implementation is safe. Ground approval in the tool’s identity, its credentials, and the permission rules from chapter 3. Revisit the annotations when an implementation changes.
idempotentHint is the one that pairs directly with the second half of this chapter. It is the declarative answer to “can this be retried?”, set once at definition time instead of relitigated in the error handler on every call.
And it is where the annotation is most often filled in backwards. lookup_order is idempotent, obviously. process_refund moves money, so the instinct is idempotentHint=False and a note that retries are unsafe — which is what the example below shows, and what a great many real tools ship. The instinct describes the side effect correctly and draws the wrong conclusion from it.
Issuing a refund is genuinely not repeatable. Nothing makes crediting a card twice equal to crediting it once. But the annotation is not about the side effect; it is about the command, and a command can be made repeatable even when its effect is not. Take an operation_id from the caller, record it, and let the second call carrying that id return the first call’s result instead of moving money again. Now process_refund is a tool that can be retried, and idempotentHint=True is the truth rather than an aspiration.
The reason to insist on this is that you do not get to opt out of retries. The Anthropic SDK retries on its own by default, a transient failure will be reissued, and a resumed session can reach a tool call whose result was never written. At-least-once is the delivery you have. So the question a tool interface must answer is not “will this be called twice?” — it is “what happens when it is?” A tool whose contract has no answer has delegated that question to luck. Chapter 13 builds the ledger that answers it, including the trap where rejecting a replay with a 409 makes things worse rather than better.
Here they are set, and confirmed on the wire. This is the un-keyed shape — a refund tool that takes only an order_id really cannot be retried, and its annotations should say so rather than flatter it:
from mcp.types import ToolAnnotations
@tool("process_refund", "Issue a refund against an order.", {"order_id": str},
annotations=ToolAnnotations(title="Issue refund", readOnlyHint=False,
destructiveHint=True, idempotentHint=False,
openWorldHint=False))
async def process_refund(args): ...
== refund
annotations: title='Issue refund' readOnlyHint=False destructiveHint=True
idempotentHint=False openWorldHint=False
One caveat, quoted from the MCP source because it matters and is easy to over-read: “all properties in ToolAnnotations are hints. They are not guaranteed to provide a faithful description of tool behavior. Clients should never make tool use decisions based on ToolAnnotations received from untrusted servers.”
The same limitation applies to a server you own. Adding a write to a previously read-only handler does not automatically update its readOnlyHint. Review the declaration against the implementation.
Practically: annotate honestly, because a client that has nothing to go on defaults to assuming the worst. Then put the decision somewhere annotations cannot reach it — a permission rule naming the tool, a credential that cannot perform the write the hint says never happens, and a review step on the diff whenever a tool’s implementation changes but its annotations do not.
Structured errors: let the agent recover
When a tool fails, how it reports the failure decides whether the agent can do anything useful about it. The anti-pattern to design against is the generic "Operation failed". That uniform error tells the agent nothing. It can’t decide whether to retry, apologize, escalate, or try another path.
One flag, three surfaces, two spellings
The same concept is spelled differently depending on which layer you are writing at, and getting it wrong fails silently in the worst possible direction.
| Surface | Spelling | Where |
|---|---|---|
| MCP wire schema | isError | mcp.types.CallToolResult |
| Claude API request | is_error | anthropic.types.ToolResultBlockParam |
| SDK tool handler return | is_error | the dict your @tool function returns |
The SDK’s tool wrapper reads the snake-case key and nothing else. From claude_agent_sdk/__init__.py:
return CallToolResult(
content=content, isError=result.get("is_error", False)
)
So a handler that returns {"isError": True} has its key silently ignored. .get falls through to the default, and the result goes out as isError=False. A failure is marshalled as a success. No exception, no warning, no log line. The agent reads a well-formed result whose payload happens to be an error message, treats it as a normal return value, and carries on.
Run against claude-agent-sdk 0.2.128, calling both spellings through a real SDK MCP server.
wrong isError=False # returned {"isError": True, ...}
right isError=True # returned {"is_error": True, ...}
The decorator’s docstring confirms the Python spelling: “Errors can be indicated by including is_error: True in the response.”
Two spellings across three surfaces reads as trivia until it costs you an afternoon. The rule that makes it memorable: camelCase is the wire, snake_case is your Python. You write is_error in both the SDK handler and a raw Anthropic API tool_result block. You only see isError when you are looking at a serialized MCP message.
Five categories, five different responses
The flag says something failed. The content says what to do about it. Classify every failure, because each class demands a different response:
- Transient — a service briefly unavailable, a connection reset before the request was accepted. Retryable, and usually retryable right here without telling anyone.
- Validation — the input was malformed. Not retryable as-is. The agent must fix the input first, which means the error has to say which field and why.
- Business — a policy violation, such as a refund outside the return window. Not retryable. The answer will not change on a second attempt.
- Permission — the caller isn’t allowed. Not retryable without a permission change, and often the right move is to escalate to a human rather than to work around it.
- Unknown — the request went out and no answer came back. Not retryable, and not failed either. Resolve it before doing anything else.
That fifth one is missing from most taxonomies, including the four-way version this chapter used to give, and its absence is expensive in a specific way. The usual list files a timeout under transient, which reads as sensible until you ask what a timeout actually tells you. It says your client stopped waiting. It says nothing whatsoever about what the server did. The refund may have been declined, or it may have been issued and the response lost on the way home.
So a timeout splits, and where it splits is on whether the call changes state:
- On a read, a timeout is transient. Retry it. The worst case is a wasted call.
- On a write, a timeout is unknown, and retrying it is how you refund a customer twice. Same error, same status code, opposite correct response.
An unknown outcome is not resolved by trying harder; it is resolved by looking it up. Fetch the operation by the key you sent with it and find out what happened. Then either report the result you found or issue the call for real, having established that it never landed. The idempotency key is what makes that lookup possible, which is why the interface has to carry one before the error taxonomy can use it — the ledger is in chapter 13.
The binary distinction still does most of the work: will trying again help? Unknown is the case where the honest answer is that you cannot tell yet, and the tool’s job is to say so rather than to pick.
So a good error response carries structured metadata the agent can branch on. The minimum viable shape is a category, a retryable flag and a human-readable message:
@tool("process_refund", "Issue a refund against an order.", {"order_id": str})
async def process_refund(args):
# ... refund logic finds the order is outside the return window ...
return {
"is_error": True, # snake_case — see above
"content": [{"type": "text", "text": (
'{"errorCategory": "business", "isRetryable": false, '
'"message": "Order A17 is outside the 30-day return window '
'(delivered 45 days ago)."}'
)}],
}
The isRetryable: false stops the agent from burning turns on a business rule that will never pass. The human-readable message lets it explain the refusal to the customer, instead of saying “something went wrong.” Both halves matter. The flag drives the agent’s control flow; the message drives what the user hears.
The envelope you actually want in production
That minimum is enough to teach the idea and not enough to operate. Two audiences read a tool error, and they need different things from it. The model needs to know what to do next. A human, three weeks later, needs to know what happened. Write one envelope that serves both without letting the second audience’s needs leak to the first.
{
"error_code": "REFUND_WINDOW_EXPIRED",
"category": "business",
"retryable": false,
"resolution": "abort",
"message": "Order A17 is outside the 30-day return window (delivered 45 days ago).",
"field_errors": [],
"correlation_id": "req_01HZX9K4",
"attempt": 1,
"deadline_ms_remaining": 8400
}
The envelope serves both the recovery decision and the later investigation:
error_code is a stable identifier and message is not. Code branches on the code; humans read the message. Reword the message freely, and change the code only when the meaning changes, because dashboards and post-incident queries are built on it.
resolution says what to do, so the agent does not have to infer it. retry, fix_input, abort, escalate, reconcile — one word covering the case a lone boolean cannot express, which is the unknown outcome above. retryable: false plus resolution: "reconcile" is exactly “do not call me again, go and find out what happened.”
field_errors makes a validation failure actionable in one turn rather than three. [{"field": "currency", "problem": "must be one of USD, EUR, GBP", "got": "dollars"}] tells the model precisely what to change. “Invalid input” makes it guess, and it will guess at the field you did not mean.
correlation_id is the join. The same value goes into your own logs beside the stack trace, the upstream request id, the user id and the SQL that failed. Then a support conversation and a log query meet without any of the internals ever entering the model’s context.
attempt and deadline_ms_remaining give the agent a budget. An agent that knows it is on attempt three of three, with eight seconds left, stops differently from one that has been told only that something failed. Without them, “retryable” reads as an invitation to keep going.
And one field that is conspicuously absent. No stack traces, no SQL, no internal hostnames, no upstream error bodies. Everything the model sees is content it may quote to a customer, and it is also content an attacker can try to elicit. Keep the internal detail in your log line, keyed by correlation_id, and let the envelope carry the safe half. The test is a good one to run literally: read every error your tools can emit as though it will be pasted into a support chat, because eventually one of them will be.
A note on the shape of that payload, since it looks odd. The error metadata is JSON inside a text block, not a first-class field. There is a proper home for it in the protocol, and the next section is about why you can’t use it yet.
Where the structure is supposed to live
MCP has real support for structured tool output, on both ends of the call. mcp.types.Tool carries an outputSchema field, and mcp.types.CallToolResult carries structuredContent: dict | None alongside content. Together they are the tool-result mirror of the input schema: declare the shape, return the object, let the client validate.
Neither is reachable through the SDK’s @tool decorator as of claude-agent-sdk 0.2.128, and the second failure is quiet.
The decorator has no outputSchema parameter, so every tool it produces advertises outputSchema: None. That one is at least obvious from the signature. The other is not. The wrapper constructs its result as CallToolResult(content=content, isError=...) and never reads a structuredContent key. A handler that returns one has it dropped on the floor.
structured isError=False structuredContent=None # handler returned structuredContent={...}
Which is why the error payload above is JSON serialized into a text block. That is not a stylistic choice or a simplification for the book; it is the only channel available. Two consequences worth carrying:
Keep the JSON in the text block well-formed and machine-parseable anyway. You are writing for two readers. The model reads it as prose and gets the human message. Any code you wrap around the agent can json.loads the block and branch on isRetryable. Prose-only errors (“Sorry, that order is too old”) serve only the first reader.
And treat this as a version-pinned workaround rather than a design principle. The protocol has the right field; the SDK does not yet plumb it. When it does, moving from a text block to structuredContent is a small change to the handler and no change at all to your error taxonomy. Design the taxonomy for the destination.
Two distinctions the exam presses
Two distinctions determine whether recovery belongs in the tool, the subagent, or the coordinator.
Access failure vs. valid empty result. A tool that couldn’t run is a failure needing a retry decision. A tool that ran successfully and found nothing is a valid empty result, not an error. Reporting “no matches” as an error makes the agent retry a query that will keep returning nothing. Reporting a timeout as an empty result makes the agent conclude the customer doesn’t exist — which is worse. That is a wrong answer delivered confidently, with no failure anywhere in the trace to explain it later. The two must be distinguishable in what the tool returns. The cleanest way to guarantee that is the one from the description section: say which is which in the description, so the model knows before it calls.
Local recovery vs. propagation, which reaches forward into the coordinator patterns from chapter 2. A subagent should handle transient failures locally, retrying the timeout itself. It should propagate to the coordinator only the errors it genuinely cannot resolve, along with partial results and what it attempted. A subagent that propagates every hiccup floods the coordinator with noise it has no context to interpret. One that silently swallows a real failure hides it, and the coordinator synthesizes a confident answer from a gap it doesn’t know is there. The right line: recover what you can, and report what you can’t with enough context for the coordinator to decide.
Notice that the categories above map straight onto that line. Transient stays local. Business and permission propagate, because the coordinator may have a different path or a human to ask. Unknown always propagates, and it propagates loudly: a subagent that quietly reconciles a possible double refund and reports nothing has removed the one signal a coordinator needed. Validation is the interesting one. It stays local if the subagent can see what was wrong with the input, and propagates if the bad input came from the coordinator in the first place.
Three failures the categories don’t cover
The taxonomy handles tools that fail. Three common situations are not failures at all, or not only failures, and each needs a decision made at the tool boundary rather than left to the model.
The tool succeeds and returns fifty thousand tokens. A query with no LIMIT, a log file, a full API page dump. Nothing failed, so is_error stays false, and the entire payload lands in the context window where it displaces everything the agent still needs. The fix belongs in the tool, not the prompt: cap the result, and say in the result that you capped it. A truncated return that ends with ... 4,812 more rows; refine the filter or request a specific page gives the agent an accurate picture and a next move. A silent truncation gives it a wrong picture. This is the same discipline chapter 12 formalizes at the level of the whole session; the tool boundary is where it is cheapest to apply.
A cap alone still leaves the agent guessing about the thing it most needs to know, which is whether it has seen everything. “Here are 50 of 4,862 rows” and “here are all 50 rows” support completely different conclusions, and an agent that cannot tell them apart will confidently report the second when it received the first. Make the result describe its own coverage:
{
"results": ["..."],
"returned": 50,
"total_matching": 4862,
"complete": false,
"next_cursor": "eyJvIjo1MH0",
"as_of": "2026-06-06T09:14:22Z",
"sort": "created_at desc, id desc"
}
complete is the field that prevents the wrong answer. Everything else is machinery; this one is the claim. When it is false, “no matching order was found” is not a conclusion the agent is entitled to draw, and the description should say so in as many words.
next_cursor is what makes the cap recoverable rather than a dead end. Return an opaque token rather than an offset, because an offset over a table that is still being written re-reads rows and skips others. If you cannot issue a cursor, at least say "paging": "unsupported" so the agent narrows the filter instead of asking for page two.
Sort deterministically, and say how you sorted. This is the quiet one. A page boundary in an unstable order silently drops rows and duplicates others between pages, and the totals will look fine while the answer is wrong. Order by a tie-broken key — created_at desc, id desc, never created_at desc alone.
as_of is provenance, and it is what lets the agent say “as of nine minutes ago” instead of implying it has just looked. For anything cached or replicated it is the difference between a defensible answer and a confidently stale one.
The tool returns malformed JSON. An upstream service changes a field, your parser raises, and now you have a decision. Returning the raw parse error as a transient failure invites a retry that will fail identically, forever — nothing about the upstream response will change on the second call. This is a validation failure pointing at the upstream response rather than at the model’s input. It belongs in a category the agent cannot fix by trying harder. Mark it non-retryable, and say what was expected and what arrived.
Two parallel tool calls both fail. When a model issues several tool calls in one turn, the results come back together. Two independent errors arrive as one block of text. If both say "Operation failed", the agent has lost the ability to tell them apart. Any recovery it attempts is a guess about which call it is recovering. Every error message should name its own subject — Order A17 and not the order — for exactly this reason. Errors written to be read one at a time become ambiguous the moment they arrive in pairs.
Final thoughts
What to carry forward.
- A description must differentiate a tool from its neighbours: input, examples, edge cases, and the boundary against similar tools.
- The short dict form marks every parameter required, so a tool with an optional input needs a full JSON Schema. An enum lowers the rate of a bad argument; it does not make one impossible.
- Annotations make a tool eligible for a policy, not safe. Nothing checks them against the code, and their defaults assume the worst.
- You do not get to opt out of retries: the SDK reissues transient failures on its own, so a state-changing tool takes an operation key and a replay returns the first result.
- The flag is
is_errorin Python andisErroron the wire; the camelCase spelling in a handler is a silent success. A timeout on a write is an unknown outcome: reconcile it, do not retry it. Keep a valid empty result distinct from an access failure.
Read the lookup tool’s contract as if you had only a customer’s email address. It should tell you to search first, and it should let you distinguish “no orders found” from “the search failed.” Then inspect the refund contract after a timeout. It should tell you how to discover the outcome before making another attempt.
These are useful review cases because they require more than a well-typed argument. The interface must give the agent enough information to choose its next step, while the handler enforces the rules that cannot depend on that choice.
Next: distributing tools and integrating MCP servers — scoping tool access, tool_choice, wiring MCP servers, and using the built-in tools well.
Comments