An LLM is very good with words. It can explain a bug, write a cover letter or plan a trip. But on its own it cannot do anything. It cannot check the weather, add two numbers with certainty, or save a file. It only writes text. This note is about how an application gives the model a way to act, and what changes once it can.
Note 4
ended with a picture of a real AI app: the model is one box, and the
application around it does most of the work. Tools were one of the boxes in
that picture. Here we open that box. We start with one calculator tool, add
weather and currency, look at the loop that runs them, and finish with a
small agent that builds a website inside a locked folder. The code is in
Spring Boot, Node.js and Python under code/05-ai-agent-tools/.
Here are the words used in this note. Each one gets a plain meaning the first time it shows up, but this table is the short version:
| Word | What it means here |
|---|---|
| LLM | The trained model. Text goes in, text comes out |
| Tool | A normal function in your code that the model is allowed to ask for |
| Tool definition | The name, description and input shape of a tool. This is all the model sees |
| Tool call | A message from the model that says "please run this tool with these inputs" |
| Tool result | What the function returned. It goes back to the model as text |
| Observation | Another name for a tool result, used when we talk about agents |
| Tool calling loop | Ask the model, run the tool it wants, send the result back, ask again |
| Agent | A model plus tools plus a loop, working toward a goal over several steps |
| Workflow | A fixed list of steps that a programmer wrote in advance |
| Sandbox | A closed area. The agent's tools can only touch what is inside it |
| Path traversal | Using ../ in a file path to climb out of the folder you were given |
| Step limit | The most loop rounds we allow before we stop the agent |
| Human in the loop | A person must approve a risky action before it runs |
1. Writing an Action Is Not Doing It
From note 2 you know what an LLM does at its core. It looks at the tokens so far (small pieces of text) and predicts the next one. Then it does that again, and again.
Ask it:
Create a folder called portfolio.It may reply:
mkdir portfolioThat is the right command. But no folder was made. The model wrote a line of text that describes an action. Nothing ran them.
The dotted line is the gap this note is about. The model's output stops at text. Getting from text to a real change needs something else.
Two everyday versions of the same gap:
- A recipe book tells you exactly how to make dal. It has never cooked anything.
- A cricket commentator can describe the perfect cover drive. He is not the one holding the bat.
Knowing how is not the same as doing.
2. Brain and Hands
Picture a very clever person locked in a room. They know a lot, they reason well and they speak clearly. But they have no hands.
You call them and say: "Please turn on the AC." They understand you. They might say: "Press the power button on the remote." They still cannot press it.
A raw LLM is in that room. It has no direct way to reach:
- a database
- the file system
- live data on the internet
- a calculator
- a terminal
- email, calendars or payment systems
Every one of those has to be handed to it by the application that runs it.
The model is the brain. Tools are the hands. Notice the arrows say "asks". The brain never touches the world directly, even with hands attached.
LLM = brain. Tools = hands. The application connects the two.
3. The Application Around the Model
ChatGPT and similar apps are much more than a model. The model sits inside an application, and the application decides what the model can reach.
Look at where the tool sits. It is attached to the application, not to the model. The model can only ask the application to use it.
A food delivery app works the same way with people. The support agent on chat does not drive to your house. They ask the delivery system, and the system sends a rider.
4. Splitting the Work: the Calculator
Take this question:
What is 938475 x 736294?A modern model might get it right. It might also get one digit wrong and say it with total confidence. Plain code gets it right every single time. So we split the job.
| Who | Job |
|---|---|
| The model | Read the question. Work out that it means "multiply 938475 by 736294" |
| The calculator | Do the multiplication |
| The model again | Turn the number into a friendly sentence |
Here is the same split as a conversation between the three of them:
Each side does what it is good at. The model handles language. The calculator handles numbers.
A shop till is a good picture of this. The shopkeeper talks to you, finds out what you want and bags it. The till adds up the bill. Nobody asks the shopkeeper to add 47 items in their head.
5. Why Software Wants Structure
Here is a normal Java method:
public double add(double a, double b) {
return a + b;
}A computer understands add(10, 20) perfectly. A person might say any of
these:
- "Add 10 and 20."
- "What's ten plus twenty?"
- "Sum of 10 and 20 please."
- "I had 10 apples and got 20 more. How many now?"
For a person, these all mean the same thing. For old style code, they are four different strings. You could try:
if (message.contains("add") || message.contains("sum") || message.contains("plus")) {
// ...
}Then someone types "I had 300 rupees and my friend gave me 200." There is no
"add" in it at all. You can never write enough if checks to cover how
people talk.
What code really wants is something fixed and boring:
{ "operation": "ADD", "a": 10, "b": 20 }The model sits in the middle and turns every version into that one shape:
Many messy sentences go in. One clean shape comes out. This is the model's most useful job in an application.
Train tickets show the same thing. "Two seats to Pune tomorrow morning", "Book me and my wife on the 7 am to Pune" and "Pune, tomorrow, 2 people" should all turn into the same booking request.
6. The Model as a Translator
So an LLM is more than a text writer. It can translate messy human intent into structured data that code can use.
Think about a food delivery support chat.
A customer writes:
My burger never came and I want my money back.The backend needs:
{ "intent": "REFUND_REQUEST", "reason": "ITEM_NOT_DELIVERED", "item": "burger" }Another customer writes:
I ordered pizza 90 minutes ago. Where is it?The backend needs:
{ "intent": "ORDER_STATUS" }Put the two messages side by side and you can see where each one ends up:
The model reads the sentence and picks the route. The services behind it stay normal code that expects normal inputs.
Two more examples of the same idea:
- A library app. "Can I keep this book one more week?" becomes
{"intent": "RENEW", "days": 7}. - A phone contacts app. "Save Ravi's new number, 98450 12345" becomes
{"action": "UPDATE_CONTACT", "name": "Ravi", "phone": "9845012345"}.
7. Why "Reply in JSON" Is Not Enough
You could just tell the model: "Always answer in JSON." Most of the time it will. Sometimes you get this:
Sure! Here is the JSON:
{ "operation": "multiplication" }Two problems. There is chatty text before the JSON, so a JSON parser fails.
And it says multiplication when your code expects MULTIPLY. It is close,
but code does not do close.
What we want is a contract: a fixed list of fields, their types and the allowed values, agreed before the model says anything.
On the left, the shape is only a polite request, and the model may ignore it. On the right, the shape is part of the API call. An unpredictable model meets strict code here, so the code needs the contract.
The model understands what the user means. The contract decides the shape it must use to say it.
8. What a Tool Is
Take away the AI words, and a tool is a normal function. This multiply is a
simplified example. The repo has one calculate(operation, a, b) tool for all
maths, shown below.
public double multiply(double a, double b) {
return a * b;
}There is nothing AI about it. What turns it into a tool is a description the model can read:
Name: multiply
Description: Multiplies two numbers.
Inputs: a (number), b (number)That description is the tool definition. It has three parts.
The name lets the model point at the tool. The description tells it when the tool fits. The parameters tell it what to fill in. The body stays in your code.
In Spring AI, you write the definition as annotations on the method. Here is the calculator from this note's code:
@Tool(description = """
Does exact arithmetic on two numbers. Use it for every calculation,
even easy ones, instead of working the answer out yourself.
Supported operations: add, subtract, multiply, divide, mod, power.
""")
public double calculate(
@ToolParam(description = "One of: add, subtract, multiply, divide, mod, power") String operation,
@ToolParam(description = "The first number") double a,
@ToolParam(description = "The second number") double b) {
String op = operation == null ? "" : operation.trim().toLowerCase(Locale.ROOT);
return switch (op) {
case "add" -> a + b;
case "multiply" -> a * b;
// ... subtract, divide, mod, power
default -> throw new IllegalArgumentException("Unsupported operation '" + operation + "'.");
};
}Spring AI reads the method name, the descriptions and the parameter types,
and builds the definition for you. The switch is the function body. It
stays in your code.
The descriptions matter more than they look. "Use it for every calculation, even easy ones" is there because models like to do small sums in their head. If the description is vague, the model may not pick the tool at all.
9. A Tool Definition Is an API Contract
You already know this idea from REST. A POST /users endpoint says: send
me a name and an email, both strings. The client follows that or gets
an error.
A tool definition is the same deal, but the client is the model.
In both, one side publishes what it accepts and the other side picks an action and fills in the fields. The LLM acts like a smart client that decides which function to call.
In the Node and Python versions we write the definition by hand. This is the exact shape the OpenAI Responses API takes:
{
"type": "function",
"name": "calculate",
"description": "Does exact arithmetic on two numbers. Use it for every calculation.",
"parameters": {
"type": "object",
"properties": {
"operation": { "type": "string", "enum": ["add", "subtract", "multiply", "divide", "mod", "power"] },
"a": { "type": "number", "description": "The first number" },
"b": { "type": "number", "description": "The second number" }
},
"required": ["operation", "a", "b"],
"additionalProperties": false
},
"strict": true
}Three things to notice:
enumlists the only allowed operations. The model cannot sendmultiplication. This fixes the problem from section 7.strict: trueasks the API to make the arguments match the schema exactly.- Strict mode wants every field in
requiredandadditionalProperties: false(no extra fields allowed). The code has a smallfunctionTool()helper that adds both, so no tool forgets them.
10. What the Model Actually Receives
The model does not get your Java class. It never sees the switch. It only
gets the definition, plus the user's message.
Everything in the right box stays on your server. This is good for safety. The model cannot read your keys or your database just because a tool uses them inside.
For "What is 450 divided by 9?" the model sends back something like:
{ "name": "calculate", "arguments": "{\"operation\":\"divide\",\"a\":450,\"b\":9}" }That message is a tool call (also called a function call). Note that
arguments is a string of JSON, not a ready object. Your code has to parse it
into real values first.
11. The Five Steps of One Tool Call
Let us follow one question all the way through: "What is 53 x 29?"
- The app sends the user's message and the list of tool definitions.
- The model does not answer yet. It replies with a tool call:
calculate("multiply", 53, 29). - The app runs the real function. It gets
1537. - The app sends a second request: the same message, the tool call, and the
result
1537. - The model sees the result and writes the final answer.
Count the arrows to the LLM. One question from the user made two model
calls. And look at who runs calculate(): the app, not the model.
The model asks for the tool. Your application runs it.
This point is easy to get wrong. The model never runs inside your Java or Node program. It cannot. It sends text that says what it wants. Your code reads that text and decides to run the function.
12. The Tool Calling Loop
After step 5 the model might not be done. It might need another tool. So the steps are really a loop.
The arrow from "Add the result" back to the LLM is the whole idea. Each round, the model sees everything so far and decides again.
You will see the same loop described in four words: think, act, observe, think again. A person fixing a leaking tap does the same. Look, tighten the nut, check if it still drips, decide what next.
13. Writing the Loop by Hand
This section is about the Node and Python loops. In Spring AI you write no
loop at all: Spring runs it for you. You pass the tool objects with
.tools(...) and get the final text back:
String reply = chatClient.prompt()
.system(SYSTEM_PROMPT)
.user(message)
.tools(calculatorTools, weatherTools, currencyTools) // definitions + the loop
.call()
.content(); // the final text, after every tool roundInside, Spring AI sends the definitions, runs the method the model asks for, sends the result back and repeats. You never see the rounds.
The Node and Python versions write the loop by hand so you can see each round.
This is the core of createAgent().run():
const input = [{ role: "user", content: message }];
for (let step = 1; step <= maxSteps; step += 1) {
const response = await client.responses.create({ model, instructions, input, tools: definitions });
const calls = response.output.filter((item) => item.type === "function_call");
// No tool requested: this is the final answer.
if (calls.length === 0) return response.output_text;
// Send back what the model produced, then one result per call.
input.push(...response.output);
for (const call of calls) {
const output = await runTool(call); // parse arguments, run the function, catch errors
input.push({ type: "function_call_output", call_id: call.call_id, output });
}
}
throw new StepLimitError(maxSteps); // section 27input_items = [{"role": "user", "content": message}]
for _step in range(self.max_steps):
response = await self._client.responses.create(
model=self._model, instructions=self._instructions,
input=input_items, tools=self._definitions,
)
calls = [item for item in response.output if item.type == "function_call"]
# No tool requested: this is the final answer.
if not calls:
return response.output_text
# Send back what the model produced, then one result per call.
input_items.extend(response.output)
for call in calls:
output = await self._run_tool(call)
input_items.append({"type": "function_call_output", "call_id": call.call_id, "output": output})
raise StepLimitError(self.max_steps) # section 27Each round, the input list grows. A function_call item is the model
asking for one tool. Here is what the list holds after one tool call:
The call_id ties each result to the call that asked for it. That matters
when the model asks for two tools at once (section 16).
Two details that are easy to miss:
- We push all of
response.output, not only the calls. Reasoning models such asgpt-5-miniadd a reasoning item (a note of their own thinking) before a call. They expect to get it back on the next round. - If a tool throws,
runToolcatches it and sends"Error: Cannot divide by zero."as the output. The model can then explain the problem to the user. The user gets a sentence, not an HTTP 500 error.
14. Many Tools Behind One Endpoint
Now give the model three tools: calculate, getCurrentWeather and
convertCurrency.
Why weather? The calculator was about being exact. Weather is about being
current. The model's training data is months old. It has no idea if it is
raining in Delhi right now. So getCurrentWeather(city) calls a live
weather service.
Why currency? Rates change every day. convertCurrency(amount, from, to)
gets today's rate and does the multiplication in code.
All three sit behind one endpoint, POST /api/chat. There is no
/calculator or /weather route. The model reads the message and picks.
This is tool selection. The model chooses based on the descriptions you wrote. Clear, different descriptions make the choice easy.
A hospital front desk works like this. You say what is wrong. The person at the desk sends you to the right department. You do not need to know which floor cardiology is on.
15. Chained Calls
Some questions need one tool's answer before the next tool can start:
Convert 120 US dollars to Indian rupees, then split it equally between 6 people.Say today's rate is 83 rupees to the dollar. The model works it out step by step:
Nobody wrote "call currency, then call calculator" in the code. The model read the goal and chose the order. The second call uses a number that did not exist until the first call finished.
That is the first real taste of agent behaviour: several decisions in a row, each one based on what the last one returned.
16. Independent Calls
Other questions need two tools that do not depend on each other:
I am travelling to Delhi. What is the weather there, and how much is 500 USD in rupees?Neither answer needs the other, so both tools can run in the same round:
The model can ask for both in the same round. The response then holds two
function_call items with different call_ids. Our loop runs each one and
adds two function_call_output items. The call_id is how the model knows
which result is the weather and which is the money.
Our loop runs them one after the other, which is simple and fine for two quick calls. A busy app could run them in parallel, at the same time.
17. Information Tools and Action Tools
Tools come in two families, and the difference matters a lot.
Information tools fetch something the model does not have. They read.
- current weather
- exchange rates
- a customer's order status
- stock left in a warehouse
Action tools change something in the world. They write.
sendEmail()refundOrder()bookTicket()writeFile()
Look at the bottom row. A bad information call gives a wrong sentence. A bad action call sends an email you cannot unsend.
Your bank app shows both. Checking your balance is information. Sending money is action. That is why the bank asks for a PIN only for the second one.
A library is the same. Searching the catalogue is information. Borrowing a book changes who has it.
18. Levels of Agency
When does "a model with a tool" become "an agent"? It is easier to think in levels than to argue about one definition.
Read it from the bottom. Each level adds one new decision for the model.
| Level | Example | What the model decides |
|---|---|---|
| 1 | "Explain polymorphism." | Only what words to write |
| 2 | "What is 53 x 29?" with a calculator | Whether to use the tool |
| 3 | Calculator, weather and currency | Which tool to use |
| 4 | "Convert 500 USD to INR and split it 7 ways" | Which tool, in what order, and when it is finished |
Level 4 is where we start calling it an agent. The system is no longer doing one isolated call. It is making a chain of decisions toward a goal.
19. The Six Parts of an Agent
Skip the long definitions. An agent has six parts:
| Part | What it is | In our website builder |
|---|---|---|
| Goal | What needs to be achieved | "Build a landing page for BrewLab" |
| Decision maker | What picks the next step | The LLM |
| Actions | What it can do | createDirectory, writeFile, readFile, listFiles |
| Environment | What the actions change | The generated-sites/ folder |
| Observations | What it learns after each action | "File written: brewlab/index.html" |
| Loop | Keep going until the goal is met | The tool calling loop |
Joined up, the six parts make a circle:
Goal, decide, act, observe, decide again. It is the loop from section 12 with a goal at the top and a real environment at the bottom.
Think of someone cleaning a messy kitchen. The goal is a clean kitchen. Their brain picks what to do next. Their hands wash, wipe and put away. The kitchen is the environment. Looking around after each job is the observation. They stop when it is clean, not after a fixed number of moves.
20. The Agent as a State Machine
The same loop, drawn as states (the stages the agent moves through). This is how the Node and Python code actually behaves, including the stop we added for safety.
There are two ways out. Done is the happy one. StepLimitHit is the
emergency exit for a model that keeps calling tools and never finishes.
Section 27 explains why that exit must exist.
21. Agent vs Workflow
A workflow and an agent can build the same thing. The difference is who decides the steps.
In a workflow, we write the what and the how:
Step 1: create the directory
Step 2: write index.html
Step 3: write style.css
Step 4: write script.jsIn an agent, we write the what and hand over the tools. The model decides the how.
The workflow always does the same four things, in the same order. The agent
might skip script.js, or read a file back and fix it, depending on the
goal.
Cooking gives a clear picture. A recipe card is a workflow: step 1, step 2, step 3. Telling a good cook "make dinner for four, there is rice and paneer in the fridge" is an agent. You gave the goal and the kitchen. They choose the steps.
Real systems mix both. A refund might be a fixed workflow with one agent step in the middle that reads the customer's message.
22. The Website Builder Agent
Now the practical part. We want to send one request:
{ "prompt": "Build a landing page for a premium coffee shop called BrewLab." }and end up with real files:
generated-sites/
└── brewlab/
├── index.html
├── style.css
└── script.jsOpening index.html in a browser should show a working page.
The model already knows HTML, CSS and JavaScript very well. So what is missing? Knowing how to write code is not the same as being able to save a file.
On the left, you get code you have to copy and paste yourself. On the right, the files exist when the request ends. Same model, different hands.
23. Four Tools, and No runCommand
Four tools are enough for a static website:
createDirectory(path)
writeFile(path, content)
readFile(path)
listFiles(path)Notice what is not on the list:
@Tool
public String runCommand(String command) { ... }It is tempting. One tool, and the model could mkdir, touch, anything.
That is the problem. Your server process can reach the code, the
environment variables (where secrets like API keys usually live), the
network and every file the user account can read. A shell tool hands all of that to whatever the model decides to type.
That includes text a user slipped into the prompt to trick it.
Both sides can build a website. Only one of them can also read your API key. The narrow tools give the model what the job needs and nothing more.
Tool = capability + permission. Giving the model a tool means letting it touch that part of your system.
A hotel key card is a good comparison. Your card opens your room and the gym. It does not open every room on the floor, even though the hotel could make one that does.
24. Sandboxing and Path Traversal
So the agent works in one folder, generated-sites/. That folder is the
sandbox. The agent must not touch anything outside it:
project/
├── src/ off limits
├── .env off limits
└── generated-sites/ the only place the agent can writeBut what if the model asks for this path?
brewlab/../../.env.. means "go up one folder". Two of them climb out of the workspace and
land on your .env file with all its secrets. This trick is called path
traversal. A user could also put it in the prompt on purpose: "write the
file to ../../src/main/java/...".
So every tool runs every path through one check, safePath, before it
touches the disk:
Path safePath(String path) {
if (path == null || path.isBlank()) {
throw new IllegalArgumentException("A path is required.");
}
// Join onto the workspace, then remove every "." and "..".
Path resolved = workspace.resolve(path.trim()).normalize();
// Path.startsWith compares whole folder names, not characters.
if (!resolved.startsWith(workspace)) {
throw new IllegalArgumentException("Path '" + path + "' is outside the workspace and is not allowed.");
}
return resolved;
}function safePath(userPath) {
if (typeof userPath !== "string" || !userPath.trim()) throw new Error("A path is required.");
// Join onto the workspace and remove every "." and "..".
const resolved = path.resolve(workspace, userPath.trim());
// How do we get from the workspace to the result? If it starts with "..", it is outside.
const fromRoot = path.relative(workspace, resolved);
if (fromRoot === ".." || fromRoot.startsWith(`..${path.sep}`) || path.isAbsolute(fromRoot)) {
throw new Error(`Path '${userPath}' is outside the workspace and is not allowed.`);
}
return resolved;
}Here is what that check does with a few paths a model might send:
Four sample paths, one check. The last one is sneaky. As a string,
generated-sites-old starts with generated-sites. That is why the Java
version uses Path.startsWith (whole folder names) and the Node version
uses path.relative, instead of a plain string startsWith.
When the check fails, the tool does not crash. It returns
"Failed to read file: Path '../../.env' is outside the workspace". The
model reads that and usually picks a proper path.
The model decides what it wants to do. Your application decides what it is allowed to do.
Never treat the model as the security boundary. A prompt that says "only
write inside generated-sites" is a hint. safePath is the lock.
25. The System Prompt as a Job Description
The model needs to know its role. Without one, a model asked to "build a website" will often just paste the HTML into its reply. It has seen far more code shown in text than code saved to disk.
So the website builder gets a job description as its system prompt. A short version of the one in the code:
You are a frontend developer who builds small static websites.
Your only way to work is the workspace tools.
For every website:
1. Create one new directory named after the site, in lower case.
2. Write index.html with semantic HTML.
3. Write style.css. Make the page responsive.
4. Write script.js only if the page needs behaviour.
5. Use plain HTML, CSS and JavaScript. No frameworks.
6. When the files are written, call listFiles on the directory.
7. Read back any file you are unsure about and fix obvious mistakes.
Do not describe the code or paste it in your reply.
Create the files with the tools.The last two lines carry the most weight. "Do not describe it. Do it." That one rule is what turns a code explainer into a code writer.
Steps 6 and 7 are also worth a look. They ask the model to observe its own
work. listFiles and readFile are information tools inside an action
job. They let the model check, then fix.
26. Watching the Agent Build a Site
Here is what one POST /api/sites call can look like from the inside. The
exact steps depend on the model. This is a typical run.
Some return arrows are left out to keep the picture short. Every tool result does go back to the model before its next step.
The API answers with the reply and the list of files written:
{
"reply": "Built BrewLab: a dark landing page with a hero, a menu and a contact form.",
"files": ["brewlab/index.html", "brewlab/script.js", "brewlab/style.css"]
}Now send a different prompt without changing any code:
{ "prompt": "A futuristic page for NeuralForge, a company that builds AI robots." }You get neuralforge/ with its own three files. We did not build a coffee
shop generator. We built a website building agent. The goal changes. The
tools stay the same. The model works out how to use them each time.
27. The Step Limit
What stops an agent that never says "done"? Without a limit, nothing. A
confused model can call listFiles again and again. Each round is a paid
model call, and the user's request just hangs.
So the loop has a counter. MAX_STEPS (default 10) is the most rounds one
request may take.
The limit turns "hang forever and burn money" into "fail fast and say why". The 502 body says "The agent stopped after 10 steps without finishing", so the caller knows to simplify the prompt or raise the limit.
Spring AI has its own cap. In application.yml we set
spring.ai.tools.limits.max-total-tool-calls from the same MAX_STEPS
variable. It counts tool calls instead of model rounds, but the purpose is
the same. The flowchart above shows the Node and Python behaviour.
A website with three files takes several rounds. If big sites get cut off, raise the limit. Do not remove it.
28. Human in the Loop
Some actions should not run just because the model asked.
Sending a normal email might be fine. Now imagine a support agent with this tool:
refundCustomer(orderId, amount)and the model asks for a refund of 50,000 rupees. Running that on the spot would be risky. The model might have misread the order. The customer might have tricked it.
So we pause and ask a person.
Either way, the model gets an observation and can tell the customer what happened. The decision about money stayed with a person.
This is called human in the loop. Use it for:
- money: refunds, payments, transfers
- things you cannot undo: deleting data, cancelling a booking
- messages that leave the company: emails to customers, social posts
- security changes: new users, new permissions
A simple rule: the bigger the result of a mistake, the stronger the check around the tool. An online shop already works like this for people. A new support agent can issue small refunds alone, but a large one needs a manager's click.
The code for this note has no approval step. Its tools only write files in a sandbox. A refund tool would need one.
29. Building It: the Agent Tools Service
Time to turn the ideas into a service. It has two endpoints:
| Endpoint | Body | What it does |
|---|---|---|
POST /api/chat | {"message": "..."} | Assistant with calculate, getCurrentWeather, convertCurrency. Returns {"reply": "..."} |
POST /api/sites | {"prompt": "..."} | Website builder agent with the four workspace tools. Returns {"reply": "...", "files": [...]} |
Both return 400 for an empty message or prompt, and 502 when the model provider fails or the step limit is hit.
One /api/chat request moves through the layers like this:
The HTTP layer knows nothing about tools. The agent loop knows nothing about HTTP. The tools know nothing about the model. Each part can be tested on its own, and the tests use a fake model client, so they need no key.
A few decisions worth copying:
| Decision | Why |
|---|---|
| Tools return short text, not raw JSON | The weather API returns about a kilobyte. One line has the same facts and far fewer input tokens, so it is cheaper and faster |
| Tool errors go back to the model as text | The model can explain "you cannot divide by zero" instead of the request failing |
| The weather key is optional | Without it the tool says "weather is not configured". The model reports that instead of inventing a temperature |
| The weather error message never includes the URL | The URL holds the API key, and tool output goes to the model |
| Currency codes are checked against three capital letters | The model can send any string. It should not end up in a URL unchecked |
| A new workspace object per site request | So files lists only what this request wrote |
| The client is passed in, not created inside | Tests pass a fake that scripts a function_call, then a final text |
30. The Code, Side by Side
The full projects are on the code page:
Browse the code for this note, or open
src/FDE/code/05-ai-agent-tools/ on GitHub. The
README there has the run and test commands for each version.
30.1 Spring AI vs Node.js and Python
The same ideas, mapped:
| Concern | Spring AI | Node.js and Python |
|---|---|---|
| Define a tool | @Tool and @ToolParam on a method | A definition object (type, name, description, parameters, strict) next to a run function |
| Give tools to the model | .tools(calculatorTools, ...) | tools: definitions in responses.create |
| Read a tool call | Done for you | Items in response.output with type: "function_call" |
| Run the tool | Done for you | Look up by name, JSON.parse(arguments), call run |
| Send the result back | Done for you | Push {type: "function_call_output", call_id, output} |
| Loop until done | Done for you | A for loop up to MAX_STEPS |
| Tool error | A RuntimeException message goes back to the model | runTool catches and returns "Error: ..." |
| Step limit | spring.ai.tools.limits.max-total-tool-calls | StepLimitError after MAX_STEPS rounds |
| Sandbox check | Path.resolve().normalize().startsWith() | path.resolve + path.relative (Node), Path.resolve().is_relative_to() (Python) |
| Test double (a stand-in used in tests) | @MockitoBean services, plain unit tests for tools | A fake client whose responses.create returns scripted outputs |
Spring hides the loop, which is what you want in a real project. Node and
Python show it, which is what you want while learning. Read the Node or
Python loop once, and you will know what .tools(...) is doing for you.
30.2 Where to look in the code
| Part | Spring Boot | Node.js | Python |
|---|---|---|---|
| Calculator tool | CalculatorTools.java | tools.js | tools.py |
| Weather and currency tools | WeatherTools.java, CurrencyTools.java | tools.js | tools.py |
Workspace tools and safePath | WorkspaceTools.java | workspace.js | workspace.py |
| The loop and the system prompts | ChatService.java, SiteBuilderService.java | agent.js | agent.py |
| HTTP layer | ChatController.java, SiteBuilderController.java | app.js | main.py |
| Keys, model, limits | application.yml | .env.example | .env.example |
| Loop tests | AgentControllersTest.java | agent.test.js | test_agent.py |
| Sandbox tests | WorkspaceToolsTest.java | workspace.test.js | test_workspace.py |
The tests check what this note cares about. The tool result goes back with
the same call_id. A chained task takes one round per tool. A tool error
reaches the model as text. The step limit stops a loop that never ends.
../.env, absolute paths and look-alike folders are all rejected.
30.3 What this demo leaves out on purpose
| Shortcut | What a real app does |
|---|---|
| No login on either endpoint | Puts both behind authentication. Anyone who can reach the port can spend your credit and write files |
| No approval step | Adds human in the loop for risky tools (section 28) |
| Paths are checked, sizes are not | Limits file size, file count and total disk use per request |
| Symlinks are not refused | Refuses symlinks inside the workspace, or checks the real path of every file |
| Each request starts from nothing | Keeps history per user, like note 4, if follow up messages need it |
| Tools run one after another | Runs independent calls at the same time and adds timeouts per tool |
| The generated HTML is not checked | Scans or serves generated pages from a separate origin so a bad script cannot reach your main site |
31. The Full Mental Model
We started with a model that only writes text. Then we gave it one tool, then three, then a goal, an environment and a loop.
Look at the box in the middle. The model proposes. The application checks and runs. That box is where your engineering lives: which tools exist, what they may touch, how many steps are allowed, and what needs a person.
In one line:
LLM + tools + environment + observations + loop + goal = AI agentA chatbot answers. An agent decides, acts, looks at the result and keeps going until the goal is met.
Key Takeaways
- An LLM writes tokens. Writing
mkdir portfoliodoes not make a folder. - LLM = brain, tools = hands. The application connects them and owns the hands.
- The model's most useful job in an app is turning messy human sentences into structured inputs for normal code.
- "Reply in JSON" is a request. A tool definition with a schema, an
enumandstrict: trueis a contract. - A tool is a normal function plus a name, a description and a parameter schema. The model sees only the definition, never the body or your keys.
- The model asks for a tool. Your application runs it and sends the result back. One user question can mean several model calls.
- The tool calling loop: send, run the requested tool, add the result with
the same
call_id, send again, until the model answers with text. - Chained calls use one result in the next call. Independent calls can come in the same round.
- Information tools read the world. Action tools change it, so they need more care.
- An agent has six parts: goal, decision maker, actions, environment, observations and a loop.
- A workflow fixes the what and the how. An agent gets the what and picks the how.
- Tool = capability + permission. Give narrow tools like
writeFile, never a generalrunCommand. - Sandbox every action tool. Check every path in code, because
../../.envwill come. - The system prompt is the agent's job description. "Do not describe it, do it" is the line that makes it act.
- Always cap the loop with a step limit, and put a human in front of actions that move money or cannot be undone.