TnsAI

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.

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:

Agent agent = AgentBuilder.create()
    .llm(new OpenAIClient("gpt-4o"))
    .role(myRole)
    .toolPojos(new CalculatorTools())
    .build();

agent.chat("What is 17% of 240?");
// LLM emits: calculator("240 * 0.17") -> 40.8

The 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.

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
    .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())
    .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)
    .build();

DynamicToolMethod and POJO @Tool methods share the same registry and dispatcher — the LLM can't tell them apart.

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.

On this page