Tool Calling in Spring AI 2.0: @Tool, ChatClient.tools(), and the Full Round Trip

July 11, 2026 · Spring AI for Enterprise Java (part 7)

▶ 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:

AttributeDefaultWhat it controls
namethe method namethe identifier the model sees and requests
descriptionnoneplain English the model uses to decide when to call this tool — write it like documentation, not a comment
returnDirectfalsewhether the raw result skips the model and goes straight to the caller
resultConverterDefaultToolCallResultConverterhow 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:

  1. Your prompt goes out, and the tool’s definition — name, description, parameter shape — rides along automatically because you registered it with .tools(...).
  2. The model doesn’t answer. It emits a tool call request: “run getCurrentDateTime.” This is a normal part of the protocol, not an error.
  3. ToolCallingManager — the internal class with resolveToolDefinitions(ToolCallingChatOptions) and executeToolCalls(Prompt, ChatResponse) — is the seam that actually dispatches that request to your method. Nothing runs unless this hop happens.
  4. Your DateTimeTools.getCurrentDateTime() executes. It’s a plain method call; there’s nothing AI-specific inside it.
  5. The return value is wrapped in a ToolResponseMessage and sent back into the conversation.
  6. Because returnDirect defaults to false, 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

  1. Write @Tool methods like public API, not internal helpers. The description is the only documentation the model gets — treat it as the method’s entire contract with the outside world.
  2. Default to request-scoped .tools(...). Only promote a tool to ChatClient.Builder.defaultTools(...) once you’re confident it should be available on every call from that client.
  3. Never assume returnDirect = true. Leave it at the false default 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.
  4. Put the authorization check inside the tool method, not around the ChatClient call. The check needs to run at execution time, scoped to the specific tool being invoked, using ToolContext for caller identity — not as a blanket gate before the prompt goes out.
  5. Design for ToolExecutionException as a normal outcome, not a bug. A denied tool call should degrade gracefully into a conversational response, not a 500.
  6. Keep tool descriptions and parameter types narrow. A String time parameter with a description that says “ISO-8601 format” is far more reliable than a loosely typed Map<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 →