Actions vs Tools
TnsAI has three overlapping extension points. They are not interchangeable.
| Primitive | Where it lives | Who calls it | Schema for the LLM | Typical trigger |
|---|---|---|---|---|
@ActionSpec on a Role method | Role class | ActionExecutor (executeAction / role routing) | Not a function schema. An LLM action can use registered tools; the action method itself is not one | Deterministic, typed work the agent (or your code) invokes by name |
@Tool on a POJO method | Any POJO you register | ToolMethodDispatcher during an LLM tool-call loop | Yes — method name + @ToolParam become the JSON schema | The model chooses a function at generation time |
BuiltInTool enum constant | tnsai-tools catalog | Same dispatcher, after AgentBuilder.builtInTools(...) instantiates the POJO | Yes — every @Tool on the backing class | You want a shipped toolkit instead of writing one |
BuiltInTool is not a third runtime. It is a compile-safe index of shipped @Tool POJOs (BuiltInTool.java). A ServiceLoader replacement is tracked as TAN-2977 — do not treat the enum as the forever registration API.
Decision
Use @ActionSpec when the call must be typed, deterministic, and routed by ActionType (LOCAL, WEB_SERVICE, LLM, MCP_TOOL). Approvals, contracts, and resilience hang off this path. See Action System.
Use @Tool when the LLM should see a function and pick it. Register the POJO with AgentBuilder.toolPojos(...). See Custom Tools.
Use BuiltInTool when that function already ships in tnsai-tools. Register the enum constant; do not re-wrap the POJO in an action. See Catalog.
Same domain, three primitives
Forecast lookup as a local action, a custom tool, and a shipped toolkit. AgentBuilder.build() still needs the accountability trio; the snippets below omit it.
1. Local @ActionSpec — your code calls it
public class WeatherRole extends Role {
@Override public RoleIdentity getIdentity() {
return new RoleIdentity("weather", "Looks up forecasts", "ops");
}
@ActionSpec(type = ActionType.LOCAL, description = "Return a stored forecast")
public String forecast(String city) {
return weatherService.forecast(city);
}
}
ActionResponse response = agent.executeAction(
ActionRequest.of("forecast", Map.of("city", "Istanbul")));The LLM does not receive a forecast function schema from this method. To hide an action from any LLM-facing list, set excludeFromLLM = true.
2. @Tool POJO — the model calls it
public class WeatherTools {
@Tool(name = "weather_forecast", description = "Forecast for a city")
public String weatherForecast(
@ToolParam(description = "City name, e.g. Istanbul") String city
) {
return weatherService.forecast(city);
}
}
Agent agent = AgentBuilder.create()
.llm(llm)
.role(role)
.toolPojos(new WeatherTools())
.principal(principal)
.liabilitySink(sink)
.authorityScope(scope)
.build();The dispatcher exposes weather_forecast as a function. Do not put API keys or other secrets in @Tool / @ToolParam descriptions or names — they are sent to the model. Keep credentials in environment variables (see Custom Tools). Per-call policy belongs on setToolCallFilter / before-hooks (TAN-2886), not in the schema.
3. BuiltInTool — shipped catalog, same dispatcher
There is no BuiltInTool.HTTP_TOOLS. For "let the model look this up" use a shipped search toolkit:
Agent agent = AgentBuilder.create()
.llm(llm)
.role(role)
.builtInTools(BuiltInTool.WEB_SEARCH_TOOLS)
.principal(principal)
.liabilitySink(sink)
.authorityScope(scope)
.build();WEB_SEARCH_TOOLS instantiates com.tnsai.tools.search.WebSearchTools and registers its @Tool methods (brave_search, duckduckgo, …). Same ToolMethodDispatcher as a custom POJO.
Do not
- Do not wrap a
BuiltInToolPOJO in an@ActionSpecjust to "expose" it. Register the enum (ortoolPojos) and let the dispatcher own the schema. - Do not put an
@ActionSpecon a method and expect the LLM to call it as a function. That is@Tool. - Do not put secrets in tool schemas.
- Do not invent
BuiltInTool.HTTP_TOOLS— it is not on the 0.13.0 enum.