Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Spring AI Function Calling & @Tool

How Spring AI function calling works, with complete @Tool examples: registering tools, typed parameters, error handling, the agent loop, and how to stop a tool-using model doing damage.

Intermediate8 min readUpdated
On this page

Tool calling is the hinge between "a model that writes text" and "a system that does things". It is also where a demo becomes a liability if you skip the guardrails.

Key Takeaways

  • The model requests a call; your application decides whether to execute it.
  • The description is the entire interface the model sees. Write it for a reader who cannot see your code — because that is exactly the situation.
  • Return errors as values, not exceptions, so the model can recover.
  • Register the smallest useful set of tools per request. More tools means worse selection.
  • Anything irreversible needs an approval step, not a well-worded prompt.

The loop

Spring AI runs the tool-calling loop for you: the model never touches your code directly.

Notice that the model is called twice for a single user question. Every tool invocation costs a round trip and a full re-send of the conversation, which is why tool-heavy flows are slower and more expensive than they look.

A first tool

OrderTools.java
package com.javaagentic.demo.tools;
 
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
 
@Component
public class OrderTools {
 
    private final OrderRepository orders;
 
    public OrderTools(OrderRepository orders) {
        this.orders = orders;
    }
 
    /**
     * The description is the API contract as far as the model is concerned.
     * Say when to use it and what it returns — the model cannot read this code.
     */
    @Tool(description = """
            Look up the delivery status of a customer order.
            Use this whenever a customer asks where their order is or when it
            will arrive. Returns the status and, when known, the expected
            delivery date. Returns NOT_FOUND if no such order exists.
            """)
    public String getOrderStatus(
            @ToolParam(description = "The order ID, in the format A-123") String orderId) {
 
        return orders.findById(orderId)
                .map(order -> "%s, expected %s".formatted(
                        order.status(), order.expectedDelivery()))
                // A returned error keeps the model in the loop and lets it ask
                // the customer for a correct ID. A thrown exception aborts the
                // whole request and produces a generic failure.
                .orElse("NOT_FOUND: no order with ID " + orderId);
    }
}

Registering it:

SupportAssistant.java
@Service
public class SupportAssistant {
 
    private final ChatClient chatClient;
 
    public SupportAssistant(ChatClient.Builder builder, OrderTools orderTools) {
        this.chatClient = builder
                .defaultSystem("""
                        You are a customer support assistant.
                        Use the available tools to look up real data.
                        Never invent an order status — if a lookup fails, say so
                        and ask the customer to check the ID.
                        """)
                .defaultTools(orderTools)
                .build();
    }
 
    public String handle(String message) {
        return chatClient.prompt().user(message).call().content();
    }
}

A customer asks "where is order A-123?". The model calls getOrderStatus("A-123"), receives SHIPPED, expected 2026-07-25, and replies in natural language. You wrote no routing logic, no intent classifier and no parser.

Writing descriptions the model can use

This is the whole skill. Compare:

// Poor: describes the implementation
@Tool(description = "Queries the orders table")
 
// Poor: too terse to disambiguate
@Tool(description = "Get order")
 
// Good: when to use it, what it needs, what comes back
@Tool(description = """
        Look up the delivery status of a customer order by its ID.
        Use when a customer asks about the location, status or arrival
        date of an existing order. Requires the order ID (format A-123).
        Returns status and expected delivery date, or NOT_FOUND.
        """)

Typed parameters

Records give the model a precise schema and give you validated input.

BookingTools.java
package com.javaagentic.demo.tools;
 
import java.time.LocalDate;
 
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
 
@Component
public class BookingTools {
 
    /**
     * An enum parameter is far more reliable than a free-text one: the schema
     * enumerates the permitted values, so the model cannot invent "ECONOMY_PLUS".
     */
    public enum CabinClass { ECONOMY, PREMIUM, BUSINESS }
 
    public record FlightSearch(
            @ToolParam(description = "Departure airport IATA code, e.g. LHR") String from,
            @ToolParam(description = "Arrival airport IATA code, e.g. JFK") String to,
            @ToolParam(description = "Departure date in ISO-8601 format") LocalDate date,
            @ToolParam(description = "Cabin class") CabinClass cabin) {}
 
    @Tool(description = """
            Search available flights between two airports on a given date.
            Use only when you have all of: origin, destination and date.
            If any is missing, ask the customer for it first.
            """)
    public String searchFlights(FlightSearch request) {
        // The model is not to be trusted with input validation. It fills
        // arguments plausibly, not correctly.
        if (request.date().isBefore(LocalDate.now())) {
            return "ERROR: date is in the past. Ask the customer for a future date.";
        }
        return findFlights(request);
    }
}

Notice the past-date check. The model produced a syntactically valid LocalDate, but "next Tuesday" resolved against a stale idea of today's date is a routine failure. Validate every tool argument as if it came from an anonymous HTTP request, because in terms of trust, it did.

Error handling

Tool errors are conversation, not exceptions.

@Tool(description = "Issue a refund for an order. Returns the refund reference.")
public String issueRefund(String orderId, String reason) {
    try {
        Order order = orders.findById(orderId)
                .orElse(null);
 
        if (order == null) {
            return "ERROR: no order " + orderId + ". Ask the customer to verify the ID.";
        }
        if (order.isRefunded()) {
            return "ERROR: order " + orderId + " has already been refunded.";
        }
        if (order.total().compareTo(AUTO_REFUND_LIMIT) > 0) {
            // Not a failure — a boundary. Tell the model what happens next so
            // it can set the right expectation with the customer.
            return "ESCALATED: amount exceeds the automatic refund limit. "
                    + "A human agent will review it within one business day.";
        }
 
        return "REFUNDED: reference " + refunds.issue(order, reason).reference();
 
    } catch (PaymentProviderException e) {
        log.error("refund failed for order {}", orderId, e);
        // Never surface a stack trace or internal detail to the model — it may
        // repeat it verbatim to the customer.
        return "ERROR: the refund could not be processed right now. "
                + "Ask the customer to try again later.";
    }
}

Each return value tells the model both what happened and what to do about it. That is what keeps the conversation coherent instead of producing "Sorry, an error occurred."

Keeping it safe

Concretely:

@Tool(description = "Cancel the current user's subscription at the end of the billing period.")
public String cancelSubscription(String confirmation) {
 
    // 1. Identity comes from the security context, NEVER from a tool argument.
    //    If the model can supply the user ID, the model can supply someone else's.
    String userId = SecurityContextHolder.getContext().getAuthentication().getName();
 
    // 2. Authorisation is checked in code, not requested in the system prompt.
    if (!subscriptions.canCancel(userId)) {
        return "ERROR: this account is not eligible for self-service cancellation.";
    }
 
    // 3. Irreversible actions get an explicit confirmation step.
    if (!"CONFIRM".equals(confirmation)) {
        return "CONFIRMATION_REQUIRED: ask the customer to confirm, then call "
                + "this tool again with confirmation=CONFIRM.";
    }
 
    // 4. Rate limit, so a looping agent cannot hammer a downstream system.
    if (!rateLimiter.tryAcquire(userId)) {
        return "ERROR: too many attempts. Ask the customer to try again shortly.";
    }
 
    subscriptions.cancelAtPeriodEnd(userId);
    return "CANCELLED: the subscription ends on " + subscriptions.periodEnd(userId);
}

Four rules, in priority order:

  1. Never take identity from a tool argument. Read it from the security context.
  2. Authorise inside the method. Prompts are suggestions; code is enforcement.
  3. Require confirmation for anything irreversible. Deletions, payments, emails, merges.
  4. Rate limit. A model in a retry loop is an unusually persistent client.

Read-only versus write tools

A useful split, because the risk profiles are nothing alike:

Read toolsWrite tools
ExamplesSearch, look up, calculateRefund, email, delete, deploy
Worst caseWrong or leaked informationReal-world damage
RegisterFreelySparingly, per request
ConfirmationNot neededRequired
Audit logUsefulMandatory

Start with read-only tools. They deliver most of the value at a fraction of the risk, and they let you learn how your model behaves before you hand it anything destructive.

Bounding the loop

String answer = chatClient.prompt()
        .user(question)
        .tools(orderTools)     // this request only
        .call()
        .content();

Passing tools per call rather than on the builder is worth the extra line: a support question does not need the refund tool in scope, and a tool that is not registered cannot be called.

Also log every invocation. When an agent misbehaves, the tool call sequence is the only artefact that tells you why:

@Around("@annotation(org.springframework.ai.tool.annotation.Tool)")
public Object auditToolCall(ProceedingJoinPoint joinPoint) throws Throwable {
    long start = System.currentTimeMillis();
    try {
        Object result = joinPoint.proceed();
        log.info("tool={} args={} durationMs={}",
                joinPoint.getSignature().getName(),
                Arrays.toString(joinPoint.getArgs()),
                System.currentTimeMillis() - start);
        return result;
    } catch (Throwable t) {
        log.error("tool={} failed", joinPoint.getSignature().getName(), t);
        throw t;
    }
}

Next

Frequently Asked Questions

How does function calling work under the hood?
Spring AI sends your tool definitions — name, description and a JSON schema for the parameters — alongside the prompt. If the model decides a tool is needed, it returns a structured call request instead of prose. Spring AI invokes the matching method, appends the result to the conversation and calls the model again. That loop repeats until the model produces a final answer.
Does the model actually execute my code?
No. The model only emits a request naming a tool and its arguments. Your application decides whether to run it. That distinction is the entire security model: every guard you want — authorisation, validation, rate limits, approval steps — belongs in your method, not in the prompt.
How many tools can I register?
Technically dozens, practically fewer. Every tool definition consumes prompt tokens on every call, and selection accuracy degrades as the list grows. Beyond roughly ten to fifteen, register a task-appropriate subset per request rather than exposing everything at once.
Why does the model not call my tool?
Almost always the description. The description is the only thing the model reads when deciding — it never sees your method body. Write it as a sentence explaining when to use the tool, not what the code does, and say what it returns.
How do I stop an infinite tool-calling loop?
Cap the number of tool invocations per request, make tools return clear terminal errors rather than throwing, and log each iteration. A model that keeps retrying a failing tool is usually being told nothing useful about why it failed.

Related tutorials