Tool Calling
What it is: TramAI's tool calling lets models invoke typed application functions during an operation. Tools are declared explicitly via @Operation(tools = [...]), registered in the builder, and executed inside an engine-owned loop.
When to use it: When the model needs to query data, perform lookups, or trigger deterministic side effects during a single operation. Tool calling is for bounded, typed function execution — not autonomous agent loops.
How the Tool Execution Loop Works
- The model returns one or more tool call requests in its response
- TramAI validates and deserializes the tool arguments using the tool's input schema
- TramAI executes the matching registered tool with a
ToolExecutionContext - TramAI returns the tool result to the model
- The model continues toward a final answer (or makes further tool calls)
The engine owns the entire loop. Applications stay inside the normal service method contract — the tool orchestration is invisible to the caller.
Minimum Setup
Step 1: Define a Tool
Implement the TramaiTool<I, O> interface:
data class LookupInput(val query: String)
data class LookupResult(val value: String)
class LookupTool : TramaiTool<LookupInput, LookupResult> {
override val name: String = "lookup"
override val description: String = "Looks up tenant data by query"
override val inputType = LookupInput::class
override val idempotent: Boolean = true
override val sideEffectLevel: SideEffectLevel = SideEffectLevel.READ_ONLY
override suspend fun execute(
input: LookupInput,
context: ToolExecutionContext,
): LookupResult = LookupResult(
value = tenantDirectory.lookup(input.query),
)
}
Step 2: Declare the Operation
Reference tools by name in @Operation(tools = [...]):
@AiService
interface TenantAssistant {
@Operation(
prompt = "Use the lookup tool when you need tenant data",
model = "claude-sonnet-4-20250514",
tools = ["lookup"],
)
suspend fun answer(question: String): String
}
Tool names must match exactly with registered tools. An empty tools array means no tools are available — this is TramAI's security model (explicit-only).
Step 3: Register and Create
val tramai = Tramai {
provider(
AnthropicProvider(System.getenv("ANTHROPIC_API_KEY")),
name = "anthropic",
default = true,
)
model("claude-sonnet-4-20250514", "anthropic")
tools(LookupTool())
}
val service = tramai.create<TenantAssistant>()
val answer = service.answer("What is the status of tenant ABC?")
Security Model: Explicit-Only Exposure
Tool exposure is always explicit:
@Operation(tools = [...]): The only way to make tools available to a specific operation. An empty array means no tools.tools(...)in builder: The only way to register tool implementations at runtime.- No automatic discovery. No path-based injection. No hidden tool access.
This means the model can only call tools you explicitly authorize for each operation.
Security Metadata on Tools
Every TramaiTool can carry a security property of type ToolSecurityMetadata:
data class ToolSecurityMetadata(
val permission: String,
val risk: RiskLevel,
val approval: ApprovalMode,
val managedNetworkEgress: ManagedNetworkEgress,
val audit: AuditDetail,
val compatibilityMode: CompatibilityMode = CompatibilityMode.STRICT,
)
The policy engine evaluates this metadata at BEFORE_TOOL_EXECUTION. When no policy engine is configured, TramAI uses a legacy permissive mode (all tools allowed, migration warning logged).
class PaymentTool : TramaiTool<PaymentInput, PaymentResult> {
override val name: String = "process_payment"
override val description: String = "Process a customer refund"
override val inputType = PaymentInput::class
override val idempotent: Boolean = false
override val sideEffectLevel: SideEffectLevel = SideEffectLevel.WRITE
override val security: ToolSecurityMetadata = ToolSecurityMetadata(
permission = "payment.process",
risk = RiskLevel.HIGH,
approval = ApprovalMode.REQUIRED,
managedNetworkEgress = ManagedNetworkEgress.AUDIT,
audit = AuditDetail.FULL,
)
// ...
}
Tool Interface Reference
interface TramaiTool<I : Any, O : Any> {
val name: String // Unique tool name
val description: String // Description for model schema
val inputType: KClass<I> // Input type for schema generation
val idempotent: Boolean // Safe to retry on failure? (default: false)
val sideEffectLevel: SideEffectLevel // NONE, READ_ONLY, WRITE, UNKNOWN (default: UNKNOWN)
val security: ToolSecurityMetadata? // Policy metadata (default: null)
suspend fun execute(input: I, context: ToolExecutionContext): O
}
ToolExecutionContext provides runtime metadata:
data class ToolExecutionContext(
val operationName: String,
val modelName: String,
val attemptNumber: Int,
val conversationId: String?,
val idempotencyKey: String?, // Set when resuming an approved tool
val timeout: Duration,
val attributes: Map<String, Any>,
)
Kotlin + Java Examples
Kotlin
data class WeatherInput(val city: String)
data class WeatherResult(val temperature: Double, val conditions: String)
class WeatherTool : TramaiTool<WeatherInput, WeatherResult> {
override val name = "get_weather"
override val description = "Get current weather for a city"
override val inputType = WeatherInput::class
override val idempotent = true
override val sideEffectLevel = SideEffectLevel.READ_ONLY
override suspend fun execute(input: WeatherInput, context: ToolExecutionContext): WeatherResult {
val data = weatherApi.fetch(input.city)
return WeatherResult(data.temp, data.conditions)
}
}
@AiService
interface TravelAssistant {
@Operation(
prompt = "Help the user plan their trip",
model = "gpt-4o",
tools = ["get_weather"],
)
suspend fun planTrip(destination: String): String
}
val tramai = Tramai {
provider(OpenAiProvider(System.getenv("OPENAI_API_KEY")), name = "openai", default = true)
model("gpt-4o", "openai")
tools(WeatherTool())
}
val assistant = tramai.create<TravelAssistant>()
Java
// Java records for input/output
public record WeatherInput(String city) {}
public record WeatherResult(double temperature, String conditions) {}
// Tool implementation
public class WeatherTool implements TramaiTool<WeatherInput, WeatherResult> {
@Override
public String getName() { return "get_weather"; }
@Override
public String getDescription() { return "Get current weather for a city"; }
@Override
public KClass<WeatherInput> getInputType() { return WeatherInput.class; }
@Override
public boolean isIdempotent() { return true; }
@Override
public SideEffectLevel getSideEffectLevel() { return SideEffectLevel.READ_ONLY; }
@Override
public WeatherResult execute(WeatherInput input, ToolExecutionContext context) {
WeatherData data = weatherApi.fetch(input.city());
return new WeatherResult(data.getTemp(), data.getConditions());
}
}
// Service interface
@AiService
public interface TravelAssistant {
@Operation(
prompt = "Help the user plan their trip",
model = "gpt-4o",
tools = {"get_weather"}
)
String planTrip(String destination);
}
// Wiring
Tramai tramai = Tramai.builder()
.provider(new OpenAiProvider(System.getenv("OPENAI_API_KEY")), "openai", true)
.model("gpt-4o", "openai")
.tools(new WeatherTool())
.build();
TravelAssistant assistant = tramai.create(TravelAssistant.class);
Tool Registration Patterns
| Pattern | How | When |
|---|---|---|
| Standalone | Tramai { tools(MyTool()) } | Framework-free apps |
| Spring Boot | @AiTool on @Component methods | Spring applications |
| Multiple tools | tools(ToolA(), ToolB()) or tools(listOf(...)) | Any number of tools |
| Duplicate detection | Builder throws ConfigurationException on duplicate name | Safety check |
Spring Boot Registration
In Spring, annotate methods with @AiTool instead of manually implementing the interface:
@Component
class TenantTools {
@AiTool(
name = "lookup",
description = "Looks up tenant data",
idempotent = true,
sideEffectLevel = SideEffectLevel.READ_ONLY,
)
fun lookup(input: LookupInput): LookupResult = LookupResult(
value = tenantDirectory.lookup(input.query),
)
}
Spring rules: @AiTool methods must take exactly one parameter (a data class). The Spring adapter converts these into TramaiTool registrations automatically.
Tool Failure Semantics
TramAI distinguishes four tool outcomes:
| Outcome | Meaning | Retry? |
|---|---|---|
ToolResult.Success | Tool executed normally | No |
ToolResult.InvalidInput | Arguments failed deserialization or validation | Yes (model reformulates) |
ToolResult.TransientFailure | Temporary error (timeout, network) | Yes, if idempotent = true |
ToolResult.PermanentFailure | Non-recoverable error | No |
Limitations
- Streaming + tool calling: Not part of the current public contract. Streaming is for raw text only.
- Autonomous planning: Tool calling is a bounded loop, not an agent framework. No planner, no autonomous recursion.
- No hidden tools: Tools are explicitly registered and explicitly attached to operations. No path-based or reflection-based discovery in standalone mode.
- Caching disabled: Tool-enabled operations skip caching even with
@Operation(cacheable = true)because tool results may depend on external state.
Next Steps
- Structured Output — Combine tools with typed return values
- Spring Boot — Auto-register tools in Spring applications
- Testing — Test tool-calling services with mock providers
- Observability — Monitor tool execution latency and failure rates
