Tool Calling Basics

ជំពូកនេះមិនទាន់ត្រូវបានបកប្រែជាភាសាខ្មែរនៅឡើយទេ — សូមអានជាភាសាអង់គ្លេសខាងក្រោម។

Table of Contents

  1. From a Hand-Made Call to Reusable Code
  2. Describing a Tool with JSON Schema
  3. Arguments Arrive as a String
  4. src/llm.js: One Model Call with Tools
  5. src/validate.js: Check the Arguments
  6. src/tools.js: Define, Describe, Run
  7. Errors Are Text the Model Can Read
  8. Big Outputs Get Cut
  9. The Demo Tools
  10. One Round Trip with the Registry
  11. A Model That Sends Broken JSON
  12. Parallel Tool Calls
  13. Tools vs Structured Output
  14. Where Real APIs Differ
  15. Recipe: The Modules
  16. Try It Yourself
  17. Quick Review: Common Questions

1. From a Hand-Made Call to Reusable Code

In What Is an Agent? you did one tool call by hand. It worked, but it was a pile of special cases: get_weather was hard-coded, nothing checked the arguments, and a single typo would have crashed the script. Real tool calling needs four small, boring, reliable pieces:

  1. A way to describe tools to the model (a name, a description and a schema for the arguments).
  2. A way to call the model with those descriptions.
  3. A way to check the arguments the model sends back.
  4. A way to run the tool and hand back a result, even when something went wrong.

By the end of this chapter you will have four new files in src/: llm.js, validate.js, tools.js and demo-tools.js. They sit beside the config.js and client.js you already know from the chatbot series. The next chapter wraps a loop around them to make a real agent.

You need the mock running, as in chapter 1, from the agentlab folder:

node mock-llm.js
# output
# mock LLM listening on http://localhost:8787  (api key: test-key)

Honest note about the mock. It picks a tool by matching words and fills arguments with simple patterns, so it is far less capable than a real model. It is good enough to test your code: the parsing, the validation, the error handling. Do not read its choices as a sign of how a real model behaves. If you use another port, set LLM_BASE_URL (for example LLM_BASE_URL=http://localhost:8880) before each demo.

2. Describing a Tool with JSON Schema

A tool description has three fields:

  • name: what the model writes when it wants the tool. Use snake_case and make it specific: get_weather, not weather or do.
  • description: plain English telling the model what the tool does and when to use it. This is the model's only manual. Treat it as an instruction, not a label (chapter 4 goes deep).
  • parameters: a JSON Schema describing the arguments.

JSON Schema is a standard way to describe the shape of JSON. For tools you only need a small part of it:

KeywordMeaningExample
typeThe kind of value"string", "number", "integer", "boolean", "object", "array"
propertiesThe named arguments and their schemas{ "city": { "type": "string" } }
requiredNames that must be present["city"]
enumThe only allowed values["c", "f"]
descriptionHelp text for the model"City name, for example Phnom Penh"

The top level is always type: "object", because the arguments are a JSON object. Here is a richer example, a weather tool that takes a city and an optional unit:

const parameters = {
  type: "object",
  properties: {
    city: { type: "string", description: "City name, for example Phnom Penh" },
    units: { type: "string", enum: ["c", "f"], description: "c for Celsius, f for Fahrenheit" },
  },
  required: ["city"],
};

units is optional because it is not in required. The enum says only "c" or "f" are valid. A short description on each property helps a real model fill it correctly. Real JSON Schema has many more keywords (minimum, pattern, oneOf, ...). Different providers support different subsets, so keep schemas simple and check your provider's documentation.

An important point: the schema is a request, not a guarantee. The model is asked to follow it. It usually does, but it can send the wrong type, forget a required field or invent a field. Your code must check (section 5).

3. Arguments Arrive as a String

In chapter 1 you saw that arguments is a string, not an object. That small detail causes many bugs. A model writes text token by token, and the text happens to be JSON. Sometimes it is not complete or valid JSON. For instance, the reply may be cut off by a length limit.

// parse-args.js: `arguments` is a string, and strings can be broken.
const good = '{"city":"Phnom Penh"}';
const broken = '{"city":"Phnom Penh"'; // the closing brace is missing

for (const text of [good, broken]) {
  try {
    console.log("parsed:", JSON.parse(text));
  } catch (err) {
    console.log("could not parse:", err.name);
  }
}
console.log(typeof good);
node parse-args.js
# output
# parsed: { city: 'Phnom Penh' }
# could not parse: SyntaxError
# string

So the rule is: always wrap JSON.parse in try/catch, and decide what to do on failure. The best answer, as you will see, is to tell the model what went wrong and let it try again.

4. src/llm.js: One Model Call with Tools

llm.js is the same call as chat() in the chatbot series, with one difference: it accepts tools and returns the whole assistant message, not just the text. You need the entire message, because it may contain tool_calls, and you must send that same message back later.

// src/llm.js (key part)
export async function complete({ messages, tools, model = defaultModel, maxTokens, signal }) {
  const res = await fetch(`${baseUrl}/v1/chat/completions`, { /* method, headers, body */ });
  if (!res.ok) throw await readError(res);
  const data = await res.json();
  const choice = data.choices[0];
  return { message: choice.message, finishReason: choice.finish_reason, usage: data.usage };
}

The full file is in the recipe (section 15). It only adds tools to the body when the list is not empty, and it passes an optional signal so you can cancel a call (chapter 3). Errors from the API (wrong key, rate limit, server error) still become an ApiError, using readError from client.js, exactly as in Errors, Retries and Cost.

Try it. This demo uses the tools from sections 6 and 9 (just copy the recipe files). It calls the model once and prints the three things complete returns:

// llm-demo.js: complete() returns the message, the finish reason and usage.
import { complete } from "./src/llm.js";
import { toApiTools } from "./src/tools.js";
import { makeDemoTools } from "./src/demo-tools.js";

const tools = toApiTools(makeDemoTools());
const { message, finishReason, usage } = await complete({
  messages: [{ role: "user", content: "What is the weather in Takeo?" }],
  tools,
});
console.log(finishReason);
console.log(message.tool_calls[0].function);
console.log(usage);
node llm-demo.js
# output
# tool_calls
# { name: 'get_weather', arguments: '{"city":"Takeo"}' }
# { prompt_tokens: 8, completion_tokens: 26, total_tokens: 34 }

The tool name and arguments are stable for the mock. The token counts are the mock's estimates, so they only change if you change the prompt or the tool list. Real token counts will differ.

5. src/validate.js: Check the Arguments

Before running a tool, check that the arguments match the schema. You could use a library, but the part of JSON Schema that tools need is small, and writing it yourself shows what is going on. validateArgs(schema, args) returns a list of problems. An empty list means "valid".

// validate-demo.js: the validator turns bad arguments into a list of problems.
import { validateArgs } from "./src/validate.js";

const schema = {
  type: "object",
  properties: {
    city: { type: "string" },
    units: { type: "string", enum: ["c", "f"] },
    days: { type: "integer" },
  },
  required: ["city"],
};

const cases = [
  { city: "Siem Reap" },
  {},
  { city: 123 },
  { city: "Takeo", units: "kelvin" },
  { city: "Takeo", days: 2.5, extra: true },
  "not an object",
];
for (const args of cases) {
  console.log(JSON.stringify(args), "->", validateArgs(schema, args));
}
node validate-demo.js
# output
# {"city":"Siem Reap"} -> []
# {} -> [ 'missing required argument "city"' ]
# {"city":123} -> [ '"city" must be string, got number' ]
# {"city":"Takeo","units":"kelvin"} -> [ '"units" must be one of: c, f' ]
# {"city":"Takeo","days":2.5,"extra":true} -> [ '"days" must be integer, got number', 'unknown argument "extra"' ]
# "not an object" -> [ 'arguments must be a JSON object' ]

Notice that the messages are written for a model to read, not just for a developer: they name the argument, say what was expected and what was received, and list the allowed values. A message like invalid input would give the model nothing to fix. We turn this list into an error message in the next section.

What the validator does not do: nested objects, arrays of items, minimum or pattern checks, oneOf. It covers what the demo tools need. For real projects with complex schemas, use a proper JSON Schema library.

6. src/tools.js: Define, Describe, Run

tools.js has three exports. They are short, so read them in full in the recipe. Here is what each one is for.

defineTool({ name, description, parameters, run }) creates a tool object. run is an ordinary JavaScript function that receives the parsed arguments, and may be async. If you give no parameters, the tool takes no arguments. The registry is just an array of these tool objects.

toApiTools(tools) converts the array into the tools field the API expects: { type: "function", function: { name, description, parameters } }. Your run functions are not sent. Only the descriptions are.

runToolCall(call, tools, { maxChars }) takes one entry from the model's tool_calls and does everything in order:

  1. Find the tool by name (unknown name: error).
  2. JSON.parse the arguments (invalid JSON: error).
  3. Validate against the schema (problems: error).
  4. Run the tool (it threw: error).
  5. Turn the result into a string and cut it if it is too long.

It returns { content, isError }. It never throws. That is the key design choice, and the next section explains why.

7. Errors Are Text the Model Can Read

What should happen when a tool call fails? Think about the loop. If your program crashes, the user sees a stack trace and the task is lost. But if the failure becomes a normal tool result that says what went wrong, the model can read it and try again, perhaps with the right argument or a different tool. A failed call is just information.

Run every kind of failure and see what comes back:

// run-demo.js: runToolCall never throws. Every failure is text.
import { runToolCall, defineTool } from "./src/tools.js";
import { makeDemoTools } from "./src/demo-tools.js";

const tools = makeDemoTools();
const call = (name, args) => ({ id: "c1", type: "function", function: { name, arguments: args } });

const cases = [
  ["works", call("get_weather", '{"city":"Takeo"}')],
  ["unknown tool", call("get_wether", '{"city":"Takeo"}')],
  ["broken JSON", call("get_weather", '{"city":"Takeo"')],
  ["missing argument", call("get_weather", "{}")],
  ["wrong type", call("add", '{"a":"two","b":3}')],
  ["tool threw", call("get_weather", '{"city":"Paris"}')],
  ["no arguments", call("list_notes", "")],
];
for (const [label, c] of cases) {
  console.log(label.padEnd(17), JSON.stringify(await runToolCall(c, tools)));
}

// Big outputs are cut so one tool cannot flood the conversation.
const big = defineTool({ name: "big", description: "Return a lot of text", run: () => "x".repeat(50) });
const out = await runToolCall(call("big", "{}"), [big], { maxChars: 20 });
console.log(out);
node run-demo.js
# output
# works             {"content":"Takeo: 32C, cloudy","isError":false}
# unknown tool      {"content":"Error: unknown tool \"get_wether\". Available tools: get_weather, add, save_note, read_note, list_notes","isError":true}
# broken JSON       {"content":"Error: the arguments were not valid JSON. Send one complete JSON object.","isError":true}
# missing argument  {"content":"Error: missing required argument \"city\"","isError":true}
# wrong type        {"content":"Error: \"a\" must be number, got string","isError":true}
# tool threw        {"content":"Error: no weather data for \"Paris\". Known cities: Phnom Penh, Siem Reap, Takeo","isError":true}
# no arguments      {"content":"(no notes yet)","isError":false}
# {
#   content: 'xxxxxxxxxxxxxxxxxxxx\n[truncated 30 characters]',
#   isError: false
# }

Every output is stable. Look at what each message offers the model:

  • The unknown tool error lists the available names, so the model can correct a typo.
  • The tool threw error comes from the tool itself ("Known cities: ..."). Writing helpful error messages inside your tools is a big part of tool design. Chapter 4 compares good and bad errors.
  • Everything starts with Error:. That is a convention the mock relies on: if a tool result starts with Error, it says "I could not finish". Real models read the word the same way. The prefix also lets your own code detect failures with isError.
  • A call with empty arguments ("") is treated as {}, because some models send nothing for tools without parameters.

One more rule: never include secrets or stack traces in error text. The model, and the user, will see it. err.message is usually fine. err.stack and anything holding a key or a file path you would rather not expose is not.

8. Big Outputs Get Cut

The last case in the demo shows truncation. A tool may return a huge result: a log file, a web page, a database dump. Everything a tool returns goes into the conversation, and the whole conversation is sent on every later call. A 100 KB result costs money and time again and again, and can push older messages out of the model's context window (see Context Windows and Memory).

So runToolCall cuts results to maxChars (default 4000) and adds a note saying how much was removed. Note the model also sees the note: [truncated 30 characters] tells it that the data is incomplete, so it can ask for a narrower query. Better still, design tools that return small answers in the first place (chapter 4).

Characters are not tokens (a token is often a few characters, but it varies by language and content). Treat maxChars as a safety limit, not a precise budget.

9. The Demo Tools

demo-tools.js exports makeDemoTools(), which builds five small tools used by almost every demo in the series:

ToolArgumentsReturns
get_weathercityFake data for Phnom Penh, Siem Reap and Takeo
adda, b (numbers)The sum, as text
save_notetitle, contentSaved note "title".
read_notetitleThe saved content, or an error
list_notesnoneThe saved titles

The weather is fake on purpose. We are learning the plumbing, not calling a weather service. Notes are kept in a Map inside the function, so each call to makeDemoTools() gives you a fresh, empty notebook. That makes demos and tests start clean. get_weather throws when the city is unknown, and read_note throws when a title is missing, which is exactly what we want to demonstrate: a tool that fails and a message the model can use.

10. One Round Trip with the Registry

Now the chapter 1 script becomes short. It sends the tools, runs whatever the model asked for, and sends the results back:

// first-tool-call.js: one round trip, now with the registry and runToolCall.
import { complete } from "./src/llm.js";
import { toApiTools, runToolCall } from "./src/tools.js";
import { makeDemoTools } from "./src/demo-tools.js";

const tools = makeDemoTools();
const messages = [{ role: "user", content: "Save a note called \"todo\" with \"buy milk\"" }];

const first = await complete({ messages, tools: toApiTools(tools) });
messages.push(first.message);

for (const call of first.message.tool_calls ?? []) {
  const result = await runToolCall(call, tools);
  console.log(call.function.name, call.function.arguments, "=>", result.content);
  messages.push({ role: "tool", tool_call_id: call.id, content: result.content });
}

const second = await complete({ messages, tools: toApiTools(tools) });
console.log(second.finishReason, "-", second.message.content);
node first-tool-call.js
# output
# save_note {"title":"todo","content":"buy milk"} => Saved note "todo".
# stop - Result: Saved note "todo".

Four rules show up in these few lines, and they hold for any provider:

  1. Push the assistant message exactly as received (messages.push(first.message)) before the tool results.
  2. One role: "tool" message per tool call, each with the matching tool_call_id.
  3. content is a string. Convert objects with JSON.stringify (runToolCall already does).
  4. Always answer a call, even with an error. A tool call with no matching result is usually rejected by real APIs, and a model that gets no answer cannot continue.

The for ... of first.message.tool_calls ?? [] also handles the case where the model gave a plain answer and there are no calls at all.

11. A Model That Sends Broken JSON

The mock has a special model, mock-sloppy. On its first attempt it sends truncated, invalid JSON as arguments. After it reads an Error: tool result it retries correctly. This simulates real failures, and lets you test the repair path:

// sloppy-demo.js: a model that sends broken JSON, and an error it can read.
import { complete } from "./src/llm.js";
import { toApiTools, runToolCall } from "./src/tools.js";
import { makeDemoTools } from "./src/demo-tools.js";

const tools = makeDemoTools();
const messages = [{ role: "user", content: "What is the weather in Takeo?" }];

for (let round = 1; round <= 3; round++) {
  const { message, finishReason } = await complete({ messages, tools: toApiTools(tools), model: "mock-sloppy" });
  messages.push(message);
  if (finishReason !== "tool_calls") {
    console.log(`round ${round}: final answer: ${message.content}`);
    break;
  }
  for (const call of message.tool_calls) {
    const result = await runToolCall(call, tools);
    console.log(`round ${round}: ${call.function.arguments} => ${result.content}`);
    messages.push({ role: "tool", tool_call_id: call.id, content: result.content });
  }
}
node sloppy-demo.js
# output
# round 1: {"city":"Takeo" => Error: the arguments were not valid JSON. Send one complete JSON object.
# round 2: {"city":"Takeo"} => Takeo: 32C, cloudy
# round 3: final answer: Result: Takeo: 32C, cloudy

Round 1 is the broken call, answered with an error. Round 2 is the same call, now correct, because the model read the error and tried again. Round 3 is the final answer. That tiny script is already the agent loop. Chapter 3 turns it into a proper function with limits.

If runToolCall had thrown instead, round 2 would never have happened. That is the payoff for "errors are text".

The mock's retry is scripted. A real model may fix the problem, fix it differently, or repeat the same mistake. This is why the loop needs a step limit.

12. Parallel Tool Calls

So far every reply had one tool call. Real models are allowed to ask for several in the same message, when the calls do not depend on each other: "weather in Phnom Penh and in Siem Reap". The reply then holds a tool_calls array with two or more entries. The mock returns only one at a time, so you cannot see this from the mock. Here is a message written by hand, in the shape a real model may return, and how to answer it:

// parallel-demo.js: an assistant message with TWO tool calls needs TWO answers.
import { runToolCall } from "./src/tools.js";
import { makeDemoTools } from "./src/demo-tools.js";

const tools = makeDemoTools();
// Written by hand, in the shape a real model may return.
const assistant = {
  role: "assistant",
  content: null,
  tool_calls: [
    { id: "call_a", type: "function", function: { name: "get_weather", arguments: '{"city":"Phnom Penh"}' } },
    { id: "call_b", type: "function", function: { name: "get_weather", arguments: '{"city":"Siem Reap"}' } },
  ],
};

const results = await Promise.all(assistant.tool_calls.map((c) => runToolCall(c, tools)));
const toolMessages = assistant.tool_calls.map((c, i) => ({
  role: "tool",
  tool_call_id: c.id,
  content: results[i].content,
}));
console.log(toolMessages);
node parallel-demo.js
# output
# [
#   {
#     role: 'tool',
#     tool_call_id: 'call_a',
#     content: 'Phnom Penh: 33C, sunny'
#   },
#   {
#     role: 'tool',
#     tool_call_id: 'call_b',
#     content: 'Siem Reap: 31C, light rain'
#   }
# ]

This is where the tool_call_id earns its keep: it matches each result to its request, so the order does not have to be fixed, and the model can tell which answer belongs to which question. The rules:

  • Answer every call in the message, one role: "tool" message each.
  • Run them one after another if they touch the same data or have side effects. Use Promise.all only for independent, safe reads, as above.
  • Chapter 3's loop uses a plain for ... of, which handles one call or five without special code.

Some providers let you turn parallel calls off, or return them in a different order. Check your provider's documentation.

13. Tools vs Structured Output

Two features are easy to confuse. Both make the model produce JSON, but for different reasons:

  • Structured output: you want the answer itself in a fixed shape, for example { "sentiment": "positive", "score": 0.9 }, so your code can read it. Nothing is executed. The JSON is the final result. (See Prompts, Roles and Structured Output.)
  • Tool calling: you want the model to ask for an action, such as reading a file or saving a note. The JSON is a request. Your code runs it and the conversation continues.

A simple test: does something happen in the world, or does the model need information it does not have? If yes, use a tool. If you only need the model to answer in a certain format, use structured output. You can combine them: the agent calls tools, and the final answer is requested in a fixed shape.

A trick people use: define a tool such as submit_answer whose schema is the shape you want, and treat the call as the final result. It works, but note that this is a pattern built on tool calling, not a separate feature. Check what your provider offers before choosing.

14. Where Real APIs Differ

This series uses a chat-completions-style shape: a tools array of { type: "function", function: { name, description, parameters } }, a reply with message.tool_calls, and role: "tool" messages that answer them. Many providers and local servers follow this shape, but not all do, and even those that do vary in details. Things that commonly differ:

  • Field names. Some APIs call the schema parameters, others use a different name, and some put tool calls in a content block instead of tool_calls. In some APIs a tool result is a content block inside a user message, not a role: "tool" message.
  • arguments as a string or an object. In chat-completions style it is a JSON string. Some APIs already give you a parsed object. Check, and still handle errors.
  • Finish reasons, ids and token counters have different names.
  • Streaming of tool calls: real APIs send tool names and arguments in pieces that you must assemble. The mock does not stream with tools.
  • Forcing a tool. Many APIs let you require a specific tool, forbid tools, or let the model choose (often called tool_choice).
  • Limits on the number of tools, the size of descriptions, and which parts of JSON Schema are accepted.

The fix is the same as in the chatbot series: keep everything that touches the provider in one small file (llm.js). If you switch providers, you change one file, and the agent loop, the tools and the tests stay as they are. Read your provider's documentation for the exact format, and re-run your tests when it changes.

15. Recipe: The Modules

All files go in src/. src/config.js and src/client.js are the ones from the chatbot series (What an LLM API Is and Errors, Retries and Cost). This chapter only needs config.js (the three settings baseUrl, apiKey, defaultModel) and ApiError and readError from client.js. The four new files follow.

src/llm.js

import { baseUrl, apiKey, defaultModel } from "./config.js";
import { readError } from "./client.js";

// One model call that may include tools. Returns the assistant message as-is.
export async function complete({ messages, tools, model = defaultModel, maxTokens, signal }) {
  const res = await fetch(`${baseUrl}/v1/chat/completions`, {
    method: "POST",
    headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
    body: JSON.stringify({
      model,
      messages,
      ...(tools?.length && { tools }),
      ...(maxTokens && { max_tokens: maxTokens }),
    }),
    signal,
  });
  if (!res.ok) throw await readError(res);
  const data = await res.json();
  const choice = data.choices[0];
  return { message: choice.message, finishReason: choice.finish_reason, usage: data.usage };
}

src/validate.js

// A tiny checker for the part of JSON Schema that tool parameters use.
// Returns a list of problems (empty list = valid).
export function validateArgs(schema, args) {
  const problems = [];
  if (typeof args !== "object" || args === null || Array.isArray(args)) {
    return ["arguments must be a JSON object"];
  }
  const props = schema?.properties ?? {};
  for (const name of schema?.required ?? []) {
    if (args[name] === undefined) problems.push(`missing required argument "${name}"`);
  }
  for (const [name, value] of Object.entries(args)) {
    const def = props[name];
    if (!def) {
      problems.push(`unknown argument "${name}"`);
      continue;
    }
    const type = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
    const okType =
      def.type === "integer" ? Number.isInteger(value) : def.type === type;
    if (def.type && !okType) problems.push(`"${name}" must be ${def.type}, got ${type}`);
    if (def.enum && !def.enum.includes(value)) {
      problems.push(`"${name}" must be one of: ${def.enum.join(", ")}`);
    }
  }
  return problems;
}

src/tools.js

import { validateArgs } from "./validate.js";

// defineTool: a tool is a name, a description for the model, a schema for
// the arguments, and a normal JavaScript function that does the work.
export function defineTool({ name, description, parameters, run }) {
  return {
    name,
    description,
    parameters: parameters ?? { type: "object", properties: {} },
    run,
  };
}

// The shape a chat-completions-style API expects in the `tools` field.
export const toApiTools = (tools) =>
  tools.map((t) => ({
    type: "function",
    function: { name: t.name, description: t.description, parameters: t.parameters },
  }));

// Run one tool call from the model. Never throws: failures come back as an
// "Error: ..." string, so the model can read the message and try again.
export async function runToolCall(call, tools, { maxChars = 4000 } = {}) {
  const tool = tools.find((t) => t.name === call.function.name);
  if (!tool) {
    const names = tools.map((t) => t.name).join(", ");
    return { content: `Error: unknown tool "${call.function.name}". Available tools: ${names}`, isError: true };
  }
  let args;
  try {
    args = JSON.parse(call.function.arguments || "{}");
  } catch {
    return { content: "Error: the arguments were not valid JSON. Send one complete JSON object.", isError: true };
  }
  const problems = validateArgs(tool.parameters, args);
  if (problems.length) return { content: `Error: ${problems.join("; ")}`, isError: true };
  try {
    const result = await tool.run(args);
    let content = typeof result === "string" ? result : JSON.stringify(result);
    if (content.length > maxChars) content = content.slice(0, maxChars) + `\n[truncated ${content.length - maxChars} characters]`;
    return { content, isError: false };
  } catch (err) {
    return { content: `Error: ${err.message}`, isError: true };
  }
}

src/demo-tools.js

import { defineTool } from "./tools.js";

// Fake data on purpose: the point is the plumbing, not a real weather service.
const WEATHER = {
  "Phnom Penh": "33C, sunny",
  "Siem Reap": "31C, light rain",
  "Takeo": "32C, cloudy",
};

export function makeDemoTools() {
  const notes = new Map();
  return [
    defineTool({
      name: "get_weather",
      description: "Get the current weather for a city",
      parameters: {
        type: "object",
        properties: { city: { type: "string", description: "City name, for example Phnom Penh" } },
        required: ["city"],
      },
      run: ({ city }) => {
        if (!(city in WEATHER)) throw new Error(`no weather data for "${city}". Known cities: ${Object.keys(WEATHER).join(", ")}`);
        return `${city}: ${WEATHER[city]}`;
      },
    }),
    defineTool({
      name: "add",
      description: "Add two numbers",
      parameters: {
        type: "object",
        properties: { a: { type: "number" }, b: { type: "number" } },
        required: ["a", "b"],
      },
      run: ({ a, b }) => String(a + b),
    }),
    defineTool({
      name: "save_note",
      description: "Save a note with a title and content",
      parameters: {
        type: "object",
        properties: { title: { type: "string" }, content: { type: "string" } },
        required: ["title", "content"],
      },
      run: ({ title, content }) => {
        notes.set(title, content);
        return `Saved note "${title}".`;
      },
    }),
    defineTool({
      name: "read_note",
      description: "Read a saved note by its title",
      parameters: { type: "object", properties: { title: { type: "string" } }, required: ["title"] },
      run: ({ title }) => {
        if (!notes.has(title)) throw new Error(`no note titled "${title}"`);
        return notes.get(title);
      },
    }),
    defineTool({
      name: "list_notes",
      description: "List the titles of all saved notes",
      run: () => (notes.size ? [...notes.keys()].join(", ") : "(no notes yet)"),
    }),
  ];
}

16. Try It Yourself

  1. Add a tool. Write word_count with one required string argument text, returning the number of words. Add it to the array that makeDemoTools() returns (or build your own array) and test it with runToolCall and a hand-made call.
  2. Add an enum. Give get_weather an optional units argument with enum: ["c", "f"]. Make run convert to Fahrenheit when asked. Check that {"city":"Takeo","units":"kelvin"} is rejected with a clear message.
  3. Write worse errors. Change the get_weather error to just "failed". Run sloppy-demo.js-style code with a bad city and read what the model would see. Why is the original message better?
  4. Truncation. Set maxChars to 10 in runToolCall and run get_weather. What does the model receive? When would a very small limit hurt, and when would it help?
  5. Parallel by hand. Extend parallel-demo.js with a third call to an unknown city. Does Promise.all still produce three tool messages? Why is it important that all three exist?
  6. Read the schema sent. Print JSON.stringify(toApiTools(makeDemoTools())[0], null, 2). Which fields of the tool object were sent to the model, and which were left behind in your program?

17. Quick Review: Common Questions

Questions beginners often ask about this chapter. Try answering each one yourself first, then click the question to open the answer.

Why is arguments a string and not an object?

Because the model generates text, and the API passes that text through. The JSON may be incomplete or invalid, so providers keep it as a string and leave parsing to you. Some APIs parse it for you, so check the documentation, and always handle bad input anyway.

Does the model check my JSON Schema?

It is shown the schema and usually follows it, but nothing forces it to. Some providers can constrain the output more strictly, but you should not rely on that alone. Always validate on your side before running a tool. Treat the model's output like input from a stranger.

Why return errors as text instead of throwing exceptions?

An exception would end the loop. A text error becomes part of the conversation, and a good model can read it and correct the call, like fixing a typo or choosing a different city. Your program stays in control and the user sees a smoother result.

What if the model asks for a tool that does not exist?

It happens, especially with many similar tool names. Return an error that lists the real tool names, as runToolCall does. That gives the model what it needs to fix the mistake. Never run code based on a name you did not register.

What is the difference between tool calling and structured output?

Structured output makes the answer a fixed JSON shape that your code reads. Tool calling makes the model request an action that your code performs, after which the conversation continues. If something must happen, or the model needs data it lacks, use a tool.

Can the model really call several tools at once?

Many real APIs allow it. The assistant message then has several tool_calls, and you must answer each one with its own role: "tool" message and matching tool_call_id. The mock does not do this, so we test it with hand-written messages.

Do these exact field names work with every provider?

No. The chat-completions-style shape is common, but names, the place the tool result goes, and streaming details vary. Keep provider-specific code in one file, read the provider's documentation, and test again whenever you switch.

Where to go next. You can now describe tools, call the model, parse and validate the arguments, run a tool safely, and send the result back. You even saw a tiny loop in sloppy-demo.js. In The Agent Loop you turn that loop into runAgent: one function with a step limit, a trace you can read, a token tally, and clear stop conditions.

ទិញកាហ្វេជូនខ្ញុំ

ចូលចិត្តមេរៀននេះមែនទេ? សូមពិចារណាទិញកាហ្វេជូនខ្ញុំមួយកែវ ដើម្បីគាំទ្រការបង្កើតមាតិកាឥតគិតថ្លៃបន្ថែមទៀត!