The most expensive mistake I made putting tool calling into a product was mental, not code: I thought the LLM executed the tools. It doesn't. The model returns a JSON object saying "I'd like to call searchCandidates with these arguments," and there its job ends. Who decides whether that call runs, with what permissions, and what happens to the result, is your code. Internalizing that boundary —the LLM proposes, your backend disposes— completely changed how I design agents. I stopped treating the model as an autonomous executor and started treating it as a planner that emits intentions I validate. This article is that mental model and the production decisions that follow from it.
The boundary that changes everything
When you give a model tools, the real loop is: you pass it the user's request and the schema of available tools; the model responds either with text, or with one or more tool calls (name + arguments in JSON); your code executes whichever ones it decides to; you return the result to the model; and the model continues. The LLM never touches your database or your API. It's a generator of structured intentions.
That boundary is the best news of the architecture, because it means all the security control lives on your side. The model can ask to delete a record; whether it gets deleted depends on an if of yours. Treating the tool call as a request subject to authorization, and not as an order, is what separates an agent you can put in production from a demo that scares you.
Schemas are your contract, and the model reads them
The quality of a tool-using agent depends brutally on how well described the tools are. The model chooses what to call based on the name, the description, and the parameter schema. Vague descriptions produce wrong calls; precise descriptions produce correct calls.
const tools = [{
name: 'search_candidates',
description: 'Search candidates by skills and seniority. ' +
'Use it only when the user asks for specific profiles, ' +
'not for general questions about the market.',
input_schema: {
type: 'object',
properties: {
skills: { type: 'array', items: { type: 'string' } },
seniority: { type: 'string', enum: ['junior', 'mid', 'senior'] },
},
required: ['skills'],
},
}];
That enum isn't decoration: it restricts the output space and makes it far less likely that the model invents a value your backend can't handle. Every restriction you put in the schema is a class of bug you eliminate before it happens. I've learned to invest in strict schemas with the same seriousness I design a public API, because to the model that's exactly what they are.
Validate the output as if it came from a hostile client
Here's the point most people skip. The model generates the arguments as text, and even if you give it a schema, there's no hard guarantee it respects your business invariants. It can hand you a valid seniority per the enum but an empty skills array, or a correctly formatted date that's in the past.
That's why I validate every tool call with Zod before executing it, with the same distrust I'd apply to user input. The tool call is user input, just generated by a model. If it doesn't validate, I don't execute: I return the error to the model as the tool result and let it retry with corrected arguments. That "you failed validation, here's why, try again" loop is surprisingly effective and keeps the system safe without human intervention.
💡 A tool call is an untrusted request that happens to come from an LLM instead of a browser. Validate it, authorize it, and log it exactly like any external input. The model is not part of your trust boundary.
Side effects: separate reading from writing
Not all tools are equal, and treating them as if they were is asking for trouble. I distinguish two categories with different rules. Read-only ones —search, query, calculate— I run without friction: if the model gets the query wrong, the cost is a poor answer, nothing irreversible. Write ones —create, update, delete, send— go through a much stricter filter.
For write tools with real impact, the pattern that works for me is not to give the model the destructive tool directly, but one that proposes the action and leaves it pending an explicit confirmation. The model can draft the email; sending it requires a step my code controls, often with a human in the loop. I chose this caution in exchange for less "magical" agents, and I'd choose it again: an agent that can send messages without supervision is an incident waiting its turn.
Why this mental model scales and the other doesn't
When you think the LLM executes, every new tool scares you, because you're widening what a non-deterministic system can do on its own. When you understand that the LLM only proposes, adding tools is cheap: each one is one more intention your code knows how to validate, authorize, and execute under your rules.
The agent stops being a black box you pray to and becomes a planner whose proposals pass through a control layer you wrote and understand. All of the model's creativity, none of its ability to cause harm without permission. That division of responsibilities isn't an implementation detail: it's the difference between an agent you can defend in a security review and one you can't.