When an agent misuses a tool, the instinct is to blame the model. Look at the tool definition first. The model chose that tool, built those arguments and interpreted that result using nothing but the name, the descriptions and the schemas it was given. If the definition is ambiguous, the behaviour will be too, and no amount of system-prompt pleading fixes a schema that says the wrong thing.
This lesson reads a real APIVerve tool definition the way a model does, then looks at the other half of the contract: what comes back, how structuredContent differs from the text block, and why a null is information rather than an absence of it.
A tool definition, field by field
Here is the complete definition the APIVerve server returns for Number to Words, as tools/list sends it. The output schema is trimmed to a few fields; the rest follow the same pattern.
{
"name": "NumbertoWords",
"title": "Number to Words",
"description": "Number to Words: Number to Words is a number to words API that converts numeric values into written cardinal words and counts whole and fractional digits. Pass any integer or float to get the spelled-out text along with digit counts and locale information. (Cost: 1 credit)",
"inputSchema": {
"type": "object",
"properties": {
"number": {
"type": "number",
"description": "The number to convert to words",
"examples": [975.07]
}
},
"required": ["number"]
},
"outputSchema": {
"type": "object",
"properties": {
"status": { "type": "string", "description": "\"ok\" on success" },
"error": { "type": ["string", "null"], "description": "Error message, null on success" },
"data": {
"type": "object",
"properties": {
"words": { "type": ["string", "null"], "description": "The cardinal word representation of the number" },
"ordinal": { "type": ["string", "null"], "description": "The ordinal word representation of the number (premium)" },
"afterDecimal": {
"type": "object",
"description": "Breakdown of the digits after the decimal point, or null when the number has no fractional part"
}
}
}
}
},
"annotations": { "readOnlyHint": true, "openWorldHint": true }
}
Each part has a job.
name is the identifier the model emits when it wants the tool. APIVerve derives it from the endpoint title stripped to letters, digits, underscores and hyphens, so Number to Words becomes NumbertoWords. Names must be stable: a client may cache them, and a user may have approved a tool by name.
title is for humans. Clients that render a tool picker show it; the model mostly ignores it.
description is the single most important string in the definition, because it is what the model reads to decide whether this tool answers the question in front of it. Note the cost appended at the end. An agent choosing between a 1-credit and a 20-credit way to answer has what it needs to pick the cheap one.
inputSchema is a JSON Schema for the arguments object. type: "number" means the model should send 975.07, not "975.07" or "nine hundred". required tells it which arguments it cannot leave out. examples shows a valid value, which helps more than people expect: a model that has seen one correctly formatted example rarely invents a different format.
outputSchema describes what a successful result contains. It arrived in protocol version 2025-06-18, so a client negotiating an older version will not see it. Look at the types: almost every field is ["string", "null"] or ["number", "null"]. That is deliberate, and it is the subject of the last section.
annotations are hints about behaviour. readOnlyHint: true says calling the tool changes nothing on your side, which is true of every APIVerve tool: they look things up, compute or generate. openWorldHint: true says the tool reaches outside a closed system. Clients may use these to decide when to ask for confirmation. The specification is clear that annotations are hints, and a client should not trust them from a server it does not trust. They are a courtesy, not a security boundary.
Which part of a tool definition does the model rely on most to decide whether to call that tool for a given question?
Answer the check above to continue.
structuredContent and the text block
A tool result can carry its payload twice. The content array holds blocks the model reads; for a JSON-producing tool that is usually one text block containing serialized JSON. On protocol 2025-06-18 and newer, a tool that declares an outputSchema also returns structuredContent: the same data as an object that conforms to that schema. APIVerve puts the identical {status, error, data} envelope in both, so the two cannot disagree.
The practical rule is short: code reads structuredContent; models read whatever the client gives them. If you are writing a host, a test harness or a workflow step that consumes tool results, use the structured half and validate it against the schema you were given at tools/list time. Parsing the text block is a fallback for servers or protocol versions that do not offer structured output.
// Node 18+, ESM. npm install ajv@8
import Ajv from 'ajv';
const ajv = new Ajv({ strict: false });
// tool: the entry from tools/list. result: the tools/call result.
export function readResult(tool, result) {
if (result.isError) {
throw new Error(result.content?.[0]?.text ?? 'tool call failed');
}
let payload = result.structuredContent;
if (payload === undefined) {
// Older protocol version: fall back to the text block.
payload = JSON.parse(result.content.find((c) => c.type === 'text').text);
}
if (tool.outputSchema) {
const validate = ajv.compile(tool.outputSchema);
if (!validate(payload)) {
// Log, don't throw: a schema that drifted from the data should not take the agent down.
console.warn(`outputSchema mismatch: ${ajv.errorsText(validate.errors)}`);
}
}
return payload.data;
}# Python 3.9+. pip install jsonschema
import json
import logging
from jsonschema import Draft202012Validator
def read_result(tool: dict, result: dict) -> dict:
"""tool: the entry from tools/list. result: the tools/call result."""
if result.get("isError"):
raise RuntimeError(result.get("content", [{}])[0].get("text", "tool call failed"))
payload = result.get("structuredContent")
if payload is None:
# Older protocol version: fall back to the text block.
text = next(c["text"] for c in result["content"] if c["type"] == "text")
payload = json.loads(text)
if "outputSchema" in tool:
# Log, don't raise: a schema that drifted from the data should not take the agent down.
for err in Draft202012Validator(tool["outputSchema"]).iter_errors(payload):
logging.warning("outputSchema mismatch at %s: %s", list(err.path), err.message)
return payload["data"]Validation is cheap insurance: if a server changes shape, you find out at the boundary with a clear message instead of three steps later with undefined is not a function. Notice that both versions log a mismatch rather than failing the call. Schemas are written by people and drift from the data. A live example: Number to Words declares afterDecimal as "type": "object", yet its own description says it is null for a whole number, and that is what the API returns for 1204. A strict validator rejects a perfectly good answer. Log mismatches, alert on them, and reserve hard failures for the fields your code actually depends on.
What null means, and what it does not
The APIVerve server tells every client, in the instructions string it sends on initialize, how to read its results: "null fields mean 'not available for this input or plan'." That one sentence covers two different situations, and both appear in a single call:
curl "https://api.apiverve.com/v1/numbertowords?number=1204" \
-H "x-api-key: YOUR_API_KEY"afterDecimal is null because 1204 has no fractional part. On lower plans, ordinal can be null too.
Null because of the input. afterDecimal is null because 1204 is a whole number. Its description says so in as many words: "or null when the number has no fractional part." suggestedCorrection on Email Validator is null whenever no domain typo was detected. canReceiveMail is null when the DNS lookup could not complete. None of these are errors.
Null because of the plan. Some fields are premium. In the output schema their descriptions end in (premium): on Number to Words that includes ordinal, numberOfDigits_words and eachNumber; on Email Validator it is suggestedCorrection, riskScore and riskLevel. The server's instructions say premium fields may be absent on lower plans. Code has to work either way.
What null never means is zero, false or empty. This is where agents and the code around them go wrong:
riskScore: nullis not a risk score of 0. Treating it as 0 rates every address as perfectly safe on a plan without the field.canReceiveMail: nullis notfalse. It means the check did not complete, which calls for a retry or a neutral verdict, not a rejection.suggestedCorrection: nullmight mean "no typo" or "your plan does not include this", and only the schema's(premium)marker plus your plan tell you which.
The defensive pattern is to treat every field as three-valued and decide explicitly what unknown should do:
// Node 18+, ESM. data: the result's data object.
export function emailVerdict(data) {
if (data.isDisposable === true || data.isRegexValid === false) return 'block';
if (data.canReceiveMail === false) return 'block';
if (data.canReceiveMail === null) return 'review'; // lookup did not complete
if (typeof data.riskScore === 'number' && data.riskScore >= 70) return 'review';
// riskScore null: plan without the field, or not computed. Do not treat as 0.
return 'allow';
}For an agent rather than code, put the rule in words where the model will see it, in your system prompt or your own server's instructions: a null field means the information is not available, it must not be reported as a negative, and a premium null should be described as "not included" rather than "none found".
Your code reads riskScore from Email Validator and uses `score ?? 0` before comparing it to a threshold. Why is that a bug?
Next
Schemas tell an agent what a tool does. They say nothing about whether it should be allowed to, how much it may spend, or whether the text a tool returns can be trusted. That is the last lesson of this course: Keys, scopes and safety for agents.