One agent with three tools tolerates a lot. A platform with forty tools shared by a dozen agents does not, because every weakness in a tool definition is multiplied by every agent that uses it and every run those agents make. A description that misleads one model in fifty runs is a weekly incident at platform volume. An argument that slips through unchecked is a credit leak that nobody sees until the invoice.
This lesson treats tool design as interface design for an unusual caller: one that reads your descriptions literally, guesses when they are vague, and sends arguments that are usually right. You will settle granularity, write the argument checker every tool should sit behind, shape outputs with schemas, move large results out of the context, and turn failures into instructions.
Granularity: one tool per decision
The question "how big should a tool be" has a practical answer: one tool per decision the model has to make. Not one per endpoint, and not one per user story.
Three patterns follow from that rule.
Merge calls the model would always make together. If every phone check is followed by a timezone lookup for the same number, the model is not deciding anything by making the second call. Do both in the tool handler and return one result. You save a model turn per run, and the model cannot forget the second step.
Split a tool when its arguments carry different risk. A single account_action tool with an action enum of lookup, lock and refund puts a read and two consequential writes behind one name, one description and one approval decision. Three tools let the platform treat them differently, which lesson 3 depends on.
Give lists their own tool. A model asked to check 120 phone numbers will make 120 calls, each a turn, each a chance to drift. A check_phones tool that takes an array and runs APIVerve's batch route does the fan-out in code. The batch route accepts up to 200 items per request on one endpoint, bills each item that succeeds as if it were a single call, and returns results in the order you sent them, each with its own envelope. The HTTP status describes the batch, not the items, so the handler must read each item's status. Deduplicate first: duplicates are billed every time they appear.
// Node 18+, ESM. A list tool backed by the batch route: dedupe, chunk, read every item.
const BATCH_MAX = 200;
export async function checkPhones({ numbers, country }, { key }) {
const unique = [...new Set(numbers.map((n) => n.trim()))];
const byNumber = new Map();
for (let i = 0; i < unique.length; i += BATCH_MAX) {
const chunk = unique.slice(i, i + BATCH_MAX);
const res = await fetch('https://api.apiverve.com/v1/phonenumbervalidator/batch', {
method: 'POST',
headers: { 'x-api-key': key, 'Content-Type': 'application/json' },
body: JSON.stringify({ requests: chunk.map((number) => (country ? { number, country } : { number })) }),
signal: AbortSignal.timeout(60_000),
});
if (!res.ok) throw new Error(`batch refused: HTTP ${res.status}`); // the batch itself was malformed or unauthorised
const body = await res.json();
body.results.forEach((r, j) => byNumber.set(chunk[j], r)); // index is the correlation
}
// Compact per-number verdicts; the model never sees 120 full envelopes.
return numbers.map((n) => {
const r = byNumber.get(n.trim());
return r.status === 'ok'
? { number: n, valid: r.data.isValid, e164: r.data.formatted?.e164 ?? null, type: r.data.type }
: { number: n, valid: null, error: typeof r.error === 'string' ? r.error : JSON.stringify(r.error) };
});
}A rate-limited item inside a batch comes back as its own 429 inside an HTTP 200, with a retryAfter, and an item past your remaining credits comes back as 402. The handler above surfaces both as per-number errors; a production version retries the rate-limited items after the wait, and stops on the first 402.
Validate model-supplied arguments in code
Models produce arguments that match the schema most of the time. "Most of the time" is not a contract, and three platform facts make the gap expensive:
- Schemas are advice to the model, not enforcement. Some providers offer a strict mode that constrains generation to the schema; many hosts and models do not use it.
- The hosted MCP server's input schemas do not set
additionalProperties: false, so an invented argument passes schema validation on the way in. - APIVerve silently drops parameters an endpoint does not declare. There is no
400for an unknown key. The call runs as if you had not sent it.
Put those together with a phone number. A model that writes region where the API expects country sends { "number": "02079460958", "region": "GB" }. The API drops region, sees a national-format number with no country, and answers 400 with country is required unless the number is in international format. The model is now told it is missing an argument it believes it sent. With a different input the result is worse: an international-format number would succeed, and the model would believe its region filter had been applied. The correct call looks like this:
curl "https://api.apiverve.com/v1/phonenumbervalidator?number=02079460958&country=gb" \
-H "x-api-key: YOUR_API_KEY"With country set, the national number resolves: formatted.e164 is +442079460958. Send region instead and the API drops it without telling you.
The fix is a checker that every tool call passes through before any request is made. It enforces the same small subset of JSON Schema your tool definitions already use: required keys, primitive types, and nothing that is not declared.
// Node 18+, ESM. Validate model-supplied arguments against a small JSON-Schema-like object.
// schema: { properties: { name: { type, enum? } }, required: [names] }
// Returns { ok: true, args } with unknown keys dropped, or { ok: false, errors }.
const TYPES = {
string: (v) => typeof v === 'string',
number: (v) => typeof v === 'number' && Number.isFinite(v),
integer: (v) => Number.isInteger(v),
boolean: (v) => typeof v === 'boolean',
array: (v) => Array.isArray(v),
object: (v) => v !== null && typeof v === 'object' && !Array.isArray(v),
};
const typeName = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
export function checkArgs(schema, args) {
if (args === null || typeof args !== 'object' || Array.isArray(args)) {
return { ok: false, errors: ['arguments must be a JSON object'] };
}
const props = schema.properties ?? {};
const errors = [];
const clean = {};
for (const name of schema.required ?? []) {
if (args[name] === undefined || args[name] === null) errors.push(`${name}: required`);
}
for (const [name, value] of Object.entries(args)) {
const spec = props[name];
if (!spec) continue; // undeclared: dropped, never forwarded
if (value === undefined || value === null) continue; // absent; a missing required key is reported above
const check = TYPES[spec.type];
if (check && !check(value)) {
errors.push(`${name}: expected ${spec.type}, got ${typeName(value)}`);
} else if (spec.enum && !spec.enum.includes(value)) {
errors.push(`${name}: must be one of ${spec.enum.join(', ')}`);
} else {
clean[name] = value;
}
}
return errors.length ? { ok: false, errors } : { ok: true, args: clean };
}Read the decisions in it, because each one is a choice you could make differently and should make on purpose:
- Unknown keys are dropped, not forwarded. That is the behaviour the API has anyway, made explicit and visible on your side. You decide what to tell the model about it (below).
nullcounts as absent. Models often send"country": nullfor "I have no value". Treating it as present and of the wrong type produces an error the model cannot fix; treating it as absent lets optional fields be optional.- No coercion.
"975"is not a number. Silently converting it would hide a model that has started formatting arguments wrongly, and the eval harness in lesson 4 wants to see that. Return an error the model can act on instead. - Every error is collected, not just the first, so the model can fix everything in one retry rather than one problem per turn.
Pinning the behaviour with a few assertions costs nothing and keeps the next refactor honest:
// Run: node --test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkArgs } from './check-args.mjs';
const phone = {
properties: { number: { type: 'string' }, country: { type: 'string' } },
required: ['number'],
};
test('drops undeclared keys', () => {
assert.deepEqual(checkArgs(phone, { number: '02079460958', region: 'GB' }), { ok: true, args: { number: '02079460958' } });
});
test('reports missing and mistyped together', () => {
assert.deepEqual(checkArgs(phone, { country: 44 }), { ok: false, errors: ['number: required', 'country: expected string, got number'] });
});
test('treats null as absent', () => {
assert.deepEqual(checkArgs(phone, { number: '+14155552671', country: null }), { ok: true, args: { number: '+14155552671' } });
});Now wire it into the executor, with one addition that closes the region trap: when keys were dropped, say so in the result. The call still goes ahead with the clean arguments, but the model learns that region did nothing.
// Node 18+, ESM. Every call: check, then call, then annotate.
import { checkArgs } from './check-args.mjs';
export async function execute(tool, rawArgs, ctx) {
const checked = checkArgs(tool.schema, rawArgs);
if (!checked.ok) {
return toolText(`Arguments rejected: ${checked.errors.join('; ')}. Fix them and call ${tool.name} again.`, true);
}
const dropped = Object.keys(rawArgs).filter((k) => !(k in (tool.schema.properties ?? {})));
const result = await tool.run(checked.args, ctx);
if (dropped.length && !result.isError) {
result.content.push({ type: 'text', text: `Note: ignored unknown argument(s) ${dropped.join(', ')}; ${tool.name} does not accept them.` });
}
return result;
}
const toolText = (text, isError = false) => ({ isError, content: [{ type: 'text', text }] });The phone schema above requires number and declares country. A model sends { "number": 4155552671, "region": "US" }. What does checkArgs return?
Output schemas and structured content
On the way out, the rule from the Associate courses holds and gets stricter: code reads structuredContent; the model reads whatever the host shows it. When you run your own server, you also decide what goes in both.
Do not pass an APIVerve envelope through unchanged. Project it. A tool's job is to answer the model's question, and most responses carry far more than that. Declare an outputSchema for the projection, return the projected object as structuredContent, and put the same object, serialized, in the text block so older clients and the model see identical data. Three rules keep the projection honest:
- Keep
nullasnull. A premium field on a lower plan, or a value the input did not produce, comes back asnull. Your projection must not turn it into0,falseor an empty string, and the schema should type it as nullable. - Read every path defensively.
formatted?.e164 ?? null, notformatted.e164. A nested object can itself benull. - Keep the field descriptions. The output schema's descriptions are how a model learns that
valid: nullmeans "not checked", not "invalid".
Errors need the same normalisation. The APIVerve error field is a string in most responses, but validation failures can carry an object. A missing required parameter returns {"missing": ["number"]}, and a value of the wrong kind returns {"invalid": ["number: must be a valid number"]}. A tool layer that interpolates body.error into a sentence prints [object Object]. Normalise once:
// Node 18+, ESM. APIVerve error fields can be strings or objects such as { missing: [...] }.
export function errorText(error) {
if (error == null) return 'unknown error';
if (typeof error === 'string') return error;
const parts = [];
if (Array.isArray(error.missing)) parts.push(`missing: ${error.missing.join(', ')}`);
if (Array.isArray(error.invalid)) parts.push(`invalid: ${error.invalid.join('; ')}`);
return parts.length ? parts.join('. ') : JSON.stringify(error);
}Pass large outputs by reference
Some tools return more than a model should carry: page Markdown, a PDF's extracted text, a 200-row batch result. Pasting it into a tool result costs context on every later turn and, if the model then passes it to another tool, output tokens to copy it. The research agent in Build Agent Workflows solved that for one agent with a ledger of source ids. On a platform the store is shared infrastructure, and it needs four properties the single-agent version could skip:
- Tenant scope. A reference resolves only for the tenant, and ideally the run, that created it. A guessed or injected reference to another tenant's object must fail exactly as a missing one does.
- Content addressing. Key the object by a hash of its content, so the same page fetched twice is stored once and two agents can share it without copying.
- Expiry. References live for the run plus a review window, then disappear. Stored tool output is data you are now responsible for.
- A useful preview. The tool returns the reference with size, title and a short excerpt, so the model can decide what to do next without reading the whole object.
// Node 18+, ESM. A tenant-scoped, content-addressed store for large tool outputs.
import { createHash } from 'node:crypto';
const store = new Map(); // use Redis, R2 or your database in production
const TTL_MS = 24 * 60 * 60 * 1000;
export function putRef(ctx, value) {
const text = typeof value === 'string' ? value : JSON.stringify(value);
const id = `ref_${createHash('sha256').update(text).digest('hex').slice(0, 20)}`;
store.set(`${ctx.tenantId}:${id}`, { text, expires: Date.now() + TTL_MS });
return { ref: id, chars: text.length, preview: text.slice(0, 400) };
}
export function getRef(ctx, ref) {
const hit = store.get(`${ctx.tenantId}:${ref}`);
if (!hit || hit.expires < Date.now()) return null; // another tenant's ref looks exactly like a missing one
return hit.text;
}Tools that consume a reference take it as an ordinary string argument, so checkArgs validates it like any other, and getRef enforces the scope. In a multi-agent design the same pattern carries work between agents: a planner hands a worker a reference, never a pasted payload, and the handoff is cheap and auditable.
Errors as tool results the model can act on
A tool failure has three possible audiences: the model, your loop and a human. Get the routing right at the platform level and every agent inherits it.
| Failure | Who acts | What the result says |
|---|---|---|
Argument rejected by checkArgs, or a 400 | The model | What was wrong and how to fix it |
404 for no result on that input | The model | "Nothing found for X; do not retry the same input" |
429 with credits remaining, timeout, 5xx | Your tool layer retries; the model hears only if retries fail | "Temporarily unavailable; continue without it" |
401, 403, or 429 with x-api-remaining-credits of 0 | Your loop stops the run; a human is told | Nothing the model can fix, so the run ends |
That table only works if you can see the status and headers, which is the strongest argument for calling REST from your own tool handlers rather than relaying the hosted MCP server: the hosted server reports a failed call as an isError sentence and keeps the status and credit headers on its side. When you own the handler, attach a small machine-readable block to every error so the loop and the eval harness can branch on fields rather than on prose.
// Node 18+, ESM. One classification for every REST failure, readable by the model and by code.
import { errorText } from './error-text.mjs';
export function toToolError(toolName, res, body) {
const remaining = res.headers.get('x-api-remaining-credits');
const outOfCredits = res.status === 429 && remaining !== null && Number(remaining) === 0;
const msg = errorText(body?.error);
const c =
res.status === 401 || res.status === 403 || outOfCredits ? { kind: 'fatal', retry: false, say: 'This tool cannot be used right now. Stop and report it.' }
: res.status === 400 ? { kind: 'fix_args', retry: true, say: `Fix the arguments: ${msg}.` }
: res.status === 404 ? { kind: 'no_result', retry: false, say: `No result for this input (${msg}). Do not retry the same input.` }
: { kind: 'transient', retry: false, say: 'Temporarily unavailable. Continue without this result.' };
return {
isError: true,
fatal: c.kind === 'fatal',
content: [{ type: 'text', text: `${toolName}: ${c.say}\n${JSON.stringify({ kind: c.kind, status: res.status, retry: c.retry, cfRay: res.headers.get('cf-ray') })}` }],
};
}Two details in it come from hard experience. The credit check compares against null before converting, because Number(null) is 0 and a missing header would otherwise read as an empty balance. And the cf-ray value travels with the error, because when someone opens a support ticket about a failed run, that identifier is what lets the request be found.
Your tool handler builds its error message with ` Failed: ${body.error} ` and the model keeps answering "the API returned an unknown object". Which response caused it?
Next
Your tools now refuse bad input before it costs anything, return compact structured results, keep big payloads out of the context and fail in ways the loop can route. Next, decide what an agent may do without asking, and build the controls that hold when the model is wrong or has been misled: Human approval and guardrails for AI agents.