Tool Calling in Spring AI 2.0: @Tool, ChatClient.tools(), and the Full Round Trip
▶ Watch on YouTube & subscribe to The Stack Underflow
A support engineer asks your internal AI assistant “what time does the alarm go off for the 3am deploy window?” The model can describe alarms, explain time zones, even write you a cron expression from memory. What it cannot do is actually read the clock on your server, look up the deploy schedule in your database, or set anything. A large language model produces text. That is the entire capability surface. It has no filesystem, no network socket, no database driver — it cannot execute a single line of code.
So when an AI feature in production does something — sends an email, files a ticket, queries a payments API — some piece of ordinary application code ran to make that happen. The interesting engineering question isn’t “can the model do X,” because the answer is always no. It’s: how does the model ask your code to do X, and who decides whether that request is honored?
Spring AI’s answer is tool calling, and it’s the first capability in this series where the model’s output feeds back into your Java code instead of ending at a chat bubble. Get the mental model wrong here and you’ll either over-trust the model (let it “just call” your services) or under-use it (hand-roll JSON parsing you don’t need). Get it right and you have a clean, auditable boundary between what the model is allowed to ask for and what your code is willing to run.
The one-sentence version: in Spring AI, an
@Tool-annotated method is a Java method the model may ask to run — the model never executes anything itself, your code always decides, and by default the result still has to pass back through the model before the user sees a sentence.
The gap tool calling closes
Before tool calling, everything in this series has been one-directional: you send a prompt, the model sends back text (Episode 3) or a typed object (Episode 5). Nothing the model produced ever triggered a side effect. Tool calling adds exactly one new mechanic: the model’s response can include a request to invoke a specific, named, described Java method — and Spring AI is the seam that carries that request to your code and the result back.
This requires Spring AI 2.0.0 GA, which means Java 21 to build and Spring Boot 4.0 / Spring Framework 7.0 as the platform — Java 17 is only the Spring Boot 4 runtime floor, not what you compile against. If your pom.xml or build.gradle still targets Java 17, tool-calling code (and everything else in 2.0.0) won’t compile.
The contract: @Tool
You don’t implement an interface or extend a base class to expose a method to the model. You write a plain Java method and annotate it:
class DateTimeTools {
@Tool(description = "Get the current date and time in the user's timezone")
String getCurrentDateTime() {
return LocalDateTime.now().toString();
}
@Tool(description = "Set a user alarm for the given time, provided in ISO-8601 format")
void setAlarm(String time) {
LocalDateTime alarmTime = LocalDateTime.parse(time);
// your scheduling logic
}
}
@Tool lives in org.springframework.ai.tool.annotation.Tool and carries a handful of attributes worth knowing precisely:
| Attribute | Default | What it controls |
|---|---|---|
name | the method name | the identifier the model sees and requests |
description | none | plain English the model uses to decide when to call this tool — write it like documentation, not a comment |
returnDirect | false | whether the raw result skips the model and goes straight to the caller |
resultConverter | DefaultToolCallResultConverter | how the return value is serialized back into the conversation |
The description does real work. The model has no other information about what getCurrentDateTime() does — it never sees your method body, only the signature and the description you wrote. Vague descriptions produce tools the model calls at the wrong moments or never calls at all.
Registering the tool: request-scoped vs. builder-scoped
Writing @Tool on a method doesn’t expose it to anything by itself. You register it on a ChatClient call:
String answer = chatClient.prompt()
.user("What day is tomorrow?")
.tools(new DateTimeTools())
.call()
.content();
ChatClient.ChatClientRequestSpec.tools(Object... tools) is request-scoped — it accepts a @Tool-annotated POJO like new DateTimeTools(), a ToolCallback, a ToolCallbackProvider, or a mixed array/Collection of any of those, and it applies only to this one call.
If every request built from a given ChatClient should have a tool available, register it once on the builder instead:
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultTools(new DateTimeTools())
.build();
ChatClient.Builder.defaultTools(Object... toolObjects) is builder-scoped and shared across every request from that builder. One thing to hold onto: a request’s own .tools(...) call fully overrides the builder’s .defaultTools(...) for that request — it doesn’t merge with it. Reach for .tools(...) when a tool is relevant to one specific call; reach for .defaultTools(...) when it should be baseline capability for the whole ChatClient.
The round trip, step by step
Once a tool is registered, a single prompt sets off a full loop between your code, Spring AI, and the model:
USER "What day is tomorrow?"
│
▼
CHATCLIENT ──(prompt + tool definition)──► MODEL
│
"run getCurrentDateTime()"
◆ TOOL CALL REQUEST
│
ToolCallingManager.executeToolCalls(...)
│
▼
YOUR CODE: DateTimeTools.getCurrentDateTime()
│
wraps result as
ToolResponseMessage
│
▼
MODEL ──► CHATCLIENT ──► USER
"Tomorrow is Thursday, July 12."
Walking it in order:
- Your prompt goes out, and the tool’s definition — name, description, parameter shape — rides along automatically because you registered it with
.tools(...). - The model doesn’t answer. It emits a tool call request: “run
getCurrentDateTime.” This is a normal part of the protocol, not an error. ToolCallingManager— the internal class withresolveToolDefinitions(ToolCallingChatOptions)andexecuteToolCalls(Prompt, ChatResponse)— is the seam that actually dispatches that request to your method. Nothing runs unless this hop happens.- Your
DateTimeTools.getCurrentDateTime()executes. It’s a plain method call; there’s nothing AI-specific inside it. - The return value is wrapped in a
ToolResponseMessageand sent back into the conversation. - Because
returnDirectdefaults tofalse, the result goes back to the model, not straight to the user. The model turns your raw data into the sentence a human reads: “Tomorrow is Thursday, July 12, 2026.”
That last point is easy to skip past and important to internalize: by default, tool calling is a two-hop trip through the model, not a one-hop shortcut. If you want the tool’s raw output returned to the caller with no rewriting, set returnDirect = true on the @Tool annotation — but that’s an opt-in, not the default behavior.
Arguments, safely typed
The setAlarm(String time) tool from earlier takes a parameter, and this is where a lot of hand-rolled AI integrations get sloppy with manual JSON parsing. Spring AI does the binding for you:
@Tool(description = "Set a user alarm for the given time, provided in ISO-8601 format")
void setAlarm(String time) { ... }
When the model decides to call this tool, it emits a JSON payload matching the method’s parameters — something like {"time": "2026-07-11T14:15:00"} — and Spring AI binds that JSON directly onto the time parameter before invoking your method. There is no manual deserialization in your method body; by the time your code runs, time is already a String (or whatever typed parameter you declared). For multi-parameter tools, annotate individual parameters with @ToolParam when you need to give the model per-argument guidance beyond the method-level description.
The enterprise rule: least privilege
Everything above assumes the tool call should succeed. Production systems need the other branch too: your code refusing.
The model can only request a tool call — it has no way to force execution. Your method is free to run an authorization check before doing anything, and to refuse:
@Tool(description = "Cancel a customer's subscription")
void cancelSubscription(String customerId, ToolContext toolContext) {
String callerRole = (String) toolContext.getContext().get("role");
if (!"ADMIN".equals(callerRole)) {
throw new ToolExecutionException("Caller not authorized to cancel subscriptions");
}
// proceed
}
ToolContext (org.springframework.ai.chat.model.ToolContext) is an immutable context map you set with .toolContext(Map.of(...)) on the request — a clean way to carry tenant IDs, permission scopes, or user identity into tool execution without smuggling it through the prompt text. When a tool declines, it throws ToolExecutionException; Spring AI surfaces that back into the conversation without crashing the app. Deny it, log it, throw — the model can only ask; it never overrides your authorization.
How to apply this right now
- Write
@Toolmethods like public API, not internal helpers. Thedescriptionis the only documentation the model gets — treat it as the method’s entire contract with the outside world. - Default to request-scoped
.tools(...). Only promote a tool toChatClient.Builder.defaultTools(...)once you’re confident it should be available on every call from that client. - Never assume
returnDirect = true. Leave it at thefalsedefault unless you have a specific reason to skip the model’s rewrite of the result — most user-facing answers benefit from the model’s final composition pass. - Put the authorization check inside the tool method, not around the
ChatClientcall. The check needs to run at execution time, scoped to the specific tool being invoked, usingToolContextfor caller identity — not as a blanket gate before the prompt goes out. - Design for
ToolExecutionExceptionas a normal outcome, not a bug. A denied tool call should degrade gracefully into a conversational response, not a 500. - Keep tool descriptions and parameter types narrow. A
String timeparameter with a description that says “ISO-8601 format” is far more reliable than a loosely typedMap<String, Object>the model has to guess the shape of.
Common misconceptions
“The model executes the tool.” It never does. The model can only emit a request naming a tool and its arguments. ToolCallingManager is the only thing that ever invokes your method — the model has zero code-execution capability, before or after tool calling exists.
“Tool results go straight back to the user.” Only if you explicitly set returnDirect = true. The default behavior sends the raw result back to the model first, so it can compose a natural-language answer around your data.
“@Tool needs an interface or SDK-specific base class.” It doesn’t. Any plain Java method on any class can be annotated @Tool and registered — there’s no framework contract beyond the annotation and a description.
“Authorization is something Spring AI handles for you.” It isn’t, and there’s no Spring-AI-specific Spring Security bridge for tool calls as of 2.0.0. Least privilege is a pattern you implement inside each tool method, typically using ToolContext to carry caller identity — not a built-in ACL attribute on @Tool itself.
Frequently asked questions
Does the model ever see my Java source code?
No. It only sees what you put in the @Tool annotation’s description (and any @ToolParam descriptions) plus the method’s parameter names and types, from which Spring AI derives a JSON schema. Your method body is completely opaque to the model.
What’s the difference between .tools(...) and .defaultTools(...)?
.tools(Object... tools) on a ChatClient request registers tools for that one call only. .defaultTools(Object... toolObjects) on ChatClient.Builder registers tools shared across every request built from that client — but a request’s own .tools(...) fully replaces the builder defaults for that call rather than adding to them.
Can a single prompt trigger multiple tool calls?
Yes. The model can request several tool calls in response to one prompt — either in sequence across the conversation, or (depending on the model provider) as multiple calls attached to a single response — and Spring AI dispatches each one through ToolCallingManager before assembling the final answer.
How do I stop the model from calling a tool it shouldn’t have access to for a given user?
Don’t register the tool for that request at all if it’s out of scope, and layer an execution-time authorization check inside the method body using ToolContext for any tool that’s registered but conditionally restricted. Both are legitimate; use registration-time scoping when you can, execution-time checks when the scope depends on runtime data.
Is tool calling the same thing as MCP? No, but they share the same underlying idea. Tool calling is Spring AI’s in-process mechanism for exposing a Java method to a model. MCP (next episode) standardizes that same request/decide boundary as a protocol, so tools can live in a separate process or server and be shared across applications and teams.
What happens if my tool method throws a regular exception, not ToolExecutionException?
Any exception thrown from inside a @Tool method interrupts that tool call’s execution. Use ToolExecutionException specifically when you want to communicate an intentional denial (bad authorization, invalid state) back into the conversation as a controlled outcome rather than an unhandled failure.
Where this fits in the series
This is Episode 7 of Spring AI for Enterprise Java. The previous episode covered retrieval-augmented generation and vector stores — see RAG and Vector Stores for how RetrievalAugmentationAdvisor and VectorStoreDocumentRetriever feed grounded context into ChatClient before the model ever sees a prompt.
The next episode takes the exact request/decide boundary covered here and standardizes it across process and organization boundaries: see MCP Integration for how the Model Context Protocol turns a local @Tool method into a shared, discoverable server capability.
Runnable code for this episode — the DateTimeTools class, both .tools() and .defaultTools() registration, and the least-privilege denial example — lives in the 07-tool-calling module of the spring-ai-enterprise-examples repo.
Video for this episode is in production — subscribe to @TheStackUnderflow on YouTube so it lands in your feed the day it publishes.
Found this useful? The deep version lives on YouTube — new breakdowns of how AI dev tools actually work, weekly.
Subscribe on YouTube →