Tool Calling: Let the AI Act
So far the ShopKart assistant can talk and can ground its answers in the catalog, but it cannot do anything. A shopper asks "Is the Asics running shoe actually in stock right now?" and the assistant can only describe the product — it cannot check live inventory. "Where is my order #5012?" is unanswerable from static text. To move from a chatbot to an assistant, the model needs to call your code. That capability is tool calling, and in Spring AI it is astonishingly little code.
The tool-calling loop
Here is the key mental model, and the one thing beginners get wrong: the model never runs your Java code. It cannot reach your database. What actually happens is a conversation:
- You send the shopper's message and a list of available tools (name, description, parameters).
- The model decides it needs data it does not have, so instead of answering it replies "please call
checkStockwith productId 101." - Spring AI intercepts that request, runs your real
checkStock(101)method in your JVM, and captures the return value. - Spring AI sends the result back to the model.
- The model uses that real data to write the final answer to the shopper.
Steps 2 to 4 can repeat if the model needs several tools. Spring AI's ToolCallingAdvisor manages this whole loop automatically when you use ChatClient — you just supply the tools.
Your first tool: checkStock
A tool is just a method annotated with @Tool. The description is not a comment — the model reads it to decide when to call the tool, so write it for the model. Annotate each parameter with @ToolParam so the model knows what to pass.
package com.shopkart.assistant.tools;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
@Component
public class InventoryTools {
private final ProductRepository productRepository;
public InventoryTools(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Tool(description = "Check how many units of a ShopKart product are currently "
+ "in stock. Use this whenever a shopper asks about availability.")
public StockInfo checkStock(
@ToolParam(description = "The numeric ShopKart product id") Long productId) {
Product p = productRepository.findById(productId);
if (p == null) {
return new StockInfo(productId, null, 0, false);
}
return new StockInfo(productId, p.name(), p.stock(), p.stock() > 0);
}
// Returned objects are serialized to JSON and handed back to the model.
public record StockInfo(Long productId, String name, int available, boolean inStock) {}
}
That is a complete, working tool. The return type is a record; Spring AI serializes it to JSON automatically and feeds it to the model, which then phrases a natural answer. Because tools run your code against your database, the numbers are always live and exact — the antidote to stale RAG facts.
Registering tools with the ChatClient
Attach tool objects to a request with .tools(...), or make them available to every call with .defaultTools(...) on the builder. Since our tools are Spring beans, inject them and register them.
@Service
public class ActingAssistant {
private final ChatClient chatClient;
private final InventoryTools inventoryTools;
public ActingAssistant(ChatClient ragChatClient, InventoryTools inventoryTools) {
this.chatClient = ragChatClient;
this.inventoryTools = inventoryTools;
}
public String ask(String question) {
return chatClient.prompt()
.user(question)
.tools(inventoryTools) // expose the @Tool methods for this call
.call()
.content();
}
}
Shopper: Is product 101 in stock right now?
Kartik: Yes, the Asics Gel-Contend 8 Running Shoes are in stock -
we currently have 24 units available. Want me to help you order?
The model saw the shopper ask about availability, matched it to the checkStock description, called it with productId=101, received {"available":24,"inStock":true}, and wrote the sentence. You never wrote branching logic to detect "the user is asking about stock" — the model did that reasoning.
More actions: order status and creating orders
Give the assistant a small toolbox. Read tools (like order status) are low risk. Write tools (like creating an order) need care — the model is deciding to call them, so guard them.
package com.shopkart.assistant.tools;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class OrderTools {
private final OrderService orderService;
public OrderTools(OrderService orderService) {
this.orderService = orderService;
}
@Tool(description = "Get the current status and delivery stage of a ShopKart "
+ "order by its order id.")
public OrderStatus getOrderStatus(
@ToolParam(description = "The ShopKart order id") Long orderId) {
Order order = orderService.findById(orderId);
if (order == null) {
return new OrderStatus(orderId, "NOT_FOUND", null);
}
return new OrderStatus(orderId, order.status(),
orderService.estimatedDelivery(orderId));
}
@Tool(description = "Place a new ShopKart order for a customer. Only call this "
+ "after the shopper has clearly confirmed the item and quantity.")
public OrderConfirmation createOrder(
@ToolParam(description = "The customer id placing the order") Long customerId,
@ToolParam(description = "The product id to order") Long productId,
@ToolParam(description = "How many units, must be at least 1") int quantity) {
// Business rules and validation stay in YOUR code, not the prompt.
if (quantity < 1) {
return OrderConfirmation.failed("Quantity must be at least 1.");
}
Order order = orderService.placeOrder(customerId, productId, quantity);
return OrderConfirmation.success(order.id(), order.total());
}
public record OrderStatus(Long orderId, String status, String estimatedDelivery) {}
public record OrderConfirmation(boolean placed, Long orderId,
java.math.BigDecimal total, String message) {
static OrderConfirmation success(Long id, java.math.BigDecimal total) {
return new OrderConfirmation(true, id, total, "Order placed successfully.");
}
static OrderConfirmation failed(String why) {
return new OrderConfirmation(false, null, null, why);
}
}
}
Register multiple tool beans in one call:
public String ask(String question) {
return chatClient.prompt()
.user(question)
.tools(inventoryTools, orderTools)
.call()
.content();
}
Now a single conversation can chain tools. The model reasons about the sequence itself:
Shopper: Order 2 of the Asics running shoes for my account, id 77.
Kartik: (calls checkStock(101) -> 24 available)
(calls createOrder(customerId=77, productId=101, quantity=2) -> order 5012, Rs 5,598)
Done! I've placed your order #5012 for 2x Asics Gel-Contend 8
Running Shoes, total Rs 5,598. You'll get a confirmation shortly.
Passing trusted context with ToolContext
Never let the model supply security-sensitive values like the logged-in customer id — it could be talked into passing someone else's. Pass trusted data out-of-band with toolContext, and read it inside the tool via a ToolContext parameter (which is not exposed to the model).
@Tool(description = "Get the current shopper's most recent orders.")
public List<OrderStatus> getMyRecentOrders(
org.springframework.ai.chat.model.ToolContext toolContext) {
// The customer id comes from YOUR session, not from the model.
Long customerId = (Long) toolContext.getContext().get("customerId");
return orderService.recentOrders(customerId);
}
chatClient.prompt()
.user("where are my recent orders?")
.tools(orderTools)
.toolContext(java.util.Map.of("customerId", authenticatedCustomerId))
.call()
.content();
The model can trigger getMyRecentOrders, but it can only ever see the customer id you injected from the authenticated session. This is the core safety pattern for write and privacy-sensitive tools.
Designing good tools
- Write descriptions for the model, not for humans. "Check live stock when a shopper asks about availability" beats "checks stock." The description is the model's only guide to when and how to call.
- Keep tools small and single-purpose. One clear job each; the model composes them.
- Validate inside the tool. The model chooses arguments, so treat them as untrusted. Enforce quantity limits, ownership, and price rules in Java, never in the prompt.
- Return structured results with a status. A record with a success flag lets the model explain failures gracefully.
- Guard destructive actions. Require confirmation in the description, inject identity via
ToolContext, and consider a human-in-the-loop step for high-value orders.
Tool calling is where an AI feature becomes a real product risk, because the model can now trigger actions with side effects. Treat every tool invocation like an unauthenticated API call: validate arguments, enforce authorization from server-side identity (ToolContext), rate-limit, and log every call with its arguments and result for audit. Start read-only, add write tools carefully, and put irreversible or high-value actions behind an explicit confirmation step.
Key takeaways
- Tool calling lets the model request that your Java methods run; the model never executes code itself — Spring AI runs it and returns the result.
- Annotate a method with
@Tool(with a model-facing description) and its parameters with@ToolParam; return a record and it is serialized to JSON automatically. - Register tools per request with
.tools(...)or globally with.defaultTools(...); theToolCallingAdvisordrives the loop. - Use tools for live, exact facts (stock, order status) and for actions (createOrder) — the cure for stale data and the step from chatbot to assistant.
- Inject trusted identity via
toolContextand aToolContextparameter; never let the model supply security-sensitive values. - Validate arguments and enforce business rules inside the tool, and guard destructive actions.
Next up: Memory, Guardrails & the Shopping Assistant — multi-turn conversations, safety hardening, and assembling the capstone.