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 —
JacksonStructuredOutputHandleris 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
- Modules Catalog — Current module inventory and entry-point choices
- tramai-core — Annotations and SPI contracts
- tramai-engine — Orchestration, retry, routing internals
- tramai-structured — How typed outputs are handled
- tramai-ollama, tramai-openai, tramai-anthropic — Provider modules
- tramai-spring-boot-starter — Spring Boot alternative
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):
.provider(provider, name, default)— Registers aModelProviderunder a logical name (defaults toprovider.providerId()). Thedefault = trueflag marks it as the model-agnostic fallback..model(modelName, providerName)— Maps a logical model string (the one referenced in@Operation(model = ...)) to a registered provider. Creates the primary route..fallbackModel(requestedModelName, fallbackModelName, providerName)— Adds a secondary route: if the primary provider fails, the engine can fall through..fallbackProvider(modelName, providerName)— Shorthand forfallbackModel(modelName, modelName, providerName)..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:
Stringreturn types — the raw model response text is returned directly- Any other return type (
data class,List,Map, etc.) — the engine routes the response throughJacksonStructuredOutputHandler, 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 aStructuredOutputExceptionwith 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 theTramaiEnginebehind it) is materialised lazily on the firstcreate(...)/runtime()call and cached; callingclose()closes that runtime, and a furthercreate(...)fails withIllegalStateException("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
Tramainever closes them. TramaiRuntime.registerService(...)exists so a service type can be registered without building a proxy, keeping its operations available forresumeApproval/resumeApprovalTypedafter a runtime restart.Builder.build()snapshots builder state (observer, resolved tools, diagnostics observer), so mutating a builder afterbuild()cannot redirect diagnostics of the already-built runtime. Partial approval composition — a continuation store, arguments digester, or gate coordinator set without the other two — failsbuild()withIllegalStateException.
Error model
| Situation | Exception | When |
|---|---|---|
Unknown provider in .model()/.fallbackModel() | ConfigurationException | At Tramai.Builder.build() time — ProviderRoutingPlan.Builder.validate() rejects a route targeting an unregistered provider |
| Duplicate provider name or duplicate primary route | ConfigurationException | At Tramai.Builder.build() time, in the same validation |
| Fallback route without a primary route | ConfigurationException | At Tramai.Builder.build() time, in the same validation |
| Missing model mapping + no default | ConfigurationException | At invocation time, via ProviderRoutingPlan.resolveCandidates() |
Duplicate tool name in .tools() | ConfigurationException | At Builder configuration time |
| Partial approval composition (continuation store / digester / coordinator) | IllegalStateException | At Tramai.Builder.build() time |
create() after close() | IllegalStateException | At invocation time ("Tramai runtime is closed") |
| Structured output parse failure | StructuredOutputException | At invocation time, inside engine |
| Provider network/timeout failure | ProviderException (ProviderFailureCode.TIMEOUT, CONNECTION_FAILED, TRANSPORT_FAILED) | At invocation time, inside engine |
Testing strategy
tramai-standaloneships its own tests (TramaiTest.kt,TramaiComponentCompositionTest.kt,ToolSafeFailureAdapterTest.ktundertramai-standalone/src/test/kotlin/dev/tramai/standalone/), usingtramai-testingas atestImplementationdependency- Broader correctness is verified through
tramai-enginetests (proxy dispatch, routing, retry) andtramai-structuredtests (schema generation, parsing, validation) - Integration tests in provider modules (
tramai-ollama,tramai-openai,tramai-anthropic) cover the fullTramai.builder()...create<T>()path
