@AiTool -- Framework-Agnostic Tool Calling
In Chapter 9 you built an AI endpoint that streams LLM responses to the browser. But LLMs can do more than generate text — they can decide to call tools: functions you define that the model can invoke when it needs external data or wants to take an action. This chapter covers @AiTool, Atmosphere’s framework-agnostic annotation for tool calling.
The Problem with Framework-Specific Tools
Section titled “The Problem with Framework-Specific Tools”Every AI framework has its own way of defining tools:
- LangChain4j uses
@Toolon methods with@Pfor parameters - Spring AI uses
ToolCallbackandToolDefinitioninterfaces - Google ADK uses
BaseToolclasses
If you define tools with one framework’s annotations, switching to another requires rewriting every tool. Atmosphere solves this with @AiTool — you define tools once, and the framework bridges them to whatever backend you are using.
@AiTool Annotation
Section titled “@AiTool Annotation”@AiTool marks a method as an AI-callable tool. It has two required attributes:
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface AiTool {
/** Tool name as exposed to the AI model. Convention: snake_case. */ String name();
/** Human-readable description of what the tool does. */ String description();}| Attribute | Type | Description |
|---|---|---|
name | String | Unique tool name (snake_case convention, e.g., "get_weather") |
description | String | Human-readable description sent to the model to help it decide when to call the tool |
@Param Annotation
Section titled “@Param Annotation”@Param annotates parameters of an @AiTool-annotated method to provide metadata for the AI model’s tool schema:
@Target(ElementType.PARAMETER)@Retention(RetentionPolicy.RUNTIME)public @interface Param {
/** Parameter name as exposed to the AI model. */ String value();
/** Human-readable description of this parameter. */ String description() default "";
/** Whether this parameter is required. Defaults to true. */ boolean required() default true;}| Attribute | Type | Default | Description |
|---|---|---|---|
value | String | (required) | Parameter name as exposed to the model |
description | String | "" | Human-readable description |
required | boolean | true | Whether the model must provide this parameter |
Complete Example: AssistantTools
Section titled “Complete Example: AssistantTools”This is the AssistantTools class from the spring-boot-ai-tools sample:
public class AssistantTools {
@AiTool(name = "get_current_time", description = "Returns the current date and time in the server's timezone") public String getCurrentTime() { return ZonedDateTime.now() .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z")); }
@AiTool(name = "get_city_time", description = "Returns the current time in a specific city") public String getCityTime( @Param(value = "city", description = "City name (e.g., Tokyo, London, Paris, New York, Sydney)") String city) { var zone = switch (city.toLowerCase()) { case "tokyo" -> "Asia/Tokyo"; case "london" -> "Europe/London"; case "paris" -> "Europe/Paris"; case "sydney" -> "Australia/Sydney"; case "new york", "nyc" -> "America/New_York"; case "los angeles", "la" -> "America/Los_Angeles"; default -> "UTC"; }; return city + ": " + ZonedDateTime.now(ZoneId.of(zone)) .format(DateTimeFormatter.ofPattern("HH:mm:ss (z)")); }
@AiTool(name = "get_weather", description = "Returns a weather report for a city with temperature and conditions") public String getWeather( @Param(value = "city", description = "City name to get weather for") String city) { return switch (city.toLowerCase()) { case "london" -> "London: Cloudy, 15C / 59F, 80% humidity"; case "paris" -> "Paris: Partly cloudy, 20C / 68F, 65% humidity"; case "tokyo" -> "Tokyo: Rainy, 22C / 72F, 90% humidity"; default -> city + ": Clear, 22C / 72F, 50% humidity"; }; }
@AiTool(name = "convert_temperature", description = "Converts a temperature between Celsius and Fahrenheit") public String convertTemperature( @Param(value = "value", description = "The temperature value to convert") double value, @Param(value = "from_unit", description = "Source unit: 'C' for Celsius or 'F' for Fahrenheit") String fromUnit) { if ("C".equalsIgnoreCase(fromUnit) || "celsius".equalsIgnoreCase(fromUnit)) { double fahrenheit = value * 9.0 / 5.0 + 32; return String.format("%.1fC = %.1fF", value, fahrenheit); } else { double celsius = (value - 32) * 5.0 / 9.0; return String.format("%.1fF = %.1fC", value, celsius); } }}Key observations:
- No framework imports — the class uses only
org.atmosphere.ai.annotation.*and standard JDK types. - Return types are plain Java —
String, not framework-specific result objects. - Parameters use
@Param— providing name, description, and optionallyrequired = false. - Type inference — the
doubleparameter forconvert_temperatureis automatically mapped to JSON Schema type"number"by theToolParameter.jsonSchemaType()method.
Connecting Tools to an @AiEndpoint
Section titled “Connecting Tools to an @AiEndpoint”Use the tools attribute on @AiEndpoint:
@AiEndpoint(path = "/atmosphere/langchain4j-tools/{room}", systemPromptResource = "prompts/system-prompt.md", conversationMemory = true, maxHistoryMessages = 30, tools = AssistantTools.class, requires = AiCapability.TOOL_CALLING, interceptors = CostMeteringInterceptor.class)public class AiToolsChat {
@PathParam("room") private String room;
@Ready public void onReady(AtmosphereResource resource) { logger.info("[room={}] Client {} connected (peers: {})", room, resource.uuid(), resource.getBroadcaster().getAtmosphereResources().size()); }
@Disconnect public void onDisconnect(AtmosphereResourceEvent event) { logger.info("[room={}] Client {} disconnected", room, event.getResource().uuid()); }
@Prompt public void onPrompt(String message, StreamingSession session, AtmosphereResource resource) { logger.info("[room={}] Prompt from {}: {}", room, resource.uuid(), message);
var settings = AiConfig.get(); if (settings == null || settings.client().apiKey() == null || settings.client().apiKey().isBlank()) { DemoResponseProducer.stream(message, session, room, "unknown"); return; }
session.stream(message); }}This endpoint demonstrates the full AI tool pipeline:
tools = AssistantTools.class— tells the framework to scanAssistantToolsfor@AiTool-annotated methods and register them.conversationMemory = true— enables multi-turn context so the model can reference previous tool results.maxHistoryMessages = 30— retains up to 30 messages (15 turns) of conversation history.interceptors = CostMeteringInterceptor.class— adds cost estimation and routing metadata.@PathParam("room")— URI template variable for per-room AI sessions.
When session.stream(message) is called:
- Tools from
AssistantToolsare attached to theAiRequest - The framework bridges them to the active backend’s native tool format
- The backend handles the tool call loop automatically
- Tool results are fed back to the model for the final response
Multiple Tool Classes
Section titled “Multiple Tool Classes”You can specify multiple tool provider classes:
@AiEndpoint(path = "/chat", tools = {WeatherTools.class, CalendarTools.class, MathTools.class})Excluding Tools
Section titled “Excluding Tools”When tools is empty (the default), all globally registered tools are available. Use excludeTools to selectively remove some:
@AiEndpoint(path = "/public-chat", excludeTools = {AdminTools.class})ToolRegistry
Section titled “ToolRegistry”The ToolRegistry is the global registry where tool definitions are stored. Tools are registered at startup (via @AiTool scanning or manual registration) and selected per-endpoint.
public interface ToolRegistry {
void register(ToolDefinition tool);
void register(Object toolProvider);
Optional<ToolDefinition> getTool(String name);
Collection<ToolDefinition> getTools(Collection<String> names);
Collection<ToolDefinition> allTools();
boolean unregister(String name);
ToolResult execute(String toolName, Map<String, Object> arguments);}| Method | Description |
|---|---|
register(ToolDefinition) | Register a single tool definition |
register(Object) | Scan an object for @AiTool-annotated methods and register all of them |
getTool(name) | Look up a tool by name |
getTools(names) | Get tools matching the given names (silently skips unknown names) |
allTools() | Get all registered tools |
unregister(name) | Remove a tool by name |
execute(toolName, arguments) | Execute a tool with the given arguments |
execute(toolName, arguments, session) | Execute a tool and auto-emit ToolStart/ToolResult events through the session |
ToolDefinition Record
Section titled “ToolDefinition Record”Each registered tool is represented as a ToolDefinition record:
public record ToolDefinition( String name, String description, List<ToolParameter> parameters, String returnType, ToolExecutor executor) { }| Field | Type | Description |
|---|---|---|
name | String | Unique tool name (must not be blank) |
description | String | Description for the model (must not be blank) |
parameters | List<ToolParameter> | Ordered parameter definitions |
returnType | String | JSON Schema type of the return value (default: "string") |
executor | ToolExecutor | The function that executes the tool |
ToolParameter Record
Section titled “ToolParameter Record”public record ToolParameter( String name, String description, String type, // JSON Schema type: string, integer, number, boolean, object, array boolean required) { }The ToolParameter.jsonSchemaType(Class<?>) static method maps Java types to JSON Schema types:
| Java Type | JSON Schema Type |
|---|---|
String, CharSequence | "string" |
int, Integer, long, Long | "integer" |
float, Float, double, Double | "number" |
boolean, Boolean | "boolean" |
| Everything else | "object" |
ToolExecutor Interface
Section titled “ToolExecutor Interface”@FunctionalInterfacepublic interface ToolExecutor { Object execute(Map<String, Object> arguments) throws Exception;}The executor receives arguments as a Map<String, Object> keyed by parameter name and returns a result that will be serialized to JSON and sent back to the model.
ToolResult Record
Section titled “ToolResult Record”public record ToolResult(String toolName, String result, boolean success, String error) { public static ToolResult success(String toolName, String result) { ... } public static ToolResult failure(String toolName, String error) { ... }}Manual Tool Registration
Section titled “Manual Tool Registration”You can register tools programmatically without annotations using the builder:
var tool = ToolDefinition.builder("calculate_area", "Calculate the area of a rectangle") .parameter("width", "Width in meters", "number") .parameter("height", "Height in meters", "number") .returnType("number") .executor(args -> { double w = ((Number) args.get("width")).doubleValue(); double h = ((Number) args.get("height")).doubleValue(); return w * h; }) .build();
toolRegistry.register(tool);The builder API:
| Builder Method | Description |
|---|---|
builder(name, description) | Create a new builder |
parameter(name, description, type) | Add a required parameter |
parameter(name, description, type, required) | Add a parameter with explicit required flag |
returnType(type) | Set the return type (default: "string") |
executor(ToolExecutor) | Set the execution function (required) |
build() | Build the ToolDefinition |
Tool Execution Events
Section titled “Tool Execution Events”ToolRegistry.execute(toolName, arguments, session) automatically emits AiEvent.ToolStart and AiEvent.ToolResult (or AiEvent.ToolError) through the StreamingSession. This provides live tool activity visibility in the frontend without manual event emission:
// Auto-emits ToolStart before invocation, ToolResult/ToolError aftertoolRegistry.execute("get_weather", Map.of("city", "Montreal"), session);The frontend receives these as structured JSON event frames:
{"event":"tool-start","data":{"toolName":"get_weather","arguments":{"city":"Montreal"}},...}{"event":"tool-result","data":{"toolName":"get_weather","result":"..."},...}The useStreaming React hook exposes these via the aiEvents array.
How Tool Bridging Works
Section titled “How Tool Bridging Works”When session.stream(message) is called on an endpoint with tools, the framework:
- Collects all
ToolDefinitioninstances from theToolRegistrythat match the endpoint’stoolsattribute - Bridges them to the active backend’s native format using a ToolBridge
- The backend handles the tool call loop
Each AI backend has its own bridge:
Spring AI (SpringAiToolBridge)
Section titled “Spring AI (SpringAiToolBridge)”Converts Atmosphere ToolDefinition to Spring AI ToolCallback:
- Builds a JSON Schema from
ToolParameterdefinitions - Wraps the
ToolExecutorin aToolCallback.call(String)implementation - Spring AI handles the tool call loop automatically — it invokes the callback and feeds results back to the model
LangChain4j (LangChain4jToolBridge)
Section titled “LangChain4j (LangChain4jToolBridge)”Converts Atmosphere ToolDefinition to LangChain4j ToolSpecification:
- Maps
ToolParametertypes to LangChain4j JSON schema elements (JsonStringSchema,JsonIntegerSchema,JsonNumberSchema,JsonBooleanSchema) - Unlike Spring AI, LangChain4j does not automatically execute tool callbacks — when the model responds with
ToolExecutionRequests, theLangChain4jToolBridge.executeToolCalls()method runs the tools and returnsToolExecutionResultMessages to feed back to the model - This loop is handled by
ToolAwareStreamingResponseHandler
Google ADK (AdkToolBridge)
Section titled “Google ADK (AdkToolBridge)”Converts Atmosphere ToolDefinition to ADK BaseTool:
- Wraps each tool as an ADK-compatible tool object
- ADK handles the tool call loop through its agent event system
Conversation Memory with Tools
Section titled “Conversation Memory with Tools”When tools are combined with conversationMemory = true, the conversation history includes tool calls and results. This lets the model:
- Reference previous tool results (“What was the weather in London earlier?”)
- Build on previous answers (“Convert that temperature to Fahrenheit”)
- Use context from earlier turns to decide whether to call a tool again
The AiConversationMemory stores ChatMessage objects, and the sliding window at maxHistoryMessages ensures memory usage stays bounded.
@AiEndpoint(path = "/chat", tools = AssistantTools.class, conversationMemory = true, maxHistoryMessages = 30)With 30 messages and 4 tools, a typical conversation might look like:
User: "What time is it in Tokyo?"→ Tool call: get_city_time(city="Tokyo")→ Tool result: "Tokyo: 14:23:45 (JST)"→ Assistant: "It's currently 2:23 PM in Tokyo (JST)."User: "And the weather there?"→ Tool call: get_weather(city="Tokyo")→ Tool result: "Tokyo: Rainy, 22C / 72F, 90% humidity"→ Assistant: "Tokyo is currently rainy at 22C..."All of these messages are retained in the conversation memory, giving the model full context for follow-up questions.
Samples
Section titled “Samples”Two sample applications demonstrate tool calling:
samples/spring-boot-ai-tools/— uses the built-in LLM client with@AiToolmethods (AssistantTools), conversation memory, and theCostMeteringInterceptor. Run with:./mvnw spring-boot:run -pl samples/spring-boot-ai-toolssamples/spring-boot-langchain4j-tools/— same tools, but powered by the LangChain4j adapter withToolAwareStreamingResponseHandler. Run with:./mvnw spring-boot:run -pl samples/spring-boot-langchain4j-tools
Both samples share the same AssistantTools class, demonstrating that @AiTool definitions are adapter-independent.
Summary
Section titled “Summary”| Concept | Purpose |
|---|---|
@AiTool(name, description) | Marks a method as an AI-callable tool |
@Param(value, description, required) | Provides parameter metadata for the tool schema |
ToolRegistry | Global registry for tool definitions |
ToolDefinition | Record: name, description, parameters, returnType, executor |
ToolParameter | Record: name, description, JSON Schema type, required |
ToolExecutor | Functional interface that executes the tool |
ToolResult | Record: toolName, result, success, error |
SpringAiToolBridge | Bridges to Spring AI ToolCallback |
LangChain4jToolBridge | Bridges to LangChain4j ToolSpecification |
AdkToolBridge | Bridges to Google ADK BaseTool |
@AiEndpoint(tools={...}) | Selects which tool classes are available at this endpoint |
conversationMemory = true | Enables multi-turn history including tool calls and results |
In the next chapter, you will learn how Atmosphere’s AI adapters connect to Spring AI, LangChain4j, Google ADK, and the built-in OpenAI-compatible client.