TramAI - governed AI workflows for Java and Kotlin

Module: tramai-standalone

One-liner: Minimal framework-free entry point that wires core, engine, and structured output into a single Tramai.create<T>() call. Module type: compositionSource files: 2 files — Tramai.kt, TramaiRuntime.kt (746 LOC); 3 test files Build: dev.tramai:tramai-standalone:0.6.0


L1: Quick Start (30-second read)

What

tramai-standalone is the simplest way to use Tramai without Spring Boot, a DI framework, or any external wiring. A single Tramai class uses the builder pattern to collect a provider, model mappings, tools, and engine settings, then exposes a single create<T>() method that returns a fully functional @AiService proxy.

Why

The standalone module exists so you can go from zero to an AI-backed service interface with:

  • No framework dependency — no Spring, no Guice, no CDI
  • One import — dev.tramai.standalone.Tramai
  • Zero magic — every dependency is explicit in the builder chain
  • Structured output auto-wired — JacksonStructuredOutputHandler is injected automatically, no extra setup

When to use

Use this module when:
- You want the fastest path to an AI-backed @AiService interface
- You're writing a CLI tool, a script, or a small integration
- You want explicit, readable setup code without annotations or auto-configuration
- You're testing or prototyping new providers or models
- You want to understand exactly how core + engine + structured wire together

Don't use this module when:
- You need Spring Boot auto-configuration (use tramai-spring)
- You need the orchestration DSL with @AiService scanning (use tramai-orchestration)
- You want DI-managed provider beans (use tramai-spring)

How to add

// build.gradle.kts
dependencies {
    implementation(platform("dev.tramai:tramai-bom:0.6.0"))
    implementation("dev.tramai:tramai-standalone")
    implementation("dev.tramai:tramai-ollama") // or tramai-openai, tramai-anthropic
}
<!-- Maven -->
<dependency>
    <groupId>dev.tramai</groupId>
    <artifactId>tramai-standalone</artifactId>
    <version>0.6.0</version>
</dependency>

Where to go next


L2: Usage Guide (5-minute read)

Quick usage: Builder API

import dev.tramai.core.annotations.AiService
import dev.tramai.core.annotations.Operation
import dev.tramai.ollama.OllamaProvider
import dev.tramai.standalone.Tramai

@AiService
interface Translator {
    @Operation(prompt = "Translate the following text to French: {text}")
    suspend fun translate(text: String): String
}

suspend fun main() {
    val translator = Tramai
        .builder()
        .provider(OllamaProvider("http://localhost:11434"), default = true)
        .model("gemma4:e2b", "ollama")
        .build()
        .create<Translator>()

    println(translator.translate("Hello, world!")) // "Bonjour le monde !"
}

Quick usage: DSL variant

val translator = Tramai {
    provider(OllamaProvider("http://localhost:11434"), default = true)
    model("gemma4:e2b", "ollama")
}.create<Translator>()

The DSL uses a Tramai(...) top-level function that receives a Tramai.Builder.() -> Unit lambda — the same Builder class used by the fluent chain.

Structured output with data classes

Since JacksonStructuredOutputHandler is auto-wired, any non-String return type gets parsed and validated automatically:

data class Weather(
    val temperature: Double,
    val condition: String,
    val humidity: Double,
)

@AiService
interface WeatherService {
    @Operation(prompt = "What is the weather in {city}?")
    suspend fun getWeather(city: String): Weather
}

suspend fun main() {
    val service = Tramai {
        provider(OllamaProvider("http://localhost:11434"), default = true)
        model("gemma4:e2b", "ollama")
    }.create<WeatherService>()

    val weather = service.getWeather("Tokyo")
    println("${weather.temperature}°C, ${weather.condition}")
}

Registering tools

data class TimeInput(val timezone: String)

class GetTimeTool : TramaiTool<TimeInput, String> {
    override val name = "get_time"
    override val description = "Returns current time for a timezone"
    override val inputType = TimeInput::class
    override suspend fun execute(input: TimeInput, context: ToolExecutionContext): String =
        "Current time in ${input.timezone} is 12:00 UTC"
}

val service = Tramai {
    provider(OpenAiProvider("sk-..."), default = true)
    model("gpt-4o", "openai")
    tools(GetTimeTool())
}.create<Assistant>()

Multiple providers and model routing

val service = Tramai {
    provider(OllamaProvider("http://localhost:11434"), name = "local")
    provider(OpenAiProvider("sk-..."), name = "openai")
    model("gemma4:e2b", "local")
    model("gpt-4o", "openai")
    defaultProvider("openai") // fallback when no explicit model mapping
}.create<MultiModelService>()

Engine configuration

val service = Tramai {
    provider(OllamaProvider("http://localhost:11434"), default = true)
    model("gemma4:e2b", "ollama")
    retryPolicy(RetryPolicySettings(maxRetryAfterMillis = 30_000, jitterRatio = 0.2))
    circuitBreaker(CircuitBreakerSettings(failureThreshold = 5, openDurationMillis = 30_000))
    tokenBudget(TokenBudgetSettings(hardMaxTokensPerOperation = 50_000))
    observer(myObserver)
    interceptor(myInterceptor)
    cache(myCache)
}.create<MyService>()

L3: Architecture & Mechanics (15-minute read)

Design philosophy

tramai-standalone is deliberately thin — a single file that composes three lower-level modules (tramai-core for contracts, tramai-engine for orchestration, tramai-structured for typed output handling) into a single callable entry point. The philosophy is: one import, one create<T>() call, zero framework.

Builder pattern

Tramai uses an explicit, immutable builder (not a copy() / data class mutation pattern). The Builder class aggregates all configuration into private fields, then build() creates a snapshot:

Tramai.builder()
  .provider(...)         → registers ModelProvider in ProviderRegistry
  .model(...)            → maps model name to provider
  .tools(...)            → registers TramaiTools with JSON schema generation
  .retryPolicy(...)      → engine-level retry pacing
  .circuitBreaker(...)   → engine-level circuit breaker for failover
  .tokenBudget(...)      → engine-level token budget controls
  .observer(...)         → pluggable operation observer
  .interceptor(...)      → pluggable request/response interceptor
  .cache(...)            → pluggable operation response cache
  .build()               → immutable Tramai instance

Every setter returns Builder (via apply), enabling both the fluent chain and the DSL lambda. Once build() is called, the Tramai instance is immutable — all fields are private and there are no setters.

Provider registry wiring

The Builder delegates all provider and model routing configuration to a ProviderRoutingPlan.Builder (from tramai-core):

  1. .provider(provider, name, default) — Registers a ModelProvider under a logical name (defaults to provider.providerId()). The default = true flag marks it as the model-agnostic fallback.
  2. .model(modelName, providerName) — Maps a logical model string (the one referenced in @Operation(model = ...)) to a registered provider. Creates the primary route.
  3. .fallbackModel(requestedModelName, fallbackModelName, providerName) — Adds a secondary route: if the primary provider fails, the engine can fall through.
  4. .fallbackProvider(modelName, providerName) — Shorthand for fallbackModel(modelName, modelName, providerName).
  5. .defaultProvider(providerName) — Model-agnostic fallback when no route exists for the requested model.

At build() time, Builder.buildRoutingPlan() freezes the routing state and calls ProviderRoutingPlan.Builder.build(), which returns an immutable ProviderRoutingPlan; ProviderRoutingPlan.Builder.validate() runs inside it and rejects duplicate provider names, duplicate primary routes, fallback-only routes, and routes pointing at unregistered providers. build() then converts the frozen plan with ProviderRegistry.from(plan) and passes that registry into the Tramai constructor. Routing mutators invalidate the cached plan, so a Builder can be reused.

Structured output auto-integration

The most important design decision in tramai-standalone is that JacksonStructuredOutputHandler is hard-wired into the engine the runtime builds:

class Tramai private constructor(/* ... */) : AutoCloseable {
    private var ownedRuntime: TramaiRuntime? = null

    fun <T : Any> create(serviceType: KClass<T>): T = activeRuntime().create(serviceType)

    private fun activeRuntime(): TramaiRuntime = synchronized(lifecycleLock) {
        check(!closed) { "Tramai runtime is closed" }
        ownedRuntime ?: TramaiRuntime(newEngine()).also { ownedRuntime = it }
    }

    private fun newEngine(): TramaiEngine = TramaiEngine(
        providerRegistry = providerRegistry,
        structuredOutputHandler = JacksonStructuredOutputHandler(),  // <-- auto-wired
        toolRegistry = toolRegistry,
        operationObserver = operationObserver,
        // ... the remaining builder state is passed through here
    )
}

This means:

  • String return types — the raw model response text is returned directly
  • Any other return type (data class, List, Map, etc.) — the engine routes the response through JacksonStructuredOutputHandler, which generates a JSON schema prompt, sends it to the model, parses the response, validates it against the expected type, and either returns a typed result or throws a StructuredOutputException with retry context

The Builder also uses JacksonStructuredOutputHandler internally when generating tool input JSON schemas via handler.generateSchema(tool.inputType.createType()).

Dependency graph

tramai-standalone
  Depends on:
    - tramai-core (api) — annotations, ProviderRoutingPlan/ProviderRegistry, ModelProvider, OperationObserver
    - tramai-engine (api) — TramaiEngine, ToolRegistry, CircuitBreakerSettings, RetryPolicySettings, TokenBudgetSettings, OperationResponseCache
    - tramai-structured (api) — JacksonStructuredOutputHandler
    - kotlin-reflect (api)

  Depended on by:
    - Application code (end-user entry point)
    - tramai-spring-core (api) — the Spring integration builds on this runtime
    - tramai-orchestration and other runtime-layer modules that need a configured Tramai instance

What create<T>() does end-to-end

1. User calls Tramai.builder()...build().create<MyService>()
2. Constructor assembles ProviderRegistry, ToolRegistry, engine settings
3. create() instantiates TramaiEngine with all components + JacksonStructuredOutputHandler
4. TramaiEngine.create(MyService::class) generates a JDK dynamic proxy
5. Each @Operation method invocation:
   a. Engine builds ModelRequest (model, messages, parameters)
   b. ProviderRegistry resolves provider for the operation
   c. Engine calls provider.complete(request) or .stream(request)
   d. If return type != String → structured output handler parses + validates response
   e. Typed result (or exception) returned to caller

Module boundary

Public:
  dev.tramai.standalone.Tramai : AutoCloseable
    companion: builder(): Builder
    create(KClass<T>): T             — service proxy with structured output
    runtime(): TramaiRuntime         — the owned runtime (created lazily, once)
    close()                          — closes the runtime this Tramai owns

  dev.tramai.standalone.Tramai.Builder
    provider(provider, name, default)
    model(modelName, providerName)
    fallbackModel(requested, fallback, provider)
    fallbackProvider(modelName, providerName)
    defaultProvider(providerName)
    tools(vararg TramaiTool<*, *>)
    tools(tools: Iterable<TramaiTool<*, *>>)
    observer(observer)               — OperationObserver
    interceptor(interceptor)         — OperationInterceptor
    cache(cache)                     — OperationResponseCache
    circuitBreaker(settings)         — CircuitBreakerSettings
    retryPolicy(settings)            — RetryPolicySettings
    tokenBudget(settings)            — TokenBudgetSettings
    dlp(interceptor) / dlpRedactionAudit(emitter)
    toolResultFiltering(settings)
    engineEventObserver(observer) / toolFailureDiagnosticObserver(observer)
    structuredOutputFailureDiagnosticObserver(observer)
    promptSanitizer(sanitizer) / memory(chatMemory)
    policyDecisionAudit(emitter) / policyEngine(engine)
    modelRegistry(registry) / modelRegistrySettings(settings)
    suspendedInvocationStore(store) / approvalContinuationStore(store)
    toolArgumentsDigester(digester) / approvalGateCoordinator(coordinator)
    approvalLifecycleAudit(emitter) / clock(clock)
    buildRoutingPlan(): ProviderRoutingPlan
    build(): Tramai

  dev.tramai.standalone.TramaiRuntime : AutoCloseable   (delegates close to its engine)
    create(KClass<T>): T
    registerService(KClass<*>)
    suspend resumeApproval(ResumeApprovalCommand): Any?
    suspend resumeApprovalTyped<R>(ResumeApprovalCommand): R

  Reified extensions:
    Tramai.create<T>(): T
    TramaiRuntime.create<T>(): T
    TramaiRuntime.registerService<T>()

  dev.tramai.standalone.Tramai(configure)  — top-level DSL function

Package: dev.tramai.standalone
  2 source files (Tramai.kt, TramaiRuntime.kt), no sub-packages.

Runtime ownership and lifecycle

Tramai is AutoCloseable and owns exactly the runtime it creates:

  • The TramaiRuntime (and the TramaiEngine behind it) is materialised lazily on the first create(...)/runtime() call and cached; calling close() closes that runtime, and a further create(...) fails with IllegalStateException("Tramai runtime is closed").
  • Providers, stores, clients, executors, and observers supplied to the builder remain caller-owned unless their own API explicitly transfers ownership — closing Tramai never closes them.
  • TramaiRuntime.registerService(...) exists so a service type can be registered without building a proxy, keeping its operations available for resumeApproval/resumeApprovalTyped after a runtime restart.
  • Builder.build() snapshots builder state (observer, resolved tools, diagnostics observer), so mutating a builder after build() cannot redirect diagnostics of the already-built runtime. Partial approval composition — a continuation store, arguments digester, or gate coordinator set without the other two — fails build() with IllegalStateException.

Error model

SituationExceptionWhen
Unknown provider in .model()/.fallbackModel()ConfigurationExceptionAt Tramai.Builder.build() time — ProviderRoutingPlan.Builder.validate() rejects a route targeting an unregistered provider
Duplicate provider name or duplicate primary routeConfigurationExceptionAt Tramai.Builder.build() time, in the same validation
Fallback route without a primary routeConfigurationExceptionAt Tramai.Builder.build() time, in the same validation
Missing model mapping + no defaultConfigurationExceptionAt invocation time, via ProviderRoutingPlan.resolveCandidates()
Duplicate tool name in .tools()ConfigurationExceptionAt Builder configuration time
Partial approval composition (continuation store / digester / coordinator)IllegalStateExceptionAt Tramai.Builder.build() time
create() after close()IllegalStateExceptionAt invocation time ("Tramai runtime is closed")
Structured output parse failureStructuredOutputExceptionAt invocation time, inside engine
Provider network/timeout failureProviderException (ProviderFailureCode.TIMEOUT, CONNECTION_FAILED, TRANSPORT_FAILED)At invocation time, inside engine

Testing strategy

  • tramai-standalone ships its own tests (TramaiTest.kt, TramaiComponentCompositionTest.kt, ToolSafeFailureAdapterTest.kt under tramai-standalone/src/test/kotlin/dev/tramai/standalone/), using tramai-testing as a testImplementation dependency
  • Broader correctness is verified through tramai-engine tests (proxy dispatch, routing, retry) and tramai-structured tests (schema generation, parsing, validation)
  • Integration tests in provider modules (tramai-ollama, tramai-openai, tramai-anthropic) cover the full Tramai.builder()...create<T>() path