This version is still in development and is not considered stable yet. For the latest stable version, please use Spring AI 2.0.1!

Tool Call Confirmation

Some tools should not run just because the model asked for them. Booking a flight, deleting a file or sending an email are actions a person may want to approve first. Spring AI lets you mark such tools as needing confirmation. When the model calls one, the tool-calling loop pauses: no tool call of that reply runs, and the reply goes back to your application with the calls that wait for a decision. Your application shows them to a person, runs the reply’s calls as the person decided, and continues the conversation with the results.

The loop keeps nothing while it waits. The pause is an ordinary response, and the resumption is an ordinary request. This keeps the feature stateless on the framework side and lets it survive restarts, as long as your application keeps the conversation.

Tool call confirmation is available since Spring AI 2.1.0.

How It Works

The confirmation check lives in the ToolCallingManager, the component that executes tool calls. Every caller of the manager gets it, including the ToolCallingAdvisor loop, custom loops and user-controlled execution.

  1. The model replies with one or more tool calls.

  2. Before any call of the reply runs, the manager asks its ToolCallConfirmationPolicy which calls need confirmation.

  3. If any does, the manager throws ToolCallConfirmationRequiredException, and nothing of the reply runs.

  4. ToolCallingAdvisor catches the exception and returns the reply to the caller, listing the keys of the calls that wait for a decision in the response metadata. A key is usually the call’s id.

  5. Your application collects one ToolCallDecision per tool call of the reply: allow it, or deny it with a message.

  6. Your application runs the reply’s calls with those decisions through ToolCallingManager.executeToolCalls(prompt, reply, decisions). Allowed calls run, and a denied call answers the model with the decision’s message instead of running.

  7. Your application sends the tool results to the model as its next request, and the conversation continues.

Marking Tools That Need Confirmation

With the default policy, a tool needs confirmation when its metadata says so. For a @Tool method, set requiresConfirmation:

class BookingTools {

    @Tool(description = "Book a flight", requiresConfirmation = true)
    String bookFlight(String flightNumber) {
        return bookingService.book(flightNumber);
    }

    @Tool(description = "Get the status of a flight")
    String flightStatus(String flightNumber) {
        return bookingService.status(flightNumber);
    }

}

For a programmatic tool, set it on the ToolMetadata:

ToolCallback bookFlight = FunctionToolCallback.builder("bookFlight", bookingService::book)
    .description("Book a flight")
    .inputType(BookingRequest.class)
    .toolMetadata(ToolMetadata.builder().requiresConfirmation(true).build())
    .build();

Calls of flightStatus run as usual. A reply that asks for both flightStatus and bookFlight pauses as a whole, so that the person decides on the reply before any of it runs.

Confirmation Policy

The ToolCallConfirmationPolicy decides which calls need confirmation. It sees the tool call, with its name and arguments, and the metadata of the tool the call would run with.

Policy Calls that need confirmation

ToolCallConfirmationPolicy.fromToolMetadata()

Calls of tools marked requiresConfirmation. This is the default.

ToolCallConfirmationPolicy.forTools("bookFlight", "cancelBooking")

Calls of the named tools, whatever their metadata says. Useful for tools you do not own, such as MCP tools.

ToolCallConfirmationPolicy.none()

No calls.

policy.or(other)

Calls that either policy names.

Since the policy is a functional interface, it can also look at the arguments:

ToolCallConfirmationPolicy expensiveTransfers = (toolCall, toolMetadata) ->
        toolCall.name().equals("transferMoney") && amountOf(toolCall.arguments()) > 1_000;

Configure the policy on the ToolCallingManager, and pass that manager to the tool-calling advisor:

ToolCallingManager toolCallingManager = ToolCallingManager.builder()
    .toolCallConfirmationPolicy(ToolCallConfirmationPolicy.fromToolMetadata()
        .or(ToolCallConfirmationPolicy.forTools("deleteFile")))
    .build();

ChatClient chatClient = ChatClient.builder(chatModel)
    .defaultAdvisors(ToolCallingAdvisor.builder().toolCallingManager(toolCallingManager).build())
    .defaultTools(new BookingTools())
    .build();

Use the same manager to run the decisions when you resume.

Spring Boot

The auto-configured ToolCallingManager honors the requiresConfirmation flag by default. The ChatClient and the Tool Search Tool auto-configurations use that manager, and so can your application when it resumes, by injecting it.

Name further tools with a property, for example tools you cannot annotate, such as MCP tools:

spring.ai.tools.confirmation.tools=deleteFile,transferMoney

Use the names the model sees, including any prefix, since a name that matches no tool has no effect.

For rules that look at the call itself, declare ToolCallConfirmationPolicy beans:

@Bean
ToolCallConfirmationPolicy largeTransfers() {
    return (toolCall, toolMetadata) -> toolCall.name().equals("transferMoney")
            && amountOf(toolCall.arguments()) > 1_000;
}

The auto-configuration combines the flag, the property and every policy bean with or: a call needs confirmation when any of them says so. A property or a bean can therefore only add calls that need confirmation, never remove them. The one way to stop honoring the requiresConfirmation flag is to disable it explicitly, which logs a warning at startup:

spring.ai.tools.confirmation.enabled=false

Do that only where nobody can confirm, such as tests or batch jobs. The listed tools and the policy beans still apply.

Property

Description

Default

spring.ai.tools.confirmation.enabled

Whether calls of tools marked requiresConfirmation need a person’s confirmation.

true

spring.ai.tools.confirmation.tools

Names of tools whose calls need confirmation, whatever their metadata says.

—

To take full control of the policy, declare your own ToolCallingManager bean, which replaces the auto-configured one.

Recognizing a Paused Reply

A paused reply is a regular ChatResponse whose assistant message holds the tool calls. ToolCallDecisions.awaitingDecision(response) returns the keys of the calls that wait for a decision, and an empty list for any other response. A key is usually the call’s id, see Tool Call Keys.

ChatResponse reply = chatClient.prompt()
    .user("Book flight VX205 for tomorrow")
    .call()
    .chatResponse();

List<String> awaiting = ToolCallDecisions.awaitingDecision(reply);
if (!awaiting.isEmpty()) {
    List<AssistantMessage.ToolCall> toolCalls = reply.getResult().getOutput().getToolCalls();
    // Show the calls, with their names and arguments, to the person
}
Read the reply with chatResponse() or chatClientResponse(). content() returns the paused reply’s text, which is often empty, and gives no sign that the loop paused. A streamed content() simply ends.

The keys name the calls that need confirmation. The decisions must still cover every call of the reply, see Decisions.

When streaming, the reply’s text streams as usual, and a last chunk carries the tool calls and the awaiting keys. Check the last chunk, since an aggregated response does not carry the keys:

List<ChatResponse> chunks = chatClient.prompt()
    .user("Book flight VX205 for tomorrow")
    .stream()
    .chatResponse()
    .collectList()
    .block();

ChatResponse last = chunks.get(chunks.size() - 1);
List<String> awaiting = ToolCallDecisions.awaitingDecision(last);

The last chunk keeps the reply’s finish reason, so advisors outside the loop see the end of the turn as usual.

Tool Call Keys

Decisions name the calls of a reply by key. A call’s key is its id. Some model providers return tool calls without ids, and a reply can give two calls the same id. Such a call is named by its position in the reply instead: #0 for the first call, #1 for the second, and so on.

ToolCallDecisions.toolCallKeys(message) returns the keys of a reply’s calls, in the order of message.getToolCalls(), so you can show each call next to its key:

AssistantMessage message = reply.getResult().getOutput();
List<String> keys = ToolCallDecisions.toolCallKeys(message);
for (int i = 0; i < keys.size(); i++) {
    AssistantMessage.ToolCall toolCall = message.getToolCalls().get(i);
    // Show toolCall.name() and toolCall.arguments() for keys.get(i)
}

The reply itself is never changed: the paused reply and the tool results carry the ids as the model gave them. A position key depends on the order of the reply’s tool calls, so resume with the reply as it was stored, with its calls in their original order.

Decisions

ToolCallDecisions holds one ToolCallDecision per tool call of the paused reply:

ToolCallDecisions decisions = ToolCallDecisions.of(
        ToolCallDecision.allow("call_1"),
        ToolCallDecision.deny("call_2"),
        ToolCallDecision.deny("call_3", "The user wants an aisle seat, ask again."));
  • allow(key) lets the call run.

  • deny(key) answers the model with ToolCallDecision.DEFAULT_DENY_MESSAGE as the call’s result.

  • deny(key, message) answers the model with your message, so it can react, for example by asking the user a question.

The manager enforces three rules before anything runs, and throws IllegalArgumentException when one is broken:

  • Every call of the reply needs a decision, including calls the policy did not flag. The policy is not consulted again on resumption, so a call cannot slip through because the tools or the policy changed in between.

  • A decision must name a call of the reply.

  • An allowed call runs only with a tool the prompt carries. The resumption never falls back to resolving a tool by name.

Resuming the Conversation

Run the decisions through the manager with the prompt the model answered, carrying the tools, and the paused reply. The result’s conversation history ends with the reply and a ToolResponseMessage holding one result per call, in the order the model asked for them.

How you continue depends on who keeps the conversation.

Your Application Keeps the Conversation

Pass the conversation as the prompt and send the whole history on:

ToolCallingChatOptions options = ToolCallingChatOptions.builder()
    .toolCallbacks(ToolCallbacks.from(new BookingTools()))
    .build();

// The conversation the model answered with the paused reply
Prompt prompt = new Prompt(conversation, options);

ToolExecutionResult result = toolCallingManager.executeToolCalls(prompt, reply, decisions);

String answer = chatClient.prompt()
    .messages(result.conversationHistory())
    .call()
    .content();

The prompt may end with the paused reply itself, the history then holds it once.

A Chat Memory Advisor Keeps the Conversation

MessageChatMemoryAdvisor and the ChatMemory API are deprecated since 2.1.0, for removal in 3.0.0, in favor of session memory; see Migrating to Session Memory.

The memory already holds the user message and the paused reply. Send only the tool results, which are the last message of the history:

ToolExecutionResult result = toolCallingManager.executeToolCalls(
        new Prompt(List.of(), options), reply, decisions);

List<Message> history = result.conversationHistory();

String answer = chatClient.prompt()
    .messages(history.get(history.size() - 1))
    .advisors(a -> a.param(MemoryAdvisor.CONVERSATION_ID, conversationId))
    .call()
    .content();

This works with the memory advisor outside the loop (the default) and inside the loop. In both cases the memory ends up with the user message, the reply with the tool calls, the tool results and the final answer, which is the order model providers expect.

Turns With Several Rounds

A turn can run tools before it pauses. For example, the model looks up a flight with an ungated tool, then asks to book it with a gated one. The paused response carries only the reply that paused, not the earlier rounds of the turn: their tool calls and tool results stay inside the loop.

Where those rounds end up depends on the memory advisor placement:

  • Outside the loop (the default), the memory never sees them. The resumed request reaches the model without the earlier results, so the model may lack information it based the paused call on. An application that keeps the conversation itself has the same gap, since it receives only the paused reply.

  • Inside the loop, the memory stores every round as it happens. The resumed request carries the whole turn, including the earlier tool calls and results.

If the turns of your application can run tools before a gated one, place the memory advisor inside the loop:

ChatClient chatClient = ChatClient.builder(chatModel)
    .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory)
        .order(ToolCallingAdvisor.DEFAULT_ORDER + 100)
        .build())
    .defaultTools(new BookingTools())
    .build();

The auto-registered ToolCallingAdvisor then forwards only the newest message of each round and leaves the history to the memory. If you register the ToolCallingAdvisor yourself, call disableInternalConversationHistory() on its builder. Resume as shown in A Chat Memory Advisor Keeps the Conversation. Inside the loop, the memory stores tool calls and tool results, so check that your ChatMemoryRepository supports them. See Tool Calling: Inside the Loop.

Return Direct

When every call of the reply was allowed and every called tool has returnDirect = true, result.returnDirect() is true. The tool results are then the answer, and the model does not need to see them. Use ToolExecutionResult.buildGenerations(result) to turn them into generations for the caller. A denied call always sends the results back to the model.

Tool Call Limits

Tool call limits apply to the allowed calls. On resumption the manager counts the calls of the current turn that it finds in the prompt, so a prompt without the turn’s earlier rounds counts fewer calls. When a limit is exceeded, the manager throws ToolCallLimitExceededException to your application, since there is no loop to turn it into a reply.

Security Considerations

The manager runs the tool calls of the reply you give it, with their arguments. Read the paused reply from storage the end user cannot change: your own conversation store, a chat memory, or a session log. Never take it from a client request. A client that can edit the reply can run any tool with any arguments, and approve the call itself.

The framework does not:

  • Authenticate the approver. Checking that the person who decides may approve the call is your application’s job.

  • Prevent replays. Running the same decisions twice runs the tools twice. Make the resumption idempotent, or record that a pause was resolved.

  • Sanitize deny messages. A deny message goes to the model as a tool result. Treat it as model input from whoever provides the decision.

Abandoned Pauses

If the person never decides, a chat memory holds a reply with tool calls but no tool results. Most model providers reject such a conversation on the next request. Before you accept new input for a conversation with a pending pause, resolve the pause, for example by denying all its calls with a message such as "The user moved on.".

Tool call confirmation works with the Tool Search Tool. Tools the search discloses carry their metadata, so a disclosed tool marked requiresConfirmation pauses the loop like any other. Resume with a prompt that carries your full tool set, the same one you configured on the ChatClient.

The loop returns only the paused reply, not the search rounds before it, as for any turn with several rounds. With the memory advisor inside the loop, the memory keeps those rounds, and the disclosed tools are offered again after the resumption. Otherwise the model sees only the search tool again and searches anew if it needs another tool.

Custom Tool Calling Managers

ToolCallingManager.executeToolCalls(Prompt, ChatResponse, ToolCallDecisions) is a default method that throws UnsupportedOperationException. A custom manager that delegates to DefaultToolCallingManager should delegate this method too, as GoogleGenAiToolCallingManager does.

See Also