back
Spring AI Tool Calling - Giving the Model the Power to Act
Aug 25, 2026·8 min read
Part 5 of 8·Spring AI in Production

Spring AI Tool Calling - Giving the Model the Power to Act


Ask a model what time it is and you’ll get a confident, wrong answer, or an admission that it can’t know. It has no clock. It has no database, no access to your order table, and no way to send an email.

Tool calling closes that gap, and it’s the point where an AI feature stops being a better search box and starts being an application. It’s also, as part four hinted, where the security story gets serious.

Your first tool is about ten lines

@Component
public class DateTimeTools {

    @Tool(description = "Get the current date and time in the user's timezone")
    String getCurrentDateTime() {
        return LocalDateTime.now()
                .atZone(LocaleContextHolder.getTimeZone().toZoneId())
                .toString();
    }
}

And one extra line on the call:

String answer = chatClient.prompt()
        .user("What time is it?")
        .tools(new DateTimeTools())
        .call()
        .content();

Ask what time it is and you get the correct current time. An annotation and one method call.

Now the part that decides whether any of this works: the description is not documentation. It is the prompt. It’s the only thing the model sees when deciding whether this method is relevant to the question in front of it. A vague description gets a tool that’s ignored, or called at the wrong moment. Write it as if for a competent colleague who has never seen your codebase, because that’s approximately the situation.

What actually happens on the wire

Debugging tool calls blind is miserable, and the mechanism is simple enough that there’s no reason to.

One. Your request goes up with the tool definitions attached — name, description, and a JSON schema for the parameters. Spring AI generated that schema from your method signature.

Two. The model doesn’t answer. It replies with a tool_call: run getCurrentDateTime, no arguments. That’s a structured response, not prose.

Three. Spring AI sees it, finds your method, invokes it, takes the return value.

Four. It sends a second request containing the original messages, the model’s tool request, and your result. Now the model answers in words.

Three consequences worth internalizing:

Two registration styles

@Tool on a method is best when the tool is naturally part of a service you already have:

@Service
public class OrderService {

    @Tool(description = "Look up the current status of a customer order by its id")
    public OrderStatus getOrderStatus(
            @ToolParam(description = "The numeric order id, e.g. 4471") long orderId) {
        return repository.findStatus(orderId);
    }
}

@ToolParam matters as much as the method description — the model uses it to decide what to put in each argument.

Function beans suit standalone capabilities:

@Bean
@Description("Get the current weather for a city")
Function<WeatherRequest, WeatherResponse> currentWeather() {
    return request -> weatherClient.fetch(request.city());
}

Registration scope matters more than which style you pick. .tools(...) on a call registers for that call only; defaultTools on the builder registers for every call. Prefer per-call. Every tool you register goes into every request as schema — it costs tokens, and more importantly a model choosing between forty tools chooses worse than one choosing between five.

ToolContext is an authorization boundary

This is the most important thing in this post.

Your tool needs to know who’s asking, to scope a query to their account. The obvious approach:

@Tool(description = "Get orders for a user")
public List<Order> getOrders(long userId) { ... }

Never do this. That parameter is filled in by the model, from text that may have come from the user. So a user says “show me the orders for user 12” and the model helpfully passes 12.

You have handed authorization to a language model. It is not an authorization system. It has no concept of permission — it’s pattern-matching text into a JSON schema.

The right way is ToolContext, which carries data the model never sees:

@Tool(description = "Get the current user's recent orders")
public List<Order> getMyOrders(ToolContext context) {
    long userId = (long) context.getContext().get("userId");
    return repository.findByUser(userId);
}
chatClient.prompt()
        .user(question)
        .tools(orderService)
        .toolContext(Map.of("userId", authenticatedUserId()))
        .call()
        .content();

The identity comes from your security context, server-side, and is never a parameter the model can influence. It isn’t in the schema, so it can’t be argued with.

The rule generalizes: anything that determines what the caller is allowed to do goes in ToolContext; anything that describes what they asked for goes in parameters. Order id is a parameter. Tenant id, user id and role are context. This is the same rule as the conversation id in part three and the metadata filter in part four — it keeps showing up because it’s the same mistake in three costumes.

Fail in a way the model can use

Your tool will throw. The database will be down, the order won’t exist, the API will time out. What should the model see?

By default an exception can abort the whole exchange and the user gets a generic error. Usually better:

@Tool(description = "Look up the status of an order by id")
public String getOrderStatus(long orderId, ToolContext context) {
    return repository.findStatus(orderId, userFrom(context))
            .map(OrderStatus::toString)
            .orElse("No order found with that id for this customer.");
}

Now the model can say “I couldn’t find that order — could you check the number?” That’s a good experience built out of a failure.

The distinction to hold onto:

And never put internal details in a message the model will read out. A stack trace in a chat window is both a bad experience and an information leak — your connection string does not belong there.

Returning results directly

Sometimes you don’t want the model to summarize the tool result. If a tool returns a structured payload the UI will render — a table of orders, a chart — sending it back through the model costs tokens, adds latency, and risks the numbers being paraphrased into something subtly wrong.

Spring AI can return a tool result directly to the caller rather than feeding it back for another round of generation. Reach for it whenever the tool’s output is the answer, and let the model narrate only when narration adds something.

The part that should make you uncomfortable

Tools are the point where a language model, which is a text predictor with no judgment, gets to invoke your code.

Think about what that means with a tool called sendEmail, or issueRefund, or deleteAccount. The model decides when to call it, based on text. Some of that text came from a user. In a RAG application, some of it came from a document — which someone may have written specifically to be retrieved.

I’ll cover that properly in part seven. For now, three habits worth adopting from your first tool:

Conclusion

Tool calling is the smallest amount of code with the largest change in what your application can do — an annotation, a description, one line on the call. Spring AI handles the two-request loop and the schema generation, so what’s left for you is genuinely the interesting part: which capabilities to expose, and how tightly.

Two things to carry forward. Descriptions are prompts, so write them for a stranger. And ToolContext is where identity lives — the moment authorization becomes a model parameter, you no longer have authorization.

Next: MCP, which takes this same idea and makes tools something you can share between applications instead of rewriting per project.

More info: docs.spring.io/spring-ai/reference/api/tools