I remember the first time I built a chatbot. It was impressive for five minutes. Then I asked it to check the weather, and it hallucinated a sunny day in London. That was the moment I realized a chatbot needs to DO things, not just answer questions.
What Tool Calling Actually Is
Tool calling is the bridge between a static language model and a dynamic application. When you use an LLM, it is essentially a text prediction engine. It does not know about your database, your API, or the current time. Tool calling changes this by allowing the model to request an action.
The loop is simple. You send a message to the model. The model decides it needs information or an action, so it returns a structured request instead of text. Your application catches this request, executes the corresponding function, and feeds the result back to the model. The model then uses that result to generate the final answer.
The difference between a demo and a product is error handling. A demo calls the API and shows the response. A product validates the response, handles failures gracefully, and never shows the user a raw error message.
Defining Type-Safe Tools
When I built the AI chat for this portfolio site, I needed the LLM to actually do things. I started by defining tools with Zod schemas and TypeScript types. This ensures the model always receives the correct parameters.
import { z } from 'zod';
export const toolDef = <T extends z.ZodSchema>(
name: string,
description: string,
parameters: T,
execute: (args: z.infer<T>) => Promise<any>
) => ({
name,
description,
parameters,
execute,
});
const weatherTool = toolDef(
'getWeather',
'Get the current weather for a location',
z.object({ location: z.string() }),
async ({ location }) => {
// Implementation here
return { temperature: '20C', condition: 'Sunny' };
}
);
The Agent Loop
The core of an agent is the loop. You send a message, check if the model wants to call a tool, execute it if it does, and repeat.
async function runAgent(messages: Message[]) {
let currentMessages = [...messages];
while (true) {
const response = await model.generate(currentMessages);
if (response.toolCalls) {
const results = await Promise.all(
response.toolCalls.map(call => executeTool(call))
);
currentMessages.push(...results);
continue;
}
return response.text;
}
}
Real-World Example: A Research Agent
Let's build a practical agent that can search, fetch URLs, and summarize. This agent needs a search tool and a fetch tool.
const searchTool = toolDef(
'search',
'Search the web for information',
z.object({ query: z.string() }),
async ({ query }) => {
// Call search API
return { results: [...] };
}
);
const fetchTool = toolDef(
'fetchUrl',
'Fetch content from a URL',
z.object({ url: z.string() }),
async ({ url }) => {
// Fetch and parse content
return { content: '...' };
}
);
Lessons from Production
Building this in production taught me a few things. First, error handling is non-negotiable. If a tool fails, the agent needs to know why. Second, timeouts are essential. You do not want your agent hanging because a search API is slow. Third, max iterations prevent infinite loops. If the agent cannot find the answer in five steps, it should stop. Finally, cost control is vital. Every tool call costs money.
Practical Takeaways:
- Always validate tool inputs with Zod.
- Implement a strict max iteration limit for your agent loop.
- Treat tool failures as expected events, not exceptions.
- Keep tool descriptions clear and concise to improve model accuracy.