toolcue Join the waitlist

Spring AI: only the tool callbacks each turn needs

Pass ChatClient the tool callbacks Toolcue picks for this user message instead of all of them. The model sees fewer, better-matched tools, and the tool loop inside the call keeps that list.

Verified on 2026-11-04 with Spring AI 1.1.8 and 2.0.2, with 5 end-to-end tests and a real Gemini run. Java 17+. Toolcue is in early access: join the waitlist.

Install#

xml
<dependency>
    <groupId>dev.toolcue</groupId>
    <artifactId>toolcue</artifactId>
    <version>0.1.0</version>
</dependency>

The toolcue artifact has no dependencies; it uses your application's Spring AI. Set TOOLCUE_URL, TOOLCUE_TOKEN and TOOLCUE_ADMIN_TOKEN, or pass them to ToolcueClient.builder().

Use it#

java
List<ToolCallback> all = List.of(ToolCallbacks.from(new BillingTools()));        // your @Tool methods
ToolcueToolCallbacks toolcue = ToolcueToolCallbacks.builder(ToolcueClient.create(), "acme-billing", all).build();

// at deploy time: publish the catalog, with what the annotations can't say
toolcue.publish(Map.of(
    "find_customer", t -> t.withProduces("customer_id"),
    "update_tax_settings", t -> t.withRequiredRoles("admin"),
    "ask_user", t -> t.withPinned(true)));

// every user turn
String answer = chatClient.prompt()
    .messages(history)
    .user(message)
    .toolCallbacks(toolcue.forTurn(message, history, Map.of("roles", user.roles())))
    .call().content();

forTurn sends the message, the last ten messages of history (with tool calls and their results) and the user's roles. It returns the callbacks for this turn: the matches, the lookups they need (find_customer before refund_invoice) and your pinned tools. A tool that wasn't offered can't run, because Spring AI only executes the callbacks you pass.

In a Spring Boot app, make ToolcueClient and ToolcueToolCallbacks beans and inject them where you build prompts. Don't also give the ChatClient every tool with defaultToolCallbacks: pass the turn's tools on each prompt.

Pins and scopes#

java
ToolcueToolCallbacks.builder(client, "acme-billing", all)
    .pinned(List.of("ask_user"))                       // on every turn, with their lookups
    .only(List.of("find_customer", "refund_invoice"))  // a refunds-only assistant
    .build();

toolcue.forTurn(Turn.of(message).user(user).pinned(PAGE_TOOLS.get(page)));      // per turn

A turn's pins are added to the builder's; its only, when set, replaces the builder's. Role rules always win.

When Toolcue is down#

forTurn never throws. If Toolcue is slow (over 0.5 s), unreachable or failing, it returns your fallbackTools(...), else only plus the pinned tools, else every callback, and logs a warning. After three failures in a row it stops calling for 30 seconds, so an outage costs nothing per turn.

Run the example#

SpringAiAgent.java in the Java examples runs four turns with Gemini or any OpenAI-compatible model and prints the tools each call got.