Tool Calling Basics
ជំពូកនេះមិនទាន់ត្រូវបានបកប្រែជាភាសាខ្មែរនៅឡើយទេ — សូមអានជាភាសាអង់គ្លេសខាងក្រោម។
Table of Contents
- From a Hand-Made Call to Reusable Code
- Describing a Tool with JSON Schema
- Arguments Arrive as a String
- src/llm.js: One Model Call with Tools
- src/validate.js: Check the Arguments
- src/tools.js: Define, Describe, Run
- Errors Are Text the Model Can Read
- Big Outputs Get Cut
- The Demo Tools
- One Round Trip with the Registry
- A Model That Sends Broken JSON
- Parallel Tool Calls
- Tools vs Structured Output
- Where Real APIs Differ
- Recipe: The Modules
- Try It Yourself
- 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:
- A way to describe tools to the model (a name, a description and a schema for the arguments).
- A way to call the model with those descriptions.
- A way to check the arguments the model sends back.
- 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 exampleLLM_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. Usesnake_caseand make it specific:get_weather, notweatherordo.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:
| Keyword | Meaning | Example |
|---|---|---|
type | The kind of value | "string", "number", "integer", "boolean", "object", "array" |
properties | The named arguments and their schemas | { "city": { "type": "string" } } |
required | Names that must be present | ["city"] |
enum | The only allowed values | ["c", "f"] |
description | Help 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:
- Find the tool by name (unknown name: error).
JSON.parsethe arguments (invalid JSON: error).- Validate against the schema (problems: error).
- Run the tool (it threw: error).
- 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 withError, it says "I could not finish". Real models read the word the same way. The prefix also lets your own code detect failures withisError. - 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:
| Tool | Arguments | Returns |
|---|---|---|
get_weather | city | Fake data for Phnom Penh, Siem Reap and Takeo |
add | a, b (numbers) | The sum, as text |
save_note | title, content | Saved note "title". |
read_note | title | The saved content, or an error |
list_notes | none | The 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:
- Push the assistant message exactly as received
(
messages.push(first.message)) before the tool results. - One
role: "tool"message per tool call, each with the matchingtool_call_id. contentis a string. Convert objects withJSON.stringify(runToolCallalready does).- 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.allonly 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 oftool_calls. In some APIs a tool result is a content block inside a user message, not arole: "tool"message. argumentsas 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
- Add a tool. Write
word_countwith one required string argumenttext, returning the number of words. Add it to the array thatmakeDemoTools()returns (or build your own array) and test it withrunToolCalland a hand-made call. - Add an enum. Give
get_weatheran optionalunitsargument withenum: ["c", "f"]. Makerunconvert to Fahrenheit when asked. Check that{"city":"Takeo","units":"kelvin"}is rejected with a clear message. - Write worse errors. Change the
get_weathererror to just"failed". Runsloppy-demo.js-style code with a bad city and read what the model would see. Why is the original message better? - Truncation. Set
maxCharsto10inrunToolCalland runget_weather. What does the model receive? When would a very small limit hurt, and when would it help? - Parallel by hand. Extend
parallel-demo.jswith a third call to an unknown city. DoesPromise.allstill produce three tool messages? Why is it important that all three exist? - 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.
ទិញកាហ្វេជូនខ្ញុំ
ចូលចិត្តមេរៀននេះមែនទេ? សូមពិចារណាទិញកាហ្វេជូនខ្ញុំមួយកែវ ដើម្បីគាំទ្រការបង្កើតមាតិកាឥតគិតថ្លៃបន្ថែមទៀត!