Custom tools
A custom tool is a public method annotated @Tool on an ordinary Java class. There is no base class or interface to implement: pass an instance to AgentBuilder.toolPojos(...) and each @Tool method becomes a function the model can call.
For the toolkits that ship with TnsAI, see the catalog. To choose between an @ActionSpec action, a @Tool method and a BuiltInTool, see Actions and tools.
A minimal toolkit
import com.tnsai.annotations.Tool;
import com.tnsai.annotations.ToolParam;
public class CalculatorTools {
@Tool(name = "calculator", description = "Evaluate an arithmetic expression")
public double calculator(
@ToolParam(description = "Expression like '2 + 2 * (3 - 1)'") String expression
) {
return new ExpressionParser().parse(expression).evaluate();
}
}Register it. build() requires principal and authorityScope; liabilitySink is optional. Later snippets leave these calls out.
package com.example.tnsai.docs;
import com.tnsai.accountability.AuthorityScope;
import com.tnsai.accountability.LiabilitySink;
import com.tnsai.agents.Agent;
import com.tnsai.agents.AgentBuilder;
import com.tnsai.identity.AgentPrincipal;
import com.tnsai.llm.providers.OpenAIClient;
import com.tnsai.roles.Role;
public final class ToolsCustomBuilderExample {
private ToolsCustomBuilderExample() {}
public static Agent build(
Role myRole,
Object calculatorTools,
AgentPrincipal principal,
LiabilitySink sink,
AuthorityScope scope
) {
return AgentBuilder.create()
.llm(new OpenAIClient("gpt-4o"))
.role(myRole)
.toolPojos(calculatorTools)
.principal(principal)
.liabilitySink(sink)
.authorityScope(scope)
.build();
}
}The model calls the tool by its @Tool name, calculator here. When name is empty, the method name is used. description is required. @ToolParam descriptions go into the JSON Schema sent to the model, so write them as you would document an API parameter.
Parameter names come from @ToolParam(name = ...) or from the compiled method. Compile your toolkits with -parameters, or name each parameter. Otherwise registration fails with IllegalStateException.
Only public, non-static methods are registered.
Several tools on one class
Each @Tool method is a separate tool, and the model picks one per call.
public class WeatherTools {
@Tool(name = "weather_current", description = "Current weather for a city")
public WeatherSnapshot weatherCurrent(
@ToolParam(description = "City name, e.g. 'Istanbul'") String city
) {
return weatherClient.getCurrent(city);
}
@Tool(name = "weather_forecast", description = "5-day forecast for a city")
public List<DailyForecast> weatherForecast(
@ToolParam(description = "City name") String city,
@ToolParam(description = "Number of days, 1-5") int days
) {
return weatherClient.getForecast(city, days);
}
}toolPojos(new WeatherTools()) registers both. The model receives the return value as text, through its toString(). Return a String, or a type such as a record whose toString() reads well.
Credentials
Read API keys from the environment when the tool runs, not in the constructor. BuiltInTool creates toolkits through their no-arg constructors, and this keeps your toolkit usable the same way.
public class WeatherTools {
private static String requireApiKey() {
String key = System.getenv("WEATHER_API_KEY");
if (key == null || key.isBlank()) {
throw new IllegalStateException("WEATHER_API_KEY environment variable is required");
}
return key;
}
@Tool(name = "weather_current", description = "Current weather for a city")
public WeatherSnapshot weatherCurrent(@ToolParam(description = "City") String city) {
String key = requireApiKey();
// ...
}
}Mixing with shipped toolkits
toolPojos(...), builtInTools(...) and dynamicTool(...) fill the same registry. Tool names must be unique per agent; a duplicate fails the build with IllegalArgumentException. See Register tools.
Per-tool deadlines
public class SearchTools {
@Tool(description = "Search the web for a query", timeoutMs = 5_000)
public String search(@ToolParam(description = "Query") String query) {
return httpClient.get(query);
}
}A positive timeoutMs is one wall-clock budget for the call, including idempotency handling and the method body. When it runs out, the call fails with ToolTimeoutException, which is retryable. The default, 0, adds no deadline of its own.
The worker is interrupted when the deadline passes. A method that ignores interruption can keep running. timeoutMs applies only to @Tool methods: a DynamicToolMethod has no deadline.
Related
- Register tools: runtime-defined tools, filters and listeners.
- Idempotency: tools that must not run twice.
- Actions: per-action model settings such as
llmSystemPrompt.