Module 7 · Tools, Skills & Plugins ⏱ 18 min

The Anatomy of a Tool Definition

By the end of this lesson you will be able to:
  • Take a tool definition apart into its name, description, and JSON-Schema inputSchema, and say what each field is for
  • Validate a proposed call against a schema: every required argument present, every present argument correctly typed
  • Avoid the classic traps: checking presence with truthiness, and trusting typeof for arrays and null

A model cannot call a tool it cannot describe. Before an agent can ever invoke search_web or add, something has to tell the model the tool exists, explain what it does, and declare exactly which arguments it accepts. That something is a tool definition — a small, structured description that serves two masters at once. The model reads it to decide whether to call the tool; your code reads it to check that the call the model eventually makes is well-formed.

This lesson takes a definition apart into its three fields, shows what each one is for, and then does the step beginners skip: it validates a proposed call against the definition before that call reaches the real function. Skipping that check is how a missing argument, or a string sent where a number belonged, turns into a crash buried inside a tool the model cannot see — or worse, into a silent wrong answer the model then trusts.

flowchart LR
  T["tool definition"] --> N["name<br/>the call identifier"]
  T --> D["description<br/>the model reads this"]
  T --> S["inputSchema<br/>properties and required"]
  style N fill:#1d4ed8,color:#fff
  style D fill:#7c3aed,color:#fff
  style S fill:#166534,color:#fff
A tool definition has three fields, and each speaks to a different reader.

The three fields

A definition carries three fields, and each does a different job. The name is the identifier the model puts in its call — add, search_web — matched exactly, so a typo here means a tool that is never reached. The description is a sentence or two of plain text the model reads when judging whether the tool fits the moment; it is the only pitch the tool gets, so a vague one earns no calls. The inputSchema is a JSON Schema describing the arguments: a properties map naming each argument alongside its type, and a required list naming the arguments a call must supply. The first two fields speak to the model; the schema speaks to the validator you are about to write.

A tool definition and a helper that reads its name and required arguments. Press Run.
const add = {
  name: 'add',
  description: 'Add two numbers and return the sum',
  inputSchema: {
    type: 'object',
    properties: { a: { type: 'number' }, b: { type: 'number' } },
    required: ['a', 'b']
  }
};

function signature(tool) {
  return tool.name + '(' + (tool.inputSchema.required || []).join(', ') + ')';
}

console.log(signature(add));
console.log(add.inputSchema.properties.a.type);

Why validate the call at all

The model does its best to fill in arguments, but it is not a type checker. Left unchecked, it will happily send a string where your function expected a number, omit a required argument entirely, or pass null for a field that must exist. If those arguments flow straight into the function, you get a crash buried inside tool code — or, worse, a computation that runs to completion on garbage and returns a confidently wrong result.

The defence is a validator: a function that compares the model's arguments against the schema before dispatch and rejects anything that does not match. The schema already declares what a correct call looks like, so the validator is simply the code that enforces that declaration. One document, two audiences — the model reads the description, your validator reads the schema.

flowchart LR
  D["one tool definition"] --> M["model reads description<br/>decides whether to call"]
  D --> V["validator reads inputSchema<br/>checks the call is well-formed"]
  style M fill:#7c3aed,color:#fff
  style V fill:#166534,color:#fff
One definition, two readers: the description guides the model's choice, the inputSchema drives the validator's checks.

Matching a value to a type

Validation has two halves. Every required argument must be present, and every argument that is present must have the right type. Presence is checked with the in operator — name in args — which is true the moment the key exists, whatever value it holds. Type-checking maps a JSON-Schema type to a JavaScript test: number, string, and boolean are direct typeof checks, but array needs Array.isArray, because in JavaScript an array also reports typeof as 'object'. That quirk — typeof [] === 'object' and typeof null === 'object' — is exactly why a naive checker passes arrays off as objects and lets null slip through as a valid object. The helper below pins each type down precisely.

A type matcher plus a validator that checks presence and type for every required argument. Press Run.
function matchesType(value, type) {
  if (type === 'array')   return Array.isArray(value);
  if (type === 'number')  return typeof value === 'number';
  if (type === 'string')  return typeof value === 'string';
  if (type === 'boolean') return typeof value === 'boolean';
  return true;
}

function validateCall(tool, args) {
  const schema = tool.inputSchema;
  const props = schema.properties || {};
  for (const name of schema.required || []) {
    if (!(name in args)) return false;
    if (!matchesType(args[name], props[name] && props[name].type)) return false;
  }
  return true;
}

const add = {
  name: 'add',
  description: 'sum two numbers',
  inputSchema: {
    type: 'object',
    properties: { a: { type: 'number' }, b: { type: 'number' } },
    required: ['a', 'b']
  }
};

console.log(validateCall(add, { a: 2, b: 3 }));
console.log(validateCall(add, { a: 2 }));
console.log(validateCall(add, { a: 'x', b: 3 }));

Optional arguments and defaults

Anything absent from required is optional, so the validator skips the presence check for it — but it still type-checks the value when the caller supplies one. A useful schema also marks a default on optional properties, so a missing limit can be filled in as 10 before dispatch instead of arriving as undefined. Filling defaults is a separate step from validation: validate first, merge defaults second, then hand the cleaned arguments to the function. Keeping those steps apart is what lets one definition feed the model, the validator, and the dispatcher without the three tangling — the model never sees the defaults, the validator never applies them, and the dispatcher can rely on them.

Merging defaults: a missing optional-with-default is filled in; an explicit value is kept. Press Run.
function applyDefaults(tool, args) {
  const out = Object.assign({}, args);
  const props = tool.inputSchema.properties || {};
  for (const name of Object.keys(props)) {
    if (!(name in out) && props[name] && 'default' in props[name]) {
      out[name] = props[name].default;
    }
  }
  return out;
}

const search = {
  name: 'search',
  description: 'Search the docs',
  inputSchema: {
    type: 'object',
    properties: {
      query: { type: 'string' },
      limit: { type: 'number', default: 10 }
    },
    required: ['query']
  }
};

console.log(applyDefaults(search, { query: 'cats' }).limit);
console.log(applyDefaults(search, { query: 'cats', limit: 3 }).limit);
flowchart TD
  C["model proposes a call"] --> CHECK["check each required arg:<br/>present and correctly typed"]
  CHECK -->|"all pass"| OK["accept and dispatch"]
  CHECK -->|"any fail"| BAD["reject before dispatch"]
  style OK fill:#166534,color:#fff
  style BAD fill:#b91c1c,color:#fff
Validation is a gate before dispatch: pass every required-arg check to accept, fail any to reject.
Exercise

What does this print? The signature is built from the required list, and the property type is read straight from the schema.

const tool = {
  name: 'search',
  inputSchema: {
    type: 'object',
    properties: { query: { type: 'string' }, limit: { type: 'number' } },
    required: ['query']
  }
};
function signature(t) { return t.name + '(' + t.inputSchema.required.join(', ') + ')'; }
console.log(signature(tool));
console.log(tool.inputSchema.properties.limit.type);
Exercise

Write requiredArgs(tool) that returns the tool's required array, or an empty array if the schema declares none.

function requiredArgs(tool) {
  // return the required array, or [] if absent
}
Exercise

Write validateCall(tool, args) that returns true only when every required argument is present (test with in) AND matches its declared type. The matchesType helper and the tool are already defined for you — focus on the loop.

function matchesType(value, type) {
  if (type === 'array') return Array.isArray(value);
  if (type === 'number') return typeof value === 'number';
  if (type === 'string') return typeof value === 'string';
  if (type === 'boolean') return typeof value === 'boolean';
  return true;
}
const tool = {
  name: 'create_user',
  inputSchema: {
    type: 'object',
    properties: { email: { type: 'string' }, roles: { type: 'array' } },
    required: ['email', 'roles']
  }
};
function validateCall(tool, args) {
  // every required arg must be present (use `in`) and match its type
}
Exercise

This validator should return true when every required argument is supplied. But it checks !args[name], which treats 0, '', and false as missing — so a valid call with count: 0 is wrongly rejected. Fix it to test whether the key EXISTS, not whether its value is truthy.

const tool = {
  inputSchema: { properties: { count: { type: 'number' } }, required: ['count'] }
};
function validateCall(tool, args) {
  for (const name of tool.inputSchema.required) {
    if (!args[name]) return false;   // BUG: rejects count: 0
  }
  return true;
}
Exercise

Write applyDefaults(tool, args) that returns a NEW arguments object: any optional property that declares a default and is ABSENT from args gets filled in; an explicit value is always kept. The original args must not be mutated. The tool is already defined for you.

const tool = {
  name: 'search',
  inputSchema: {
    type: 'object',
    properties: { query: { type: 'string' }, limit: { type: 'number', default: 10 } },
    required: ['query']
  }
};
function applyDefaults(tool, args) {
  // return a new object; fill in defaults for absent optional-with-default props
}

Recap

  • A tool definition has three fields: a name (the call identifier), a description (the model's only cue for when to call), and an inputSchema (a JSON Schema the validator enforces).
  • One definition serves two audiences: the model reads the description, your validator reads the schema.
  • Validate before dispatch: every required argument must be present, and every present argument must match its type.
  • Test presence with in, never with truthiness — 0, '', and false are valid values that happen to be falsy.
  • typeof [] and typeof null are both 'object', so check array with Array.isArray and never trust typeof alone for objects.
  • Keep the steps apart: validate, then merge defaults, then dispatch.

Next you will put definitions to work inside the loop that actually runs them — reading a model's tool request, executing it, and feeding the result back.

Checkpoint quiz

What does the description field of a tool definition do that the inputSchema does not?

A validator uses if (!args[name]) return false to check a required argument. Which valid call does it wrongly reject?

Go deeper — technical resources