Design tools for agents at scale

Learn how to size agent tools, validate model-supplied arguments in code, return structured output, pass large results by reference and write errors an agent can act on.

  • 45 min
  • Expert

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.

check-phones.mjs
// 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:

  1. 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.
  2. The hosted MCP server's input schemas do not set additionalProperties: false, so an invented argument passes schema validation on the way in.
  3. APIVerve silently drops parameters an endpoint does not declare. There is no 400 for 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:

Try it livePhone Number Validator10 credits
GEThttps://api.apiverve.com/v1/phonenumbervalidator
curl "https://api.apiverve.com/v1/phonenumbervalidator?number=02079460958&country=gb" \
  -H "x-api-key: YOUR_API_KEY"
Sign in or create a free account to run this with your own 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.

check-args.mjs
// 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).
  • null counts as absent. Models often send "country": null for "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:

check-args.test.mjs
// 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.

execute.mjs
// 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 }] });
Check yourself

The phone schema above requires number and declares country. A model sends { "number": 4155552671, "region": "US" }. What does checkArgs return?

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales