Module: tramai-structured
One-liner: Generates JSON schemas from Kotlin types, extracts and validates structured objects from LLM responses. Module type:
coreBuild coordinates:dev.tramai:tramai-structured:0.6.0
L1: Quick Start (30-second read)
What
tramai-structured is the module that makes structured output a first-class capability in Tramai. It provides a single public class — JacksonStructuredOutputHandler — that:
- Generates JSON Schema from Kotlin data classes, enums, and nested types (leveraging Kotlin nullability,
@AiDescription,@AiRange, and@AiMinItemsannotations) - Extracts JSON from model responses, handling markdown fences and arbitrary text preambles
- Deserializes the extracted JSON into the target Kotlin type via Jackson
- Validates the result against annotation-driven constraints (range, min items, nullability)
- Returns a typed
StructuredOutputResult.SuccessorStructuredOutputResult.Failurewith a feedback message the engine can feed into a retry
Why
LLMs return unstructured text. When your application needs typed, validated data — a SpendAnalysis object, a list of Recommendation enums, a nested configuration tree — you need a reliable pipeline that:
- Tells the model what shape to return (schema)
- Extracts JSON from whatever the model actually emits (markdown fences, preamble text)
- Parses it into your target type
- Validates constraints (ranges, required fields, collection sizes)
- Reports failures in a way the engine can use for automatic retries
Without this module, every consumer would reimplement JSON extraction, schema generation, and validation — inconsistently.
When to use
- Your
@Operationmethods return types other thanString— data classes, value classes, enums, lists, maps of scalars - You need reliable typed output from models that occasionally emit malformed JSON
- You want annotation-driven validation (
@AiRange,@AiMinItems) on structured output properties - You want automatic retry feedback when the model's response fails to parse or validate
You do not need tramai-structured separately if you only return String from your @Operation methods — the engine handles that directly.
How to add
Gradle (Kotlin DSL):
dependencies {
implementation(platform("dev.tramai:tramai-bom:0.6.0"))
implementation("dev.tramai:tramai-structured")
}
Maven:
<dependency>
<groupId>dev.tramai</groupId>
<artifactId>tramai-structured</artifactId>
<version>0.6.0</version>
</dependency>
The module pulls in:
tramai-core(theStructuredOutputHandlerSPI)jackson-databind+jackson-module-kotlin(JSON processing)kotlin-reflect(runtime type introspection)
Where to go next
- Structured Output Engineering Spec on GitHub
- JacksonStructuredOutputHandler API
- Modules Catalog: Which modules do I need?
L2: Usage Guide (5-minute read)
Quick usage
The JacksonStructuredOutputHandler is the single implementation of StructuredOutputHandler. The engine does not discover it automatically: TramaiEngine(structuredOutputHandler = ...) must be given a handler, otherwise a structured-return operation fails fast with ConfigurationException. tramai-standalone wires JacksonStructuredOutputHandler() for you. Here is how it works end to end.
1. Define a data class with annotations:
import dev.tramai.core.annotations.AiDescription
import dev.tramai.core.annotations.AiRange
import dev.tramai.core.annotations.AiMinItems
data class SpendAnalysis(
@property:AiDescription("Total spend in USD, always positive")
@property:AiRange(min = 0.0, max = 1000000.0)
val totalSpend: Double,
@property:AiDescription("Cost reduction recommendations, ordered by impact")
@property:AiMinItems(1)
val recommendations: List<String>,
@property:AiDescription("Confidence score between 0.0 and 1.0")
@property:AiRange(min = 0.0, max = 1.0)
val confidence: Double,
val nickname: String?,
)
2. The handler generates a JSON Schema from this type:
{
"type" : "object",
"properties" : {
"confidence" : {
"type" : "number",
"description" : "Confidence score between 0.0 and 1.0",
"minimum" : 0.0,
"maximum" : 1.0
},
"nickname" : {
"type" : "string",
"nullable" : true
},
"recommendations" : {
"type" : "array",
"items" : {
"type" : "string"
},
"minItems" : 1,
"description" : "Cost reduction recommendations, ordered by impact"
},
"totalSpend" : {
"type" : "number",
"description" : "Total spend in USD, always positive",
"minimum" : 0.0,
"maximum" : 1000000.0
}
},
"required" : ["totalSpend", "recommendations", "confidence"],
"additionalProperties" : false
}
3. The handler parses a model response (including markdown fences):
val handler = JacksonStructuredOutputHandler()
val result = handler.analyze(
rawResponse = """
```json
{
"totalSpend": 1200.5,
"recommendations": ["right-size"],
"confidence": 0.95,
"nickname": null
}
```
""".trimIndent(),
targetType = typeOf<SpendAnalysis>(),
)
when (result) {
is StructuredOutputResult.Success -> {
val analysis = result.value as SpendAnalysis
println("Spend: ${analysis.totalSpend}")
}
is StructuredOutputResult.Failure -> {
println("Failed: ${result.errorSummary}")
println("Feedback for retry: ${result.feedbackMessage}")
}
}
4. Validation failures produce retry-friendly feedback:
// If the model returns recommendations: [] (violates @AiMinItems(1))
// or confidence: 1.4 (violates @AiRange(0.0, 1.0))
val result = handler.analyze(rawResponse = /* ... */, targetType = typeOf<SpendAnalysis>())
// result is Failure with:
// errorSummary: "Structured output failed validation"
// feedbackMessage: "Your previous response failed validation: Property 'recommendations' must contain at least 1 items. Return corrected JSON only."
The engine automatically feeds this feedback back to the model for a retry attempt.
Advanced usage
Enums
Enums are serialized as string schemas. Define your enum:
enum class Grade {
STANDARD, PREMIUM, ENTERPRISE
}
data class Subscription(
val grade: Grade,
val monthlyCost: Double,
)
The generated schema will include grade as a "type": "string" property with an "enum": ["STANDARD", "PREMIUM", "ENTERPRISE"] list naming the allowed constants, and Jackson will deserialize the enum value from the string in the JSON response.
Nested objects
Nested data classes produce nested JSON Schema objects:
data class Address(
val city: String,
val country: String,
)
data class Customer(
val name: String,
val address: Address,
val tags: List<String>,
)
Generated schema:
{
"type" : "object",
"properties" : {
"address" : {
"type" : "object",
"properties" : {
"city" : { "type" : "string" },
"country" : { "type" : "string" }
},
"required" : ["city", "country"],
"additionalProperties" : false
},
"name" : { "type" : "string" },
"tags" : {
"type" : "array",
"items" : { "type" : "string" }
}
},
"required" : ["name", "address", "tags"],
"additionalProperties" : false
}
Nullable properties
Properties declared with ? (e.g., val nickname: String?) are marked "nullable": true in the schema and are excluded from the "required" array. The parser accepts null values for these properties without error.
Lists of objects
data class LineItem(
val sku: String,
val quantity: Int,
)
data class Order(
val orderId: String,
val items: List<LineItem>,
)
The items property generates an "array" schema with "items" referencing the nested LineItem object schema.
Expert usage
Custom Jackson ObjectMapper
The handler accepts a custom ObjectMapper in its constructor:
val mapper = JsonMapper.builder()
.addModule(kotlinModule())
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build()
val handler = JacksonStructuredOutputHandler(objectMapper = mapper)
This is useful when you need custom serialization configuration, mix-ins, or custom deserializers across all structured output handling.
Direct schema generation
val schemaJson: String = handler.generateSchema(typeOf<SpendAnalysis>())
// Returns the JSON Schema string without creating a full contract
Direct deserialization (without analysis pipeline)
// From a JsonNode
val node: JsonNode = mapper.readTree("""{"totalSpend": 100.0}""")
val analysis = handler.deserialize(node, typeOf<SpendAnalysis>()) as SpendAnalysis
// From a string
val analysis2 = handler.deserialize("""{"totalSpend": 100.0}""", typeOf<SpendAnalysis>()) as SpendAnalysis
// From any compatible value
val analysis3 = handler.deserialize(mapOf("totalSpend" to 100.0), typeOf<SpendAnalysis>()) as SpendAnalysis
Serialization for tool results
val jsonNode = handler.serialize(SpendAnalysis(100.0, listOf("fix"), 0.5, null))
// Returns a JsonNode tree
Configuration reference
| Property | Type | Default | Description |
|---|---|---|---|
objectMapper | ObjectMapper | JsonMapper with kotlinModule() | Jackson mapper used for schema generation, deserialization, and serialization |
L3: Architecture & Mechanics (15-minute read)
Design philosophy
The module follows a parse-validate-retry architecture:
┌─────────────────────────────────────────────────────────────┐
│ tramai-structured │
│ │
│ Schema Generation ──► Response Extraction ──► Deserialize │
│ │ │ │ │
│ │ │ ▼ │
│ │ │ Validate │
│ │ │ / \ │
│ │ │ OK FAIL │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ StructuredOutputContract StructuredOutputResult │
│ │
│ ↑ SPI (tramai-core) ↓ Consumed by │
│ JacksonStructuredOutputHandler tramai-engine │
└─────────────────────────────────────────────────────────────┘
Three core principles:
- Schema-first. The model should know exactly what shape to return before it generates a response. The schema is injected into the system prompt by the engine.
- Resilient extraction. Models often wrap JSON in markdown fences (
```json) or add explanatory text. The handler strips all of that before parsing. - Rich failure context. Every failure carries both a human-readable error summary (for logging/observability) and a model-targeted feedback message (for retry). The engine never needs to understand JSON to decide whether to retry.
Module boundary
tramai-structured owns one public class — JacksonStructuredOutputHandler — implemented across 10 main-source files (the handler plus the internal descriptor package: type compilers, descriptor cache, schema renderer, shape validator, value validator). It implements the StructuredOutputHandler SPI defined in tramai-core:
| SPI method | Purpose |
|---|---|
createContract(targetType) | Returns a StructuredOutputContract containing the target type and its JSON Schema |
analyze(rawResponse, targetType) | Full parse-validate pipeline → StructuredOutputResult.Success or .Failure |
generateSchema(type) | Pure schema generation (no extraction or validation) |
deserialize(input, targetType) | Low-level typed deserialization from JsonNode, String, or any compatible value |
serialize(value) | Converts an object to a JsonNode tree |
What tramai-structured explicitly does not own:
- Retry policy — owned by
tramai-engine. The engine inspects theStructuredOutputResultand decides how many retries, with what backoff. - Tool execution — owned by
tramai-engineand provider modules. - Contract caching by the engine — the engine calls
handler.createContract(...)per attempt. Descriptor caching is internal to this module:JacksonStructuredOutputHandlercompiles each targetKTypeonce into aStructuredTypeDescriptorand memoizes it in an instance-scoped, concurrency-safeStructuredDescriptorCache.
Dependency graph
tramai-structured
├── tramai-core (api) ← StructuredOutputHandler SPI, annotations
├── jackson-databind ← JSON parsing, schema generation
├── jackson-module-kotlin ← Kotlin data class support in Jackson
└── kotlin-reflect ← Runtime type introspection (KType, KClass)
tramai-structured is consumed by:
tramai-standalone—apidependency; wiresJacksonStructuredOutputHandlerinto the engine it buildstramai-mcp—implementationdependency, for structured output in MCP tool responsestramai-engine—testdependency only. The engine consumes theStructuredOutputHandlerSPI fromtramai-core; the handler instance is supplied by the caller, sotramai-structuredis not on the engine's runtime classpath unless you add it.
Inner mechanics
Schema generation flow
createContract(targetType: KType)
│
└─► schemaForType(targetType)
│
├── String ──────────────────────────────────► {"type": "string"}
├── Int / Long / Short ──────────────────────► {"type": "integer"}
├── Float / Double ──────────────────────────► {"type": "number"}
├── Boolean ─────────────────────────────────► {"type": "boolean"}
├── List<E> ─────────────────────────────────► {"type": "array", "items": schemaForType(E)}
├── Map ─────────────────────────────────────► throws IllegalStateException (unsupported)
└── data class / enum ───────────────────────► objectSchema()
│
├── For each public property:
│ ├── schemaForType(property.returnType)
│ ├── @AiDescription → "description" field
│ ├── @AiRange → "minimum" / "maximum" fields
│ ├── @AiMinItems → "minItems" field
│ └── nullable? → "nullable": true, excluded from "required"
│
└── Returns:
{"type":"object","properties":{...},"required":[...],
"additionalProperties":false}
Key behaviors:
Maptypes are rejected at schema generation time — they have no well-defined property shape for the model to target.- Nullable properties (
Type?) are excluded from"required"and emit"nullable": true. - Annotations enrich the property schema with descriptions and constraints.
- Properties are sorted alphabetically for deterministic schema output.
additionalPropertiesis alwaysfalseto prevent the model from inventing fields.
Response extraction flow
analyze(rawResponse: String, targetType: KType)
│
├── 1. extractJsonCandidate(rawResponse)
│ ├── Strip markdown fences (```json ... ```)
│ ├── Whole response if it parses as complete JSON (bare scalar roots)
│ ├── Else first '{' ... last '}' (object) or first '[' ... last ']' (array),
│ │ whichever opening delimiter comes first (prose-prefixed responses)
│ └── Fail → Failure("Could not extract JSON content from the model response")
│
├── 2. readTree (parse once)
│ └── Fail → Failure("Could not parse the JSON payload")
│
├── 3. StructuredJsonShapeValidator.validate(node, descriptor)
│ ├── Unknown keys rejected ("Property 'x' is not allowed")
│ ├── Required-property presence / nullability
│ └── Collection + nested object shape
│ └── Fail → Failure("Structured output failed validation")
│
├── 4. Deserialize with Jackson
│ └── Fail → Failure("Could not deserialize the JSON payload")
│
└── 5. StructuredValueValidator.validate(value, descriptor)
├── Null check against nullable
├── Recurse into lists (validate each item)
├── For objects: validate each property
│ ├── Nullability enforcement
│ ├── @AiRange → numeric bounds check
│ └── @AiMinItems → collection size check
└── Fail → Failure("Structured output failed validation")
The extraction step (extractJsonCandidate) is deliberately lenient:
- If the response starts with
```and ends with a closing fence, the fenced content is extracted. - Otherwise, if the whole trimmed response parses as complete JSON (a bare scalar root such as an enum string or number), it is accepted as-is.
- Otherwise, it finds the outermost
{...}or[...]pair — whichever opening delimiter occurs first — and slices to the last matching closer. - If no delimiter pair is found, it throws
IllegalArgumentException, which is caught and converted to aFailure.
Validation engine
The validateValue function recursively walks the deserialized object tree and checks every property against its annotations:
// Pseudocode for the validation logic
fun validateValue(value, targetType):
if null → fail unless targetType is nullable
if scalar (String, Int, etc.) → pass
if List → validate each item
if object → for each property:
check not null if non-nullable
check @AiRange bounds
check @AiMinItems size
recurse into nested objects
Validation runs in two passes: a shape pass on the parsed JSON tree before deserialization (StructuredJsonShapeValidator — unknown keys, required presence, collection/object shape) and a value pass on the deserialized instance after it (StructuredValueValidator — nullability, @AiRange, @AiMinItems). The value pass is the final gate before Success. A response can therefore be valid JSON and still fail validation (e.g., confidence: 1.4 is valid JSON but violates @AiRange(max = 1.0)).
Error model
The module defines no custom exceptions. All structured output errors flow through StructuredOutputResult:
| Failure mode | Trigger | errorSummary |
|---|---|---|
| Extraction failure | No JSON object/array found in the response | "Could not extract JSON content from the model response" |
| Parse failure | Malformed JSON in the extracted candidate | "Could not parse the JSON payload" |
| Shape violation | Unknown key, missing required property, wrong collection/object shape | "Structured output failed validation" |
| Deserialization failure | Type mismatch during Jackson binding | "Could not deserialize the JSON payload" |
| Constraint violation | Null for a required property, @AiRange or @AiMinItems breach | "Structured output failed validation" |
errorSummary is deliberately fixed and coarse. The specific constraint text (e.g. "Property 'confidence' must be between 0.0 and 1.0", "Property 'recommendations' must contain at least 1 items", "Property 'totalSpend' must not be null") is embedded in feedbackMessage:
"Your previous response failed validation: <constraint detail>. Return corrected JSON only."
Each Failure also carries:
rawResponse— the original model outputfeedbackMessage— the model-targeted message above, built for the retry promptfailure— the originatingThrowable, set as a diagnostic-only body property (excluded fromequals/hashCode) and delivered only to the structured-output failure observer
At the engine level, when retries are exhausted the engine throws safeStructuredOutputFailure(code, attemptCount) — a StructuredOutputException (defined in tramai-core) carrying a fixed public message, the attempt count, and a typed StructuredOutputFailureCode. The raw response and validation detail travel only to the structured-output failure observer, not through the exception.
Testing strategy
The test file (JacksonStructuredOutputHandlerTest.kt) covers:
| Test | What it proves |
|---|---|
generates schema with nullability and custom annotations | Schema includes @AiDescription, @AiRange, @AiMinItems, nullable properties |
parses fenced json successfully | Markdown fence extraction works end to end |
returns validation failure when annotated constraints are violated | @AiMinItems and @AiRange violations produce Failure |
returns parse failure when no json payload can be extracted | Non-JSON responses are cleanly caught |
rejects unsupported root types during schema generation | Map<String, String> is rejected at schema time |
The test uses typeOf<SpendAnalysis>() (Kotlin's reified type inference) to pass generic types to the handler, which is the same mechanism the engine uses.
L4: API Reference
dev.tramai.structured.JacksonStructuredOutputHandler
class JacksonStructuredOutputHandler(
objectMapper: ObjectMapper = JsonMapper.builder()
.addModule(kotlinModule())
.build()
) : StructuredOutputHandler
The sole implementation of the StructuredOutputHandler SPI. Thread-safe once constructed (Jackson's ObjectMapper is thread-safe).
Constructor
| Parameter | Type | Default | Description |
|---|---|---|---|
objectMapper | ObjectMapper | JsonMapper + kotlinModule() | Jackson mapper for all schema/parse/serialize operations |
Methods
| Method | Returns | Description |
|---|---|---|
createContract(targetType: KType) | StructuredOutputContract | Generates schema + wraps in contract for prompt injection |
analyze(rawResponse: String, targetType: KType) | StructuredOutputResult | Full pipeline: extract → parse → validate |
generateSchema(type: KType) | String | Pure JSON Schema generation |
deserialize(input: Any, targetType: KType) | Any | Low-level deserialization (from JsonNode, String, or any value) |
serialize(value: Any) | Any (actually JsonNode) | Object → JsonNode tree |
dev.tramai.core.structured.StructuredOutputHandler (SPI)
interface StructuredOutputHandler {
fun createContract(targetType: KType): StructuredOutputContract
fun analyze(rawResponse: String, targetType: KType): StructuredOutputResult
fun generateSchema(type: KType): String
fun deserialize(input: Any, targetType: KType): Any
fun serialize(value: Any): Any
}
dev.tramai.core.structured.StructuredOutputResult
sealed interface StructuredOutputResult {
data class Success(
val value: Any,
val rawResponse: String,
) : StructuredOutputResult
data class Failure(
val rawResponse: String,
val errorSummary: String,
val feedbackMessage: String,
) : StructuredOutputResult
}
dev.tramai.core.structured.StructuredOutputContract
data class StructuredOutputContract(
val targetType: KType,
val schemaJson: String,
)
Related annotations (in tramai-core)
| Annotation | Target | Purpose |
|---|---|---|
@AiDescription(value: String) | Property, field, parameter | Describes the property meaning to the model |
@AiRange(min: Double, max: Double) | Property, field, parameter | Constrains a numeric property to a closed range |
@AiMinItems(value: Int) | Property, field, parameter | Enforces minimum collection size |
