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,
)
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.execute",
risk = RiskLevel.HIGH,
approval = ApprovalMode.HUMAN_REQUIRED,
managedNetworkEgress = ManagedNetworkEgress.ALLOWLIST_ONLY,
audit = AuditDetail.FULL,
)
// ...
}
In Spring the adapter derives the same metadata from the annotation once permission is non-empty:
@Component
class PaymentTools {
@AiTool(
name = "process_payment",
description = "Process a customer refund",
sideEffectLevel = SideEffectLevel.WRITE,
permission = "payment.execute",
risk = RiskLevel.HIGH,
approval = ApprovalMode.HUMAN_REQUIRED,
managedNetworkEgress = ManagedNetworkEgress.ALLOWLIST_ONLY,
audit = AuditDetail.FULL,
)
fun processPayment(input: PaymentInput): PaymentResult = paymentGateway.charge(input)
}
An @AiTool method with an empty permission produces no metadata at all. That legacy contract is kept alive by PolicyConfiguration.allowLegacyToolsWithoutSecurityMetadata, which the secure preset leaves false — so secure profiles such as sovereign reject tools that have no metadata.
Tool Governance
Tool calls are governed at three mandatory enforcement points. They are independent of each other: exposure permission is not execution permission.
| Enforcement point | Evaluated before |
|---|---|
BEFORE_TOOL_EXPOSURE | Tool definitions are placed in the model request |
BEFORE_TOOL_EXECUTION | The tool implementation runs |
BEFORE_TOOL_RESULT_REINJECTION | The tool result goes back into the model context |
The engine calls the configured PolicyEngine at each point and expects PolicyDecision.Allow, PolicyDecision.Deny(reason, reasonCode), or PolicyDecision.RequireApproval(requirement). With no policy engine configured the engine falls back to a legacy permissive mode (everything allowed, migration warning logged) — configure one for anything that matters.
import dev.tramai.core.coroutines.rethrowIfCancellation
import dev.tramai.core.exception.ApprovalSuspendedException
import dev.tramai.core.exception.PolicyViolationException
import dev.tramai.core.policy.EnforcementPoint
import dev.tramai.core.policy.PolicyDecision
import dev.tramai.core.policy.PolicyEngine
import dev.tramai.security.DefaultPolicyEngine
import dev.tramai.security.PolicyConfiguration
import dev.tramai.standalone.Tramai
val baseline = DefaultPolicyEngine(
PolicyConfiguration.secure() // deny-by-default
.copy(
allowedTools = setOf("process_payment"),
allowedPermissions = setOf("payment.execute"),
allowedModels = setOf("gpt-4o"),
allowedProviders = setOf("openai"),
),
)
// Deny one tool outright, delegate every other decision to the baseline.
val policy = PolicyEngine { context ->
if (context.enforcementPoint == EnforcementPoint.BEFORE_TOOL_EXECUTION &&
context.toolName == "delete_account"
) {
PolicyDecision.Deny(
reason = "Account deletion is disabled in this environment",
reasonCode = "account-delete-disabled",
)
} else {
baseline.evaluate(context)
}
}
val tramai = Tramai {
provider(OpenAiProvider(System.getenv("OPENAI_API_KEY")), name = "openai", default = true)
model("gpt-4o", "openai")
tools(PaymentTool(), DeleteAccountTool())
policyEngine(policy)
}
val payments = tramai.create<PaymentService>()
try {
payments.pay("5000 EUR")
} catch (e: PolicyViolationException) {
// Denied before execution: the tool never ran.
reportDenied(e.decision.reason, e.decision.reasonCode)
} catch (e: ApprovalSuspendedException) {
// Suspended pending human approval: the tool never ran.
reportSuspended(e.toolName, e.workflowRunId)
} catch (e: Exception) {
e.rethrowIfCancellation() // never swallow coroutine cancellation
throw e
}
PolicyConfiguration.secure() is the secure baseline: every allowlist empty, tools without security metadata rejected, workflow resume denied. PolicyConfiguration.preview() is a 0.4.x migration and testing preset with wildcard allowlists — useful to get an existing application booting, never a production posture.
A tool is suspended instead of executed when its risk level is in requireApprovalForRiskLevel (default: HIGH, CRITICAL) or when its approval mode is HUMAN_REQUIRED / HUMAN_REQUIRED_WITH_TIMEOUT:
| Exception | Fields worth reading |
|---|---|
PolicyViolationException | decision.reason, decision.reasonCode |
ApprovalSuspendedException | toolName, workflowRunId, approvalId, toolCallId, challenge, continuationVersion |
Both mean the tool did not execute. Raw tool arguments never appear in the exception message or in evidence records.
Evidence: the tool.permission family
Tool enforcement decisions are exported as their own evidence family, separate from generic policy.decision events:
import dev.tramai.security.evidence.ToolPermissionRuntimeEvidenceExporter
val records = ToolPermissionRuntimeEvidenceExporter().export(auditEvents)
PolicyDecisionRuntimeEvidenceExporter deliberately excludes the three tool enforcement points, so a tool decision cannot be mistaken for a model or provider policy decision. Raw arguments, secrets, prompts, and tokens are excluded from both families.
A runnable end-to-end version of the three outcomes (ALLOW, DENY, REQUIRE_APPROVAL) is the examples/tool-governance module in the repository: no credentials, no Docker, no network.
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 // Repetition-safe with the same input? (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
Tool implementations are Kotlin: TramaiTool.execute is a suspend function, and the Spring @AiTool scan requires the single input parameter to be a Kotlin data class. Java code consumes the typed service and registers the Kotlin tool instance.
// Service interface: annotations are Java-visible, methods are blocking.
@AiService
public interface TravelAssistant {
@Operation(
prompt = "Help the user plan their trip",
model = "gpt-4o",
tools = {"get_weather"}
)
String planTrip(String destination);
}
// Wiring: the tool is the Kotlin WeatherTool defined above.
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, and that parameter's type must be a Kotlin data class. The Spring adapter converts these methods into TramaiTool registrations automatically. Add permission (and the other governance attributes) to produce security metadata; a tool without metadata is rejected by secure profiles.
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 — Register tools with
@AiTooland select the runtime profile - Testing — Test tool-calling services with mock providers
- Observability — Monitor tool execution latency and failure rates
