LLMs don’t act. They just talk.
Tools are for making LLMs act.
If you try to ask an LLM through the OpenAI API to send an email or access your database, it will not be able to do that.
await client.chat.completions.create({
model: 'gpt-5.4-mini',
messages: [
{ role: 'user', content: 'Send an email to test@example.com and say Hi!' }
],
})
It will respond with something like:
I can help draft it, but I can’t send emails directly from here...
This is expected because LLMs just know how to respond to your message. They don’t know how to perform tasks.
What are tools?
Tools are functions you define in your code. Literally they are something like:
const send_email = ({ email, message }) => {
// code to send email
// ...
return {
status: 'success',
content: `Email sent to ${email}`
}
}
This is just a JavaScript function that you write to send an email through your email service.
How can LLMs call that function
They can’t. But they can ask you to do that for them.
So the LLM asks you “call send_email for me and tell me the result”.
You call it like send_email({ email: 'test@example.com’, message: ‘Hi there!’}), and then tell the LLM that email was sent or not (the result).
This is the most important idea of tools. The LLM does not call the function; you call the function from your code and give the LLM the result.
When do we call the function?
The LLM tells you in its response. So the response could be the final answer, or a request to call a function for it.
The response I’m talking about here is what gets back when we prompt the LLM:
const response = await client.chat.completions.create({...})
You can access the output message, in the OpenAI API example, through this:
const output = response.choices[0].message
The output could be a regular message answering your question:
// console.log(output)
{
role: 'assistant',
content: 'I can’t send emails directly from here, but I can draft the message for you'
}
Or it could be the LLM asking you to call a function (a tool) for it:
// console.log(output)
{
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call_rxeTYAS9N11uH5e7Xa1bc3Uw',
type: 'function',
function: {
name: 'send_email',
arguments: '{"email":"test@example.com","message":"Hi there!"}'
}
}
]
}
Notice how when it asks you to call a tool, the content is null and there’s a new field: tool_calls.
In the second case, the LLM is “waiting” for us to call the function and hand it the results back.
How does the LLM know that we have that function defined?
You tell it! In the OpenAI API example, we can pass a tools array when prompting an LLM:
const messages = [
{
role: 'user',
content: 'Send an email to test@example.com and say Hi there!',
},
]
const response = await client.chat.completions.create({
model: 'gpt-5.4-mini',
messages,
// Passing tools here
tools: [
{
type: 'function',
function: {
name: 'send_email',
description: 'Send an email to a given email address.',
parameters: {
type: 'object',
properties: { email: { type: 'string' }, message: { type: 'string' } },
required: ['email', 'message'],
},
},
},
],
})
We call this array tool schemas! It’s just a description of the functions you have in your code that the LLM can ask you to call.
The format of the schema is not the important part. You just need to use it to describe the tool you have:
- The
description: What it does. The LLM uses this to know when it should call the function. - The function
name: So you know which function to call. - The
properties: The parameters the function accepts.
Calling the tool
In the example above, the tool_calls has an object where it specifies the function it wants to call. We can read its name (send_email) and arguments to send to the function ({"email":"test@example.com","message":"Hi!"}).
This means we can simply write this code:
const messages = [
{
role: 'user',
content: 'Send an email to test@example.com and say Hi there!',
},
]
const response = await client.chat.completions.create({
model: 'gpt-5.4-mini',
messages,
tools: toolSchemas,
})
const output = response.choices[0].message
messages.push(output)
if (output.tool_calls?.length) {
// LLM is asking you to call a tool for it
} else {
// The LLM does not need tool call. It just responded.
console.log(output.content)
}
So if tool_calls is undefined or empty, then we execute the else branch, which in this case just logs the answer.
However, if tool_calls has at least one object, then it means we need to call the function for the LLM. Let’s zoom into the if branch:
if (output.tool_calls?.length) {
// LLM is asking you to call a tool for it
const call = output.tool_calls[0]
const functionName = call.function.name
const args = JSON.parse(call.function.arguments)
// We got the function name and arguments.
// Call the function if it exists
if (functionName === 'send_email') {
// Call the function here...
} else {
console.log(`Tool with name ${functionName} does not exist!`)
}
} else {
// The LLM does not need tool call. It just responded.
console.log(output.content)
}
You can see in the code above that we need to know what function it’s asking us to call and whether it exists in our code.
If it does not exist, we log:
Tool with name ${functionName} does not exist!
But if it exists, we will handle calling the function for the LLM. Let’s zoom into that if branch if (functionName === 'send_email'):
if (functionName === 'send_email') {
// Call the function
const result = send_email({
email: args.email,
message: args.message,
})
// Tell the LLM about the result
messages.push({
role: 'tool',
tool_call_id: call.id,
content: JSON.stringify(result),
})
const responseAfterToolCall = await client.chat.completions.create({
model: 'gpt-5.4-mini',
messages,
tools: toolSchemas,
})
// The final output of the LLM after calling the tool
// This could be: 'Done — the email was sent to test@example.com.'
console.log(responseAfterToolCall.choices[0].message.content)
}
Note how we prompted the LLM again after the tool call. This time it would return the final answer.
One thing I want to draw your attention to is how we used role: tool instead of user.
This is how OpenAI distinguishes whether this prompt is a regular user message or a response to its tool call request.
It also accepts tool_call_id to distinguish which tool call you are sending the result for. Let me explain more.
tool_calls is an array
Since tool_calls is an array then it might contain multiple requests for calling multiple tools.
In this example we just processed the first one:
const call = output.tool_calls[0]
const functionName = call.function.name
const args = JSON.parse(call.function.arguments)
But in real-world code, you would need to loop through the calls instead of fetching the first one:
for (const call of output.tool_calls) {
const functionName = call.function.name
const args = JSON.parse(call.function.arguments)
// ...
}
In this example, we only have one tool: send_email. But you could add as many tools as you want:
const tools = {
send_email: () => {},
get_customer_by_id: () => {}, // fetches customer from db
read_file_content: () => {}
}
For each of these tools, you need to define a new object in the toolSchemas array. And when the LLM asks you to call a tool, you add a new branch checking the name:
if (functionName === 'send_email') {}
else if (functionName === 'get_customer_by_id') {}
else if (functionName === 'read_file_content') {}
else {
// tool does not exist
}
Or a cleaner way to access it dynamically from the tools object:
const functionToCall = tools[functionName](args)
Now it should be clear why OpenAI asks us to pass the call id to tool_call_id when sending the function results via a role: tool message. We need to do that because the LLM might ask us to call multiple tools, and with tool_call_id we can tell it which result is for which call.
Top comments (0)