Skip to content
tnsaijava agent framework

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

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:

  • DynamicToolMethodresolveTimeoutMs returns 0 for 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.