TramAI - governed AI workflows for Java and Kotlin

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

  1. The model returns one or more tool call requests in its response
  2. TramAI validates and deserializes the tool arguments using the tool's input schema
  3. TramAI executes the matching registered tool with a ToolExecutionContext
  4. TramAI returns the tool result to the model
  5. 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 pointEvaluated before
BEFORE_TOOL_EXPOSURETool definitions are placed in the model request
BEFORE_TOOL_EXECUTIONThe tool implementation runs
BEFORE_TOOL_RESULT_REINJECTIONThe 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:

ExceptionFields worth reading
PolicyViolationExceptiondecision.reason, decision.reasonCode
ApprovalSuspendedExceptiontoolName, 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

PatternHowWhen
StandaloneTramai { tools(MyTool()) }Framework-free apps
Spring Boot@AiTool on @Component methodsSpring applications
Multiple toolstools(ToolA(), ToolB()) or tools(listOf(...))Any number of tools
Duplicate detectionBuilder throws ConfigurationException on duplicate nameSafety 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:

OutcomeMeaningRetry?
ToolResult.SuccessTool executed normallyNo
ToolResult.InvalidInputArguments failed deserialization or validationYes (model reformulates)
ToolResult.TransientFailureTemporary error (timeout, network)Yes, if idempotent = true
ToolResult.PermanentFailureNon-recoverable errorNo

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 @AiTool and select the runtime profile
  • Testing — Test tool-calling services with mock providers
  • Observability — Monitor tool execution latency and failure rates