Skip to main content

Command Palette

Search for a command to run...

Spring AI Tool Calling: From Chatbot to AI Agent with @Tool

Updated
9 min read
K

I am a developer who loves Java, Spring, Quarkus, Micronaut, Open source, Microservices, Cloud

Spring AI Tool Calling: From Chatbot to AI Agent with @Tool

Your AI is smart. It knows an enormous amount. But it's frozen.

It doesn't know what time it is right now. It doesn't know what's on your calendar. It can't check the weather, place an order, or set an alarm. All it can do is generate text based on what it learned during training.

For demos, that's fine. For production applications, it's a fundamental limitation.

Tool Calling is how you fix it.


The Core Idea

Tool Calling (also called Function Calling) is a mechanism that lets the AI model request execution of real Java methods in your application. The model doesn't run the code itself — it asks your app to run it, then synthesizes the result into a natural language response.

Here's the three-step loop:

  1. You send the user's message to the model, along with definitions of your tools — name, description, parameter schema. The model never sees your code.
  2. The model decides it needs data. It returns a tool_call response — a structured JSON object saying "call getCurrentDateTime with no parameters."
  3. Your application executes the Java method. Your code. Your security context. You send the result back to the model.
  4. The model synthesizes the tool result into a final answer.

The model never executes anything directly. This is by design. Your application is always the gate.


Two Patterns

Before writing code, it helps to recognize that there are two fundamentally different kinds of tools:

Information Retrieval — tools that read data and return it. Current time, weather, database records, calendar events. No side effects. Safe to retry.

Taking Action — tools that change state. Set an alarm, send an email, write to a database, place an order. Side effects. Require careful design.

Both are valid. But you should know which one you're building.


The @Tool Annotation

Spring AI's simplest tool definition approach is the @Tool annotation. You create a plain Java class — no interface to implement, no special base class — and annotate any method you want to expose.

class ShipmentTools {

    @Tool(description = "Track a package and return its current delivery status and estimated arrival. " +
          "Use this whenever the user asks where their order is, when it arrives, or about shipping updates.")
    String trackPackage(
        @ToolParam(description = "The order tracking number provided by the carrier") String trackingNumber) {
        return shippingService.getStatus(trackingNumber);
    }
}

Then attach it to a ChatClient call:

String response = ChatClient.create(chatModel)
    .prompt("Where is my order TRK-88291?")
    .tools(new ShipmentTools())
    .call()
    .content();

That's it. Spring AI reads your method signature and automatically generates the JSON schema the model needs. The model receives the tool definition, decides it's relevant, and calls trackPackage. Your Java method runs. The model gets the result.

The description field is the most important field

The model uses description to decide when to call your tool. If your description is vague, the model won't know when to use it — or worse, it'll use it at the wrong time.

Write descriptions like you're documenting an API endpoint:

// Bad
@Tool(description = "gets order info")

// Good
@Tool(description = "Track a package and return its current delivery status and estimated arrival. " +
      "Use this whenever the user asks where their order is, when it arrives, or about shipping updates.")

Be specific about what the tool returns, what format, and when it should be called.


Taking Action with @ToolParam

For tools that take parameters, use @ToolParam to give the model descriptions and format hints for each parameter:

@Tool(description = "Add a product to the user's shopping cart")
CartConfirmation addToCart(
    @ToolParam(description = "The product SKU from the catalog — alphanumeric, e.g. PRD-1042") String sku,
    @ToolParam(description = "Number of units to add — must be a positive integer") int quantity) {

    return cartService.addItem(sku, quantity);
}

The @ToolParam descriptions shape how the model formats its tool call. Here, you're telling the model the expected SKU format and that quantity must be positive. It will comply.

You can also use void return types for fire-and-forget actions:

@Tool(description = "Save a product to the user's wishlist for later")
void saveForLater(
    @ToolParam(description = "The product SKU to save") String sku) {

    wishlistService.add(sku);
}

Tool calling works with side-effect-only methods — no return value required.


Full @Tool Reference

Field Default Purpose
name method name Tool identifier sent to the model. Override if method name is unclear.
description empty How the model decides when to call this tool. Required in practice.
returnDirect false When true, result skips model synthesis and goes straight to the user.
resultConverter Jackson Custom serializer for the return value. Implement ToolCallResultConverter.

returnDirect = true is useful when you want raw data returned directly — no prose wrapping. Good for data lookup tools where formatting should be handled in the UI, not by the model.


Three Ways to Wire Tools

Per-call (instance)

chatClient.prompt("Is the blue hoodie TRK-8821 in stock?")
    .tools(new ShipmentTools())
    .call()
    .content();

Tools are available for that one call only. Good for request-scoped tools.

Default via builder

ChatClient chatClient = ChatClient.builder(chatModel)
    .defaultTools(new ShipmentTools())
    .build();

All calls through this ChatClient automatically get these tools. Good for global utilities — tools your assistant always needs, like product lookup or cart access.

Spring Bean (functional style)

@Bean
@Description("Look up product details and inventory by SKU")
Function<ProductRequest, ProductResponse> productLookup() {
    return productService;
}

// Reference by name
chatClient.prompt("Is the Acme Pro Backpack in stock?")
    .toolNames("productLookup")
    .call()
    .content();

The functional style is useful when you already have service beans. Spring AI wraps your Function and handles schema generation.

My recommendation: use @Tool for most things. Use the functional style when you're integrating existing service beans.


The Security Model

This is the most underappreciated aspect of tool calling.

The model never runs your code. It emits a tool_call JSON object. Your application receives it, decides whether to honor it, executes the Java method if appropriate, and returns the result.

This means:

  • Your Spring Security context is fully intact
  • Your @PreAuthorize, role checks, and tenant isolation all apply
  • You can add an approval step before executing any tool

If you want explicit control over the execution loop, set internalToolExecutionEnabled(false):

ChatOptions options = ToolCallingChatOptions.builder()
    .toolCallbacks(toolCallbacks)
    .internalToolExecutionEnabled(false)
    .build();

ChatResponse response = chatModel.call(new Prompt(userMessage, options));

while (response.hasToolCalls()) {
    ToolExecutionResult result = toolCallingManager.executeToolCalls(prompt, response);
    prompt = new Prompt(result.conversationHistory(), options);
    response = chatModel.call(prompt);
}

This is the pattern for applications that need a human approval step, rate limiting on actions, or audit logging before any tool executes.


ToolContext: Secure Request-Scoped Data

Sometimes you need to pass data into a tool that shouldn't be in the prompt — authentication tokens, user IDs, session data.

Use ToolContext:

@Tool(description = "Get the logged-in user's recent orders. Use when the user asks about their purchase history, past orders, or order status.")
List<Order> getRecentOrders(ToolContext toolContext) {
    String accountId = (String) toolContext.getContext().get("accountId");
    return orderRepository.findRecent(accountId, 10);
}

The ToolContext parameter is injected by Spring AI. It never appears in the JSON schema. The model never sees it — so the user's account ID doesn't accidentally leak into the conversation.

Pass context at call time:

chatClient.prompt("What did I order last week?")
    .tools(new OrderTools())
    .toolContext(Map.of("accountId", currentUser.getAccountId()))
    .call()
    .content();

This is the right pattern for authenticated APIs, multi-tenant apps, and any situation where request-scoped identity needs to flow into tool execution without leaking into the prompt.


What Tool Methods Can't Do

Method-based tools (@Tool) don't support:

  • Optional return types
  • CompletableFuture or Future
  • Reactive types: Mono, Flux, Flow

Keep your tool method signatures simple: String, primitives, records, and POJOs. If you need reactive patterns in your tools, use the functional (FunctionToolCallback) approach instead.


Common Mistakes

Vague descriptions. If description = "gets data", the model won't know when to call your tool. Write it specifically.

Too many tools at once. Exposing 20+ tools in a single call gives the model too many options. Confusion leads to wrong tool selection or no tool call at all. Scope your tools to the task — 3 to 5 per request is a reasonable upper bound.

Assuming the model is right. The model chooses tools based on descriptions. If the behavior seems wrong, the description is usually the problem — not the model.

Forgetting the execution loop. If internalToolExecutionEnabled = false, you're driving the loop. Forgetting means the model's tool_call response is returned raw to your caller.


The Shift

Before tool calling, your Spring AI application is a sophisticated text engine. It knows what it was trained on — and nothing else.

After tool calling, your application can interact with the world. Current data, live APIs, real databases, real actions.

That's the difference between a chatbot and an agent.

The next lecture goes to the other side of the conversation: Structured Output. Instead of the model returning raw text, you'll map AI responses directly to Java objects — type-safe, validated, production-ready.


Quick Reference

// Minimal retrieval tool
class ShipmentTools {
    @Tool(description = "Track a package — use when user asks about delivery status or estimated arrival")
    String trackPackage(@ToolParam(description = "Carrier tracking number, e.g. TRK-88291") String trackingNumber) {
        return shippingService.getStatus(trackingNumber);
    }
}

// Attach per-call
ChatClient.create(chatModel)
    .prompt("Where is my order TRK-88291?")
    .tools(new ShipmentTools())
    .call()
    .content();

// With ToolContext for authenticated user data
ChatClient.create(chatModel)
    .prompt("What did I order last week?")
    .tools(new OrderTools())
    .toolContext(Map.of("accountId", currentUser.getAccountId()))
    .call()
    .content();

Part of the Spring AI Complete Course — a production-focused series for intermediate Spring Boot developers.

Previous: Advisors API — Middleware for LLM Calls | Next: Structured Output — AI Meets POJOs

More from this blog

C

Coding Saint - Simple Short Tutorials

57 posts

I am Kumar Pallav, a passionate programmer.I love java, open source & microservices . I create Simple , Short Tutorials Follow me at https://twitter.com/kumar_pallav