# 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](catalog.md).
If you are choosing between `@ActionSpec`, `@Tool`, and `BuiltInTool`, start
at [Actions vs Tools](actions-vs-tools.md). If you are choosing between a Role `@ActionSpec`, a custom `@Tool`, and a `BuiltInTool` constant, start at [Actions vs Tools](../../agents/fundamentals/actions-vs-tools.md).

## A minimal toolkit

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

<!-- java-contract: src/main/java/com/example/tnsai/docs/ToolsCustomBuilderExample.java -->
```java
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();
    }
}
```

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

```java
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:

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

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

```java
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:

```java
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](https://github.com/TnsAI-Framework/TnsAI/pull/164)
([TAN-2958](https://linear.app/tansuasici-workspace-1/issue/TAN-2958)).

```java
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](https://linear.app/tansuasici-workspace-1/issue/TAN-4387)). Do
not document this as a hard kill.

Out of scope for TAN-2958:

- `DynamicToolMethod` — `resolveTimeoutMs` returns `0` for non-static
  tools; there is no builder field.
- MCP server-side deadlines.
- Parallel sibling-tool policy — that remains
  [TAN-3145](https://linear.app/tansuasici-workspace-1/issue/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:

```java
@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](registration.md#tool-call-filters).

## Observability

Use `setToolCallListener` to log every tool invocation — see [Tool Integration](registration.md#tool-call-listeners).
