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
Search for a command to run...
I am a developer who loves Java, Spring, Quarkus, Micronaut, Open source, Microservices, Cloud
No comments yet. Be the first to comment.
Spring AI Advisors API Explained Series: Spring AI Complete Course — Lecture 4 of 12Reading Time: 8 minutesLevel: Intermediate Most developers stop at ChatClient. That is enough for demos. It is not
Working with Multiple AI Models in Spring AI Spring AI Complete Course — Lecture 3 of 12Previous: Lecture 2 — ChatClient API | Next: Lecture 4 — Advisors API In production AI applications, you rarel
Spring AI ChatClient API: The Fluent Heart of AI Integration Introduction If you've ever tried integrating AI models into a Java application, you know the pain. HTTP clients, API keys scattered everywhere, vendor-specific SDKs that never quite fit. W...
What is Spring AI? — Why Java Developers Need This in 2026 Every AI tutorial you see is in Python. LangChain, LlamaIndex, OpenAI SDK — all Python. But here's the uncomfortable truth: 80% of enterprise backends run Java. So who's building AI into thos...
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.
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:
tool_call response — a structured JSON object saying "call getCurrentDateTime with no parameters."The model never executes anything directly. This is by design. Your application is always the gate.
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.
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 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.
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.
| 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.
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.
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.
@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.
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:
@PreAuthorize, role checks, and tenant isolation all applyIf 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.
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.
Method-based tools (@Tool) don't support:
Optional return typesCompletableFuture or FutureMono, Flux, FlowKeep your tool method signatures simple: String, primitives, records, and POJOs. If you need reactive patterns in your tools, use the functional (FunctionToolCallback) approach instead.
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.
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.
// 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