Spring AI Tool Calling: From Chatbot to AI Agent with @Tool
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:
- 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.
- The model decides it needs data. It returns a
tool_callresponse — a structured JSON object saying "callgetCurrentDateTimewith no parameters." - Your application executes the Java method. Your code. Your security context. You send the result back to the model.
- 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:
Optionalreturn typesCompletableFutureorFuture- 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

