Custom Tools
A custom tool in TnsAI is a plain Java class with public methods annotated @Tool. The framework discovers them reflectively and exposes each method as a function the LLM can call. There is no base class to extend, no SPI to register, no Tool interface to implement — just an instance you hand to AgentBuilder.toolPojos(...).
For the shipped toolkits (CSV, PDF, web search, Jira, etc.), see the Tool Catalog.
If you are choosing between @ActionSpec, @Tool, and BuiltInTool, start
at Actions vs Tools. If you are choosing between a Role @ActionSpec, a custom @Tool, and a BuiltInTool constant, start at Actions vs 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 and use it. build() requires the accountability trio (TNS-298); later snippets on this page omit those three calls for brevity.
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();
}
}agent.chat("What is 17% of 240?");
// LLM emits: calculator("240 * 0.17") -> 40.8The method name (calculator) is what the LLM sees and calls. @ToolParam descriptions surface in the JSON-Schema sent to the model — write them as you'd document an API parameter.
TnsAI 0.14.0 (TnsAI@a070d674, TAN-3411) fails registration with
IllegalStateException when a parameter has no @ToolParam(name=...)
and the compiler emitted a synthetic argN name. The framework parent
POM already passes -parameters, so the examples above keep working.
Consumer projects that compile their own @Tool POJOs must enable
-parameters or set @ToolParam(name = "expression"). This ships in
Maven Central 0.14.1. Maven Central 0.13.0 registered arg0 as the
schema name.
Multiple methods on one POJO
A toolkit groups related methods on a single class. Each @Tool method is independent — the LLM 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);
}
}Register the whole toolkit in one line:
AgentBuilder.create()
.llm(llm)
.role(role)
.toolPojos(new WeatherTools()) // both weather_current and weather_forecast registered
.principal(principal)
.liabilitySink(sink)
.authorityScope(scope)
.build();Method return values can be any type Jackson can serialise — POJOs, records, Map, List, primitives. The framework serialises the return value to JSON before handing it back to the LLM.
Reading credentials
Toolkits typically read API keys from environment variables on first use rather than via the constructor. Keeps the BuiltInTool.instantiate() path (no-arg constructors) compatible with credential-bearing toolkits.
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 custom POJOs with shipped toolkits
toolPojos(...) and builtInTools(...) accumulate into the same ToolMethodRegistry. Names must be unique across every registered toolkit — a clash fails fast at build() time.
Agent agent = AgentBuilder.create()
.llm(llm)
.role(role)
.builtInTools(BuiltInTool.WEB_SEARCH_TOOLS, BuiltInTool.UTILITY_TOOLS)
.toolPojos(new WeatherTools(), new MyDomainTools())
.principal(principal)
.liabilitySink(sink)
.authorityScope(scope)
.build();Tools that need to be defined at runtime
When a tool's identity is only known at runtime — for example, an MCP proxy fronting a remote server's catalog — use DynamicToolMethod instead of an annotated POJO:
import com.tnsai.tools.method.DynamicToolMethod;
DynamicToolMethod proxy = DynamicToolMethod.builder()
.name("remote_search")
.description("Search the remote knowledge base")
.parameter("query", "string", "Search term")
.handler(args -> remoteClient.search((String) args.get("query")))
.build();
AgentBuilder.create()
.llm(llm)
.role(role)
.dynamicTool(proxy)
.principal(principal)
.liabilitySink(sink)
.authorityScope(scope)
.build();DynamicToolMethod and POJO @Tool methods share the same registry and dispatcher — the LLM can't tell them apart.
Per-tool deadlines
@Tool.timeoutMs ships in Maven Central 0.14.1 (@since 0.14.0).
The contract below is framework PR #164
(TAN-2958).
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);
}
}timeoutMs is additive annotation metadata. A positive value is one
wall-clock budget for the complete reflected POJO dispatch — idempotency
key and store work plus the tool body. Expiry raises retryable
com.tnsai.tools.spi.ToolTimeoutException. timeoutMs = 0 (the default)
adds no dispatcher deadline and keeps whatever timeout the enclosing agent
already applies.
Cancellation is best-effort. The dispatcher interrupts the worker; a tool that ignores interruption can keep running (TAN-4387). Do not document this as a hard kill.
Out of scope for TAN-2958:
DynamicToolMethod—resolveTimeoutMsreturns0for non-static tools; there is no builder field.- MCP server-side deadlines.
- Parallel sibling-tool policy — that remains TAN-3145. A timed-out POJO does not by itself block a later sibling call.
Per-action LLM overrides
If a specific @ActionSpec(type = LLM) action needs its own system prompt or temperature without changing the agent's global LLM config, set them directly on the annotation:
@ActionSpec(
type = ActionType.LLM,
description = "Extract entities from text — must be deterministic",
llmSystemPrompt = "You are a precise NER extractor. Output JSON only.",
llmTemperature = 0.0f
)
public String extractEntities(String text) {
return "Extract entities from: " + text;
}llmSystemPrompt overrides the LLM client's default system prompt for this action only; llmTemperature >= 0 overrides the temperature. Tool exposure stays at the agent level — every @ActionSpec(type = LLM) action sees the agent's complete tool registry.
Permission control
Use setToolCallFilter to gate or block specific tool calls — see Tool Integration.
Observability
Use setToolCallListener to log every tool invocation — see Tool Integration.
Tool Integration
A "tool" in TnsAI is a Java method annotated @Tool. Methods are grouped on POJO classes (toolkits); the framework discovers them reflectively at agent build time and exposes each as a function the LLM can call. Two registration paths share the same underlying ToolMethodRegistry:
Tool Use Examples
Tool use examples are concrete input/output pairs (and counter-examples) that travel alongside a tool definition to the LLM. They teach the model the call patterns the tool expects — including patterns that look reasonable from a language standpoint but break the contract.