🔥 0
0
Lesson 7 of 8 30 min +250 XP

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 checkStock with 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.

The tool-calling loop Your Spring app ChatClient + @Tool beans LLM decides which tool 1. message + tool list 2. "call checkStock(101)" 3. run checkStock(101) real DB, returns 24 4. result: 24 in stock 5. Model writes final answer: "Yes, 24 units of the Asics shoe are in stock."

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.
On the job

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(...); the ToolCallingAdvisor drives 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 toolContext and a ToolContext parameter; 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.

🧠 Quick Quiz

Test your understanding of this lesson.

1

In the tool-calling loop, who actually executes a @Tool method?

2

Why annotate tool parameters with @ToolParam descriptions?

RAG over the Product Catalog