AI Agents with Tool Calling
Right now the ShopKart assistant can talk about shopping but cannot do anything. Ask it "do you have boAt earbuds under 1500 rupees in stock?" and it will cheerfully make something up, because it has never seen your catalog. This lesson fixes that and, in doing so, turns the chatbot into an agent.
The mechanism is tool calling. You hand the model a set of Java methods it is allowed to invoke. When it needs live data or wants to take an action, it does not guess - it asks Spring AI to run one of your tools and waits for the result. That single capability is the line between a chatbot and an agent.
What a tool actually is
A tool is just a Java method, annotated with @Tool, whose description and parameters are sent to the model as part of the prompt. The model reads those descriptions and decides, on its own, when a tool is relevant.
Crucially, the model never runs your code. It emits a structured request like "call searchProducts with query='earbuds', maxPrice=1500". Spring AI intercepts that, executes your method on the server, and returns the result to the model. Your database credentials, your business logic, and your server stay firmly under your control.
Defining the ShopKart tools
Create a ShopKartTools class. Each method wraps logic you already built in lesson 2 - the tools are thin adapters, not new business logic. Notice the descriptions: they are written for the model, telling it exactly when and how to use each tool.
package com.shopkart.agent;
import com.shopkart.model.*;
import com.shopkart.repository.ProductRepository;
import com.shopkart.service.OrderService;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.List;
@Component
public class ShopKartTools {
private final ProductRepository products;
private final OrderService orders;
public ShopKartTools(ProductRepository products, OrderService orders) {
this.products = products;
this.orders = orders;
}
@Tool(description = "Search the ShopKart catalog by keyword and optional maximum price in INR. "
+ "Returns matching products with id, name, brand, price, rating and stock.")
public List<Product> searchProducts(
@ToolParam(description = "Keyword such as 'earbuds', 'running shoes', or a brand name")
String query,
@ToolParam(description = "Maximum price in INR, or null for no limit", required = false)
BigDecimal maxPrice) {
return products.search(query, maxPrice);
}
@Tool(description = "Check how many units of a product are currently in stock. "
+ "Use this before promising availability to a customer.")
public String checkStock(
@ToolParam(description = "The numeric product id") Long productId) {
return products.findById(productId)
.map(p -> p.stock() > 0
? p.name() + " has " + p.stock() + " units in stock."
: p.name() + " is currently out of stock.")
.orElse("No product exists with id " + productId + ".");
}
@Tool(description = "Place an order for a customer. Only call this after the customer has "
+ "clearly confirmed the product and quantity. Returns the order id and total in INR.")
public String placeOrder(
@ToolParam(description = "The customer's numeric id") Long customerId,
@ToolParam(description = "The product id to order") Long productId,
@ToolParam(description = "Quantity to order, must be at least 1") int quantity) {
try {
Order order = orders.place(customerId, List.of(new OrderItem(productId, quantity)));
return "Order #" + order.id() + " placed successfully. Total: INR " + order.total()
+ ". Status: " + order.status() + ".";
} catch (RuntimeException ex) {
return "Could not place the order: " + ex.getMessage();
}
}
}
Three things make these tools good:
- Descriptions written for the model. "Use this before promising availability" nudges the model to call
checkStockat the right moment. The description is prompt engineering. - The
placeOrderguardrail. "Only call this after the customer has clearly confirmed" discourages the agent from ordering things prematurely. Tool descriptions are where you encode safety. - Errors returned as text, not thrown.
placeOrdercatches exceptions and returns a readable string. The model reads that message and explains the problem to the customer ("Sorry, the Puma Softride is out of stock") instead of the whole request blowing up.
Registering the tools
Attach the tools to the ChatClient. You can set them per-request with .tools(...), but for an assistant that always needs them, register them as defaults on the bean. Update the ChatConfig from lesson 4.
@Bean
public ChatClient chatClient(ChatModel chatModel, ShopKartTools shopKartTools) {
return ChatClient.builder(chatModel)
.defaultSystem(SYSTEM_PROMPT)
.defaultTools(shopKartTools) // <- the tools are now available every turn
.build();
}
That is the entire wiring. Your ChatController from lesson 4 does not change - .prompt().messages(history).call().content() already runs the full tool loop under the hood. Spring AI generates a JSON schema from each method signature, offers the tools to the model, and when the model requests a call, executes the method and loops until the model produces a final answer.
The reason to act to observe loop
This is the beating heart of every agent. When a message needs tools, the model and your server go back and forth several times before the customer sees a reply.
The loop can run multiple times. For "find me good earbuds under 1500 and order two", the agent might call searchProducts, then checkStock on the top match, then placeOrder - three act-observe cycles - before replying. Spring AI drives all of it; you wrote the tools and nothing else.
Tracing a real multi-step run
Here is the actual sequence for that order request, so you can see the reasoning unfold:
- User: "Order two of your best boAt earbuds under 1500 for customer 100."
- Reason: model needs candidates. Act:
searchProducts("earbuds", 1500)- one keyword, since the repository matches a query substring against name, category, and tags. Observe: results come back sorted by rating, highest first: boAt Rockerz 255 Pro (1499, rating 4.4, stock 27) and boAt Airdopes 141 (1299, rating 4.2, stock 58). - Reason: the Rockerz has the higher rating but confirm stock. Act:
checkStock(6). Observe: "boAt Rockerz 255 Pro has 27 units in stock." - Reason: good to order. Act:
placeOrder(100, 6, 2). Observe: "Order #1 placed successfully. Total: INR 2998. Status: PLACED." - Final answer: "Done! I ordered 2 boAt Rockerz 255 Pro (the higher-rated pick at 1499 each). Order #1, total INR 2998."
Every fact in that reply came from your repository through a tool. The model reasoned about which products to pick, but it never invented a price or a stock number.
Why returning errors as text matters
Look again at placeOrder catching its exception. Suppose the customer tries to order the Puma Softride, which seeded with stock: 0. OrderService.place(...) throws "Insufficient stock for Puma Softride". Because the tool returns that as a string, the model observes the failure and can respond gracefully: "That one is sold out right now - want me to suggest a similar pair?" If the tool threw instead, the model would see nothing useful and the request would fail. Designing tools that fail into readable text is a core agent-building skill.
The biggest lever on agent quality is not the model - it is your tool descriptions. Vague descriptions make the agent call the wrong tool or skip one it needed. Treat each @Tool and @ToolParam description as a piece of prompt engineering: say what the tool does, when to use it, what it returns, and any preconditions ("only after the customer confirms"). And keep destructive actions like placeOrder narrowly described and confirmation-gated - an over-eager agent that orders things nobody asked for is a support nightmare.
Key takeaways
- A tool is a
@Tool-annotated Java method whose description and parameters are sent to the model; the model decides when to call it but never runs your code. - Bind tools to the logic you already own -
ShopKartToolswrapsProductRepositoryandOrderServicefrom lesson 2. - Register them once with
.defaultTools(shopKartTools); Spring AI runs the whole tool loop automatically. - The reason to act to observe loop repeats until the model stops requesting tools and produces a final answer.
- Return tool failures as readable text so the agent can explain problems to the customer instead of crashing.
Next up: Model Context Protocol (MCP) - standardize and share these tools so any MCP-compatible client can use them, and so your app can consume tools from other servers.