
Generate JSON Schemas and LLM function-calling schemas from functions and models—including third-party classes—via compile-time zero-overhead generation or runtime reflection; annotation-aware and OpenAI/Anthropic-compatible.
Table of contents:
Generate JSON schemas and LLM function calling schemas from Kotlin code — including classes you don't own.
[!IMPORTANT] Given the highly experimental nature of this work, nothing is settled in stone. Kotlinx-schema-json might eventually be moved to kotlinx-serialization.
Quick Links:
Dual Generation Modes:
LLM Integration:
Flexible Annotation Support:
@Description, @LLMDescription, @JsonPropertyDescription, @P, and moreComprehensive Type Support:
oneOf generation["string", "null"])Developer Experience:
[!TIP] Need to build JSON Schemas manually? The kotlinx-schema-json module provides type-safe Kotlin models and DSL compliant with JSON Schema Draft 2020-12, with support for polymorphism, discriminators, and type-safe enums. See JSON Schema DSL section ↓
This library solves three key challenges:
@Description-like annotations from other frameworks| 🔧 KSP Processor | 📦 Serialization-based | 🔍 Runtime Reflection | |
|---|---|---|---|
| Platforms | JVM + Multiplatform | JVM + Multiplatform | JVM only |
| When generated | Compile-time | Runtime | Runtime |
| Requires annotation processor | Yes (KSP) | No | No |
Class must be @Serializable |
No | Yes | No |
Annotate class with @Schema |
Required | Not required | Not required |
| KDoc extracted to description | ✅ | ❌ | ❌ |
| Extract data class defaults | ❌ | ❌ | ✅ |
| Third-party classes | ❌ | ✅ (only @Serializable) |
✅ any JVM class |
Pick KSP when you own the classes, want zero runtime overhead, and target Multiplatform or need KDoc in your schema.
Pick Serialization-based when your classes are already @Serializable and you need
Multiplatform support without a build-time processor.
Pick Reflection when you need JVM-only runtime generation for third-party classes, or
need to extract data class default values. Works equally well for @Serializable classes on JVM.
Recommended: use the Gradle plugin. It applies KSP for you, wires generated sources, and sets up task dependencies.
Refer to the example projects here.
/**
* A postal address for deliveries and billing.
*/
@Schema
data class Address(
@Description("Street address, including house number") val street: String,
@Description("City or town name") val city: String,
@Description("Postal or ZIP code") val zipCode: String,
@Description("Two-letter ISO country code; defaults to US") val country: String = "US",
)Note: KDoc comments on classes can also be used as descriptions.
Add the Google KSP plugin and the processor dependency:
// Multiplatform (KMP)
plugins {
kotlin("multiplatform")
id("com.google.devtools.ksp") version "<ksp-version>"
}
dependencies {
add("kspCommonMainMetadata", "org.jetbrains.kotlinx:kotlinx-schema-ksp:<version>")
implementation("org.jetbrains.kotlinx:kotlinx-schema-annotations:<version>")
}
kotlin {
sourceSets.commonMain.kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}For JVM-only projects, the Kotlinx-Schema Gradle plugin, Maven, and full configuration options, see * *KSP Configuration Guide**.
[!NOTE] If your classes are
@Serializable, use the Serialization-Based Generator instead — it works on all platforms without reflection.
For JVM-only scenarios with classes you don't own or can't annotate, use
ReflectionClassJsonSchemaGenerator
and
ReflectionFunctionCallingSchemaGenerator
with Kotlin reflection.
Primary use case: Third-party library classes
The compile-time (KSP) approach requires you to annotate classes with @Schema, which isn't possible for:
Runtime generation solves this by using reflection to analyze any class at runtime.
[!IMPORTANT] Limitations:
- KDoc annotations are not available at runtime
- Function parameter defaults (e.g.,
fun foo(x: Int = 5)) cannot be extracted via reflection- Data class property defaults (e.g.,
data class Config(val port: Int = 8080)) ARE supported
// Works with ANY class, even from third-party libraries
import com.thirdparty.library.User // Not your code!
val generator = kotlinx.schema.generator.json.ReflectionClassJsonSchemaGenerator.Default
val schema: JsonSchema = generator.generateSchema(User::class)
val schemaString: String = generator.generateSchemaString(User::class)Add dependency: org.jetbrains.kotlinx:kotlinx-schema-generator-json:<version>
Schemas follow JSON Schema Draft 2020-12 format. Example (pretty-printed):
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "com.example.Address",
"type": "object",
"properties": {
"street": {
"type": "string",
"description": "Street address, including house number"
},
"city": {
"type": "string",
"description": "City or town name"
},
"zipCode": {
"type": "string",
"description": "Postal or ZIP code"
},
"country": {
"type": "string",
"description": "Two-letter ISO country code; defaults to US",
"default": "US"
}
},
"required": [
"street",
"city",
"zipCode"
],
"additionalProperties": false,
"description": "A postal address for deliveries and billing."
}type: string with enum: [] and carry @Description as description.@Description asdescription.val country: String = "US" → "default": "US"). Note: KSP (compile-time) tracks which properties have defaults but
cannot extract the actual values.null.List<T>/Set<T> → { "type":"array", "items": T }; Map<String, V> →
{ "type":"object", "additionalProperties": V }.kotlin.Any with a minimal definition in $defs.Here's a practical example of a product model with various property types:
@Description("A purchasable product with pricing and inventory info.")
@Schema
data class Product(
@Description("Unique identifier for the product")
val id: Long,
@Description("Human-readable product name")
val name: String,
@Description("Optional detailed description of the product")
val description: String?,
@Description("Unit price expressed as a decimal number")
val price: Double,
@Description("Whether the product is currently in stock")
val inStock: Boolean = true,
@Description("List of tags for categorization and search")
val tags: List<String> = emptyList(),
)Use the generated extensions:
val schema = Product::class.jsonSchemaString
val schemaObject = Product::class.jsonSchema{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "com.example.Product",
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "Unique identifier for the product"
},
"name": {
"type": "string",
"description": "Human-readable product name"
},
"description": {
"type": [
"string",
"null"
],
"description": "Optional detailed description of the product"
},
"price": {
"type": "number",
"description": "Unit price expressed as a decimal number"
},
"inStock": {
"type": "boolean",
"description": "Whether the product is currently in stock",
"default": true
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of tags for categorization and search",
"default": []
}
},
"required": [
"id",
"name",
"description",
"price"
],
"additionalProperties": false,
"description": "A purchasable product with pricing and inventory info."
}Enums are supported with descriptions on both the enum class and individual values:
@Description("Current lifecycle status of an entity.")
@Schema
enum class Status {
@Description("Entity is active and usable")
ACTIVE,
@Description("Entity is inactive or disabled")
INACTIVE,
@Description("Entity is pending activation or approval")
PENDING,
}{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "com.example.Status",
"type": "string",
"enum": [
"ACTIVE",
"INACTIVE",
"PENDING"
],
"description": "Current lifecycle status of an entity."
}You can compose schemas by nesting annotated classes:
@Description("A person with a first and last name and age.")
@Schema
data class Person(
@Description("Given name of the person")
val firstName: String,
@Description("Family name of the person")
val lastName: String,
@Description("Age of the person in years")
val age: Int,
)
@Description("An order placed by a customer containing multiple items.")
@Schema
data class Order(
@Description("Unique order identifier")
val id: String,
@Description("The customer who placed the order")
val customer: Person,
@Description("Destination address for shipment")
val shippingAddress: Address,
@Description("List of items included in the order")
val items: List<Product>,
@Description("Current status of the order")
val status: Status,
)The generated schema for Order will automatically include definitions for all nested types (Person, Address,
Product, Status) in the $defs section, with appropriate $ref pointers to link them together. This makes it easy
to build complex, composable data models.
Generic classes are supported, with type parameters resolved at usage sites:
@Description("A generic container that wraps content with optional metadata.")
@Schema
data class Container<T>(
@Description("The wrapped content value")
val content: T,
@Description("Arbitrary metadata key-value pairs")
val metadata: Map<String, Any> = emptyMap(),
)Generic type parameters are resolved at the usage site. When generating a schema for a generic class, unbound type
parameters (like T) are treated as kotlin.Any with a minimal definition in the $defs section. For more specific
typing, instantiate the generic class with concrete types when you need them.
The library automatically generates JSON schemas for Kotlin sealed class hierarchies using oneOf:
@Description("Multicellular eukaryotic organism of the kingdom Metazoa")
@Schema
sealed class Animal {
/**
* Animal's name
*/
@Description("Animal's name")
abstract val name: String
@Schema(withSchemaObject = true)
data class Dog(
@Description("Animal's name")
override val name: String,
) : Animal()
@Schema(withSchemaObject = true)
data class Cat(
@Description("Animal's name")
override val name: String,
) : Animal()
}val generator = ReflectionClassJsonSchemaGenerator.Default
val schema = generator.generateSchema(Animal::class)
println(schema.encodeToString(Json { prettyPrint = true })){
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "kotlinx.schema.integration.type.Animal",
"description": "Multicellular eukaryotic organism of the kingdom Metazoa",
"type": "object",
"additionalProperties": false,
"oneOf": [
{
"$ref": "#/$defs/Animal.Cat"
},
{
"$ref": "#/$defs/Animal.Dog"
}
],
"$defs": {
"Animal.Cat": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "Animal.Cat"
},
"name": {
"type": "string",
"description": "Animal's name"
}
},
"required": [
"type",
"name"
],
"additionalProperties": false
},
"Animal.Dog": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "Animal.Dog"
},
"name": {
"type": "string",
"description": "Animal's name"
}
},
"required": [
"type",
"name"
],
"additionalProperties": false
}
}
}Key features:
oneOf with $ref: Each sealed subclass is referenced from a $defs sectionlives: Int = 9) are includedMark classes with @Schema to generate extension properties for them:
@Schema // Uses default schema type "json"
data class Address(val street: String, val city: String)
@Schema("json") // Explicitly specify schema type
data class Person(val name: String, val age: Int)@Schema parameters:
value = "json": Schema type (only JSON currently supported)withSchemaObject = false: Generate jsonSchema: JsonObject property (
see Advanced Configuration)Note: jsonSchemaString is always generated. jsonSchema requires withSchemaObject = true.
Use @Description on classes and properties to add human-readable documentation to your schemas:
@Description("A purchasable product with pricing info")
@Schema
data class Product(
@Description("Unique identifier for the product") val id: Long,
@Description("Human-readable product name") val name: String,
@Description("Optional detailed description of the product") val description: String?,
@Description("Unit price expressed as a decimal number") val price: Double,
)Tip: With the recommended compiler flag -Xannotation-default-target=param-property, a bare @Description on a
primary constructor parameter also applies to the property. If you do not enable the flag, use @param:Description for
constructor-declared properties.
Modern LLMs (OpenAI GPT-4, Anthropic Claude, etc.) use structured function calling to interact with your code. They require a specific JSON schema format that describes available functions, their parameters, and types.
LLM APIs need to know:
This library automatically generates schemas that comply with the OpenAI function calling specification, making it easy to expose Kotlin functions to LLMs.
@Description("Get current weather for a location")
fun getWeather(
@Description("City and country, e.g. 'London, UK'")
location: String,
@Description("Temperature unit")
unit: String = "celsius"
): WeatherInfo {
return WeatherInfo(20.0, unit)
}
val generator = ReflectionFunctionCallingSchemaGenerator.Default
val schema = generator.generateSchema(::getWeather)The generated schema follows the LLM function calling format:
{
"type": "function",
"name": "getWeather",
"description": "Get current weather for a location",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and country, e.g. 'London, UK'"
},
"unit": {
"type": "string",
"description": "Temperature unit"
}
},
"required": [
"location",
"unit"
],
"additionalProperties": false
}
}@Description annotationsdata class Config(val port: Int = 8080))strict: true enables
OpenAI's strict mode for reliable parsing["string", "null"] instead of nullable: true
Note: Function parameter defaults (e.g.,
unit: String = "celsius") cannot be extracted via reflection, but nested data class property defaults are fully supported.
// Define your functions
@Description("Search the knowledge base")
fun searchKnowledge(
@Description("Search query") query: String,
@Description("Max results") limit: Int = 10
): String = TODO()
@Description("Calculate order total with tax")
fun calculateTotal(
@Description("Item prices") prices: List<Double>,
@Description("Tax rate as decimal") taxRate: Double = 0.0
): Double = TODO()
// Generate schemas
val generator = ReflectionFunctionCallingSchemaGenerator.Default
val schemas = listOf(::searchKnowledge, ::calculateTotal)
.map { generator.generateSchema(it) }
// Serialize to JSON
val jsonSchemas = schemas.map { Json.encodeToString(it) }
// Or get as JsonObject
val schemaObjects = schemas.map { it.encodeToJsonObject() }The generated schemas can be sent to any LLM API that supports function calling (OpenAI, Anthropic, etc.). Integration with specific LLM providers requires their respective client libraries.
Nullable parameters are represented as union types:
@Description("Update user profile")
fun updateProfile(
@Description("User ID") userId: String,
@Description("New name, if changing") name: String? = null,
@Description("New email, if changing") email: String? = null
): Boolean = TODO("does not matter")// ...Generates:
{
"properties": {
"userId": {
"type": "string",
"description": "User ID"
},
"name": {
"type": [
"string",
"null"
],
"description": "New name, if changing"
},
"email": {
"type": [
"string",
"null"
],
"description": "New email, if changing"
}
},
"required": [
"userId",
"name",
"email"
]
}Note: Even nullable parameters are in required array. The null type in the union indicates optionality.
For more details on function calling schemas and OpenAI compatibility, see kotlinx-schema-json/README.md.
Generate function schemas at compile time with zero runtime overhead. KSP generates type-safe extensions for all your annotated functions, with APIs that reflect where functions actually live in your code.
Annotate package-level functions to generate top-level schema accessors:
@Schema
@Description("Sends a greeting message to a person")
fun greetPerson(
@Description("Name of the person to greet")
name: String,
@Description("Optional greeting prefix (e.g., 'Hello', 'Hi')")
greeting: String = "Hello",
): String = "$greeting, $name!"
// Generated: top-level functions
val schema = greetPersonJsonSchemaString()Annotate class methods to generate KClass extensions on the containing class:
class UserService {
@Schema
@Description("Registers a new user in the system")
fun registerUser(
@Description("Username for the new account")
username: String,
@Description("Email address")
email: String,
): String = "User registered"
}
// Generated: KClass extension on UserService
val schema = UserService::class.registerUserJsonSchemaString()Annotate companion methods to generate KClass extensions on the companion object itself:
class DatabaseConnection {
companion object {
@Schema
@Description("Creates a new database connection")
fun create(
@Description("Database host")
host: String,
@Description("Database port")
port: Int = 5432,
): DatabaseConnection = TODO()
}
}
// Generated: KClass extension on companion object
val schema = DatabaseConnection.Companion::class.createJsonSchemaString()This API correctly reflects that companion functions belong to the companion object, not the parent class.
Annotate object methods to generate KClass extensions on the object type:
object ConfigurationManager {
@Schema
@Description("Loads configuration from a file")
fun loadConfig(
@Description("Configuration file path")
filePath: String,
@Description("Whether to create file if it doesn't exist")
createIfMissing: Boolean = false,
): Map<String, String> = TODO()
}
// Generated: KClass extension on object
val schema = ConfigurationManager::class.loadConfigJsonSchemaString()KSP generates schema accessor functions that match where your functions live:
| Function Type | Annotate | Generated API | Example |
|---|---|---|---|
| Top-level | Package function | Top-level accessor | greetPersonJsonSchemaString() |
| Instance | Class method |
KClass extension |
UserService::class.registerUserJsonSchemaString() |
| Companion | Companion method |
Companion::class extension |
DatabaseConnection.Companion::class.createJsonSchemaString() |
| Object | Object method |
Object::class extension |
ConfigurationManager::class.loadConfigJsonSchemaString() |
For each annotated function, you get:
{functionName}JsonSchemaString(): String — returns the schema as a JSON string{functionName}JsonSchema(): FunctionCallingSchema — returns the schema object (requires
withSchemaObject = true)Generated schemas follow the OpenAI function calling format:
{
"type": "function",
"name": "greetPerson",
"description": "Sends a greeting message to a person",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the person to greet"
},
"greeting": {
"type": "string",
"description": "Optional greeting prefix"
}
},
"required": [
"name",
"greeting"
],
"additionalProperties": false
}
}OpenAI Strict Mode: All parameters are marked as required by default, even those with default values. This ensures compatibility with OpenAI Structured Outputs.
| Feature | KSP (Compile-time) | Reflection (Runtime) |
|---|---|---|
| Performance | Zero runtime cost | Small reflection overhead |
| Platforms | Multiplatform | JVM only |
| Default values | Tracked but not extracted (KSP limitation) | Fully extracted from data classes |
| When to use | Your annotated functions | Third-party functions, dynamic scenarios |
Suspend functions work identically to regular functions. The generated schemas don't expose the suspend modifier—they describe parameter types only:
@Schema
@Description("Fetches user data asynchronously")
suspend fun fetchUserData(
@Description("User ID to fetch") userId: Long,
): UserData = TODO()
// Generated API works the same way
val schema = fetchUserDataJsonSchemaString()You don't need to change your existing code!
kotlinx-schema recognizes description annotations from multiple frameworks by their simple name, allowing you to generate schemas from code that uses annotations from other libraries.
The library automatically recognizes these description annotations by default:
| Annotation | Simple Name | Library/Framework | Example |
|---|---|---|---|
kotlinx.schema.Description |
Description |
kotlinx-schema | @Description("User name") |
ai.koog.agents.core.tools.annotations.LLMDescription |
LLMDescription |
Koog AI agents | @LLMDescription("Query text") |
com.fasterxml.jackson.annotation.JsonPropertyDescription |
JsonPropertyDescription |
Jackson | @JsonPropertyDescription("Email") |
com.fasterxml.jackson.annotation.JsonClassDescription |
JsonClassDescription |
Jackson | @JsonClassDescription("User model") |
dev.langchain4j.model.output.structured.P |
P |
LangChain4j | @P("Search query") |
The introspector matches annotations by their simple name only, not the fully qualified name. This means:
Annotation detection is configurable via kotlinx-schema.properties loaded from the classpath.
The configuration file is optional — if not provided or fails to load, the library uses sensible defaults.
By default, the library recognizes:
Annotation names: Description, LLMDescription, JsonPropertyDescription, JsonClassDescription, P Attribute names: value, description
To customize, place kotlinx-schema.properties in your project's resources:
# Add your custom annotations to the defaults
introspector.annotations.description.names=Description,MyCustomAnnotation,DocString
introspector.annotations.description.attributes=value,description,textNote: The library falls back to built-in defaults if the configuration file is missing or cannot be loaded.
// Your custom annotation
package com.mycompany.annotations
annotation class ApiDoc(val text: String)
// Usage in your models
@ApiDoc(text = "Customer profile information")
data class Customer(
@ApiDoc(text = "Unique customer identifier")
val id: Long,
val name: String
)Update kotlinx-schema.properties:
introspector.annotations.description.names=Description,ApiDoc
introspector.annotations.description.attributes=value,description,textNow the schema generator will recognize @ApiDoc and extract descriptions from its text parameter.
If your project already uses Jackson for JSON serialization, you can generate schemas from existing Jackson-annotated classes without any modifications. This is particularly useful for REST APIs and Spring Boot applications where Jackson annotations are already present.
// Existing code with Jackson annotations - NO CHANGES NEEDED!
@JsonClassDescription("Customer profile data")
data class Customer(
@JsonPropertyDescription("Unique customer ID")
val id: Long,
@JsonPropertyDescription("Full name")
val name: String,
@JsonPropertyDescription("Contact email")
val email: String
)
// Generate JSON schema without modifying the code
val generator = kotlinx.schema.generator.json.ReflectionClassJsonSchemaGenerator.Default
val schema = generator.generateSchema(Customer::class)
// Schema includes all Jackson descriptions!LangChain4j uses the @P annotation for parameter descriptions in AI function calling. The library recognizes these
annotations automatically, enabling seamless integration with existing LangChain4j codebases.
// Code using LangChain4j annotations
data class SearchQuery(
@P("Search terms")
val query: String,
@P("Maximum results to return")
val limit: Int = 10
)
// Generate schema for LLM function calling
val generator = ReflectionFunctionCallingSchemaGenerator.Default
val schema = generator.generateSchema(SearchQuery::class.constructors.first())Koog AI framework uses @LLMDescription for documenting agent tools and parameters. The library supports both the
verbose description = syntax and the shorthand form, making migration from Koog straightforward.
@LLMDescription(description = "Product with pricing information")
@Schema
data class Product(
@LLMDescription(description = "Product identifier")
val id: Long,
@LLMDescription("Product name")
val name: String,
@LLMDescription("Unit price")
val price: Double,
)If multiple description annotations are present on the same element, the library uses this precedence order:
@Description (kotlinx-schema's own annotation)Tip: For best compatibility, prefer @Description from kotlinx-schema when writing new code, but existing
annotations from other libraries work seamlessly.
For manual schema construction, use the kotlinx-schema-json module. It provides type-safe Kotlin models compliant with the JSON Schema Draft 2020-12 specification and a DSL for building JSON Schema definitions programmatically, with full kotlinx-serialization support.
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-schema-json:<version>")
}Quick Example:
val schema = jsonSchema {
property("id") {
required = true
string { format = "uuid" }
}
property("email") {
required = true
string { format = "email" }
}
property("age") {
integer {
minimum = 0.0
maximum = 150.0
}
}
// Polymorphic types with discriminators
property("role") {
oneOf {
discriminator(propertyName = "type") {
"admin" mappedTo "#/definitions/AdminRole"
"user" mappedTo {
property("type") { string { constValue = "user" } }
property("permissions") { array { ofString() } }
}
}
}
}
}Features:
📖 For comprehensive documentation, see kotlinx-schema-json/README.md covering:
For build instructions, development setup, and contribution guidelines, see CONTRIBUTING.md.
Tip: If you use @Description on primary constructor parameters, enable
-Xannotation-default-target=param-property in Kotlin compiler options so the description applies to the backing
property.
This project and the corresponding community are governed by the JetBrains Open Source and Community Code of Conduct. Please make sure you read and adhere to it.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
Table of contents:
Generate JSON schemas and LLM function calling schemas from Kotlin code — including classes you don't own.
[!IMPORTANT] Given the highly experimental nature of this work, nothing is settled in stone. Kotlinx-schema-json might eventually be moved to kotlinx-serialization.
Quick Links:
Dual Generation Modes:
LLM Integration:
Flexible Annotation Support:
@Description, @LLMDescription, @JsonPropertyDescription, @P, and moreComprehensive Type Support:
oneOf generation["string", "null"])Developer Experience:
[!TIP] Need to build JSON Schemas manually? The kotlinx-schema-json module provides type-safe Kotlin models and DSL compliant with JSON Schema Draft 2020-12, with support for polymorphism, discriminators, and type-safe enums. See JSON Schema DSL section ↓
This library solves three key challenges:
@Description-like annotations from other frameworks| 🔧 KSP Processor | 📦 Serialization-based | 🔍 Runtime Reflection | |
|---|---|---|---|
| Platforms | JVM + Multiplatform | JVM + Multiplatform | JVM only |
| When generated | Compile-time | Runtime | Runtime |
| Requires annotation processor | Yes (KSP) | No | No |
Class must be @Serializable |
No | Yes | No |
Annotate class with @Schema |
Required | Not required | Not required |
| KDoc extracted to description | ✅ | ❌ | ❌ |
| Extract data class defaults | ❌ | ❌ | ✅ |
| Third-party classes | ❌ | ✅ (only @Serializable) |
✅ any JVM class |
Pick KSP when you own the classes, want zero runtime overhead, and target Multiplatform or need KDoc in your schema.
Pick Serialization-based when your classes are already @Serializable and you need
Multiplatform support without a build-time processor.
Pick Reflection when you need JVM-only runtime generation for third-party classes, or
need to extract data class default values. Works equally well for @Serializable classes on JVM.
Recommended: use the Gradle plugin. It applies KSP for you, wires generated sources, and sets up task dependencies.
Refer to the example projects here.
/**
* A postal address for deliveries and billing.
*/
@Schema
data class Address(
@Description("Street address, including house number") val street: String,
@Description("City or town name") val city: String,
@Description("Postal or ZIP code") val zipCode: String,
@Description("Two-letter ISO country code; defaults to US") val country: String = "US",
)Note: KDoc comments on classes can also be used as descriptions.
Add the Google KSP plugin and the processor dependency:
// Multiplatform (KMP)
plugins {
kotlin("multiplatform")
id("com.google.devtools.ksp") version "<ksp-version>"
}
dependencies {
add("kspCommonMainMetadata", "org.jetbrains.kotlinx:kotlinx-schema-ksp:<version>")
implementation("org.jetbrains.kotlinx:kotlinx-schema-annotations:<version>")
}
kotlin {
sourceSets.commonMain.kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}For JVM-only projects, the Kotlinx-Schema Gradle plugin, Maven, and full configuration options, see * *KSP Configuration Guide**.
[!NOTE] If your classes are
@Serializable, use the Serialization-Based Generator instead — it works on all platforms without reflection.
For JVM-only scenarios with classes you don't own or can't annotate, use
ReflectionClassJsonSchemaGenerator
and
ReflectionFunctionCallingSchemaGenerator
with Kotlin reflection.
Primary use case: Third-party library classes
The compile-time (KSP) approach requires you to annotate classes with @Schema, which isn't possible for:
Runtime generation solves this by using reflection to analyze any class at runtime.
[!IMPORTANT] Limitations:
- KDoc annotations are not available at runtime
- Function parameter defaults (e.g.,
fun foo(x: Int = 5)) cannot be extracted via reflection- Data class property defaults (e.g.,
data class Config(val port: Int = 8080)) ARE supported
// Works with ANY class, even from third-party libraries
import com.thirdparty.library.User // Not your code!
val generator = kotlinx.schema.generator.json.ReflectionClassJsonSchemaGenerator.Default
val schema: JsonSchema = generator.generateSchema(User::class)
val schemaString: String = generator.generateSchemaString(User::class)Add dependency: org.jetbrains.kotlinx:kotlinx-schema-generator-json:<version>
Schemas follow JSON Schema Draft 2020-12 format. Example (pretty-printed):
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "com.example.Address",
"type": "object",
"properties": {
"street": {
"type": "string",
"description": "Street address, including house number"
},
"city": {
"type": "string",
"description": "City or town name"
},
"zipCode": {
"type": "string",
"description": "Postal or ZIP code"
},
"country": {
"type": "string",
"description": "Two-letter ISO country code; defaults to US",
"default": "US"
}
},
"required": [
"street",
"city",
"zipCode"
],
"additionalProperties": false,
"description": "A postal address for deliveries and billing."
}type: string with enum: [] and carry @Description as description.@Description asdescription.val country: String = "US" → "default": "US"). Note: KSP (compile-time) tracks which properties have defaults but
cannot extract the actual values.null.List<T>/Set<T> → { "type":"array", "items": T }; Map<String, V> →
{ "type":"object", "additionalProperties": V }.kotlin.Any with a minimal definition in $defs.Here's a practical example of a product model with various property types:
@Description("A purchasable product with pricing and inventory info.")
@Schema
data class Product(
@Description("Unique identifier for the product")
val id: Long,
@Description("Human-readable product name")
val name: String,
@Description("Optional detailed description of the product")
val description: String?,
@Description("Unit price expressed as a decimal number")
val price: Double,
@Description("Whether the product is currently in stock")
val inStock: Boolean = true,
@Description("List of tags for categorization and search")
val tags: List<String> = emptyList(),
)Use the generated extensions:
val schema = Product::class.jsonSchemaString
val schemaObject = Product::class.jsonSchema{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "com.example.Product",
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "Unique identifier for the product"
},
"name": {
"type": "string",
"description": "Human-readable product name"
},
"description": {
"type": [
"string",
"null"
],
"description": "Optional detailed description of the product"
},
"price": {
"type": "number",
"description": "Unit price expressed as a decimal number"
},
"inStock": {
"type": "boolean",
"description": "Whether the product is currently in stock",
"default": true
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of tags for categorization and search",
"default": []
}
},
"required": [
"id",
"name",
"description",
"price"
],
"additionalProperties": false,
"description": "A purchasable product with pricing and inventory info."
}Enums are supported with descriptions on both the enum class and individual values:
@Description("Current lifecycle status of an entity.")
@Schema
enum class Status {
@Description("Entity is active and usable")
ACTIVE,
@Description("Entity is inactive or disabled")
INACTIVE,
@Description("Entity is pending activation or approval")
PENDING,
}{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "com.example.Status",
"type": "string",
"enum": [
"ACTIVE",
"INACTIVE",
"PENDING"
],
"description": "Current lifecycle status of an entity."
}You can compose schemas by nesting annotated classes:
@Description("A person with a first and last name and age.")
@Schema
data class Person(
@Description("Given name of the person")
val firstName: String,
@Description("Family name of the person")
val lastName: String,
@Description("Age of the person in years")
val age: Int,
)
@Description("An order placed by a customer containing multiple items.")
@Schema
data class Order(
@Description("Unique order identifier")
val id: String,
@Description("The customer who placed the order")
val customer: Person,
@Description("Destination address for shipment")
val shippingAddress: Address,
@Description("List of items included in the order")
val items: List<Product>,
@Description("Current status of the order")
val status: Status,
)The generated schema for Order will automatically include definitions for all nested types (Person, Address,
Product, Status) in the $defs section, with appropriate $ref pointers to link them together. This makes it easy
to build complex, composable data models.
Generic classes are supported, with type parameters resolved at usage sites:
@Description("A generic container that wraps content with optional metadata.")
@Schema
data class Container<T>(
@Description("The wrapped content value")
val content: T,
@Description("Arbitrary metadata key-value pairs")
val metadata: Map<String, Any> = emptyMap(),
)Generic type parameters are resolved at the usage site. When generating a schema for a generic class, unbound type
parameters (like T) are treated as kotlin.Any with a minimal definition in the $defs section. For more specific
typing, instantiate the generic class with concrete types when you need them.
The library automatically generates JSON schemas for Kotlin sealed class hierarchies using oneOf:
@Description("Multicellular eukaryotic organism of the kingdom Metazoa")
@Schema
sealed class Animal {
/**
* Animal's name
*/
@Description("Animal's name")
abstract val name: String
@Schema(withSchemaObject = true)
data class Dog(
@Description("Animal's name")
override val name: String,
) : Animal()
@Schema(withSchemaObject = true)
data class Cat(
@Description("Animal's name")
override val name: String,
) : Animal()
}val generator = ReflectionClassJsonSchemaGenerator.Default
val schema = generator.generateSchema(Animal::class)
println(schema.encodeToString(Json { prettyPrint = true })){
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "kotlinx.schema.integration.type.Animal",
"description": "Multicellular eukaryotic organism of the kingdom Metazoa",
"type": "object",
"additionalProperties": false,
"oneOf": [
{
"$ref": "#/$defs/Animal.Cat"
},
{
"$ref": "#/$defs/Animal.Dog"
}
],
"$defs": {
"Animal.Cat": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "Animal.Cat"
},
"name": {
"type": "string",
"description": "Animal's name"
}
},
"required": [
"type",
"name"
],
"additionalProperties": false
},
"Animal.Dog": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "Animal.Dog"
},
"name": {
"type": "string",
"description": "Animal's name"
}
},
"required": [
"type",
"name"
],
"additionalProperties": false
}
}
}Key features:
oneOf with $ref: Each sealed subclass is referenced from a $defs sectionlives: Int = 9) are includedMark classes with @Schema to generate extension properties for them:
@Schema // Uses default schema type "json"
data class Address(val street: String, val city: String)
@Schema("json") // Explicitly specify schema type
data class Person(val name: String, val age: Int)@Schema parameters:
value = "json": Schema type (only JSON currently supported)withSchemaObject = false: Generate jsonSchema: JsonObject property (
see Advanced Configuration)Note: jsonSchemaString is always generated. jsonSchema requires withSchemaObject = true.
Use @Description on classes and properties to add human-readable documentation to your schemas:
@Description("A purchasable product with pricing info")
@Schema
data class Product(
@Description("Unique identifier for the product") val id: Long,
@Description("Human-readable product name") val name: String,
@Description("Optional detailed description of the product") val description: String?,
@Description("Unit price expressed as a decimal number") val price: Double,
)Tip: With the recommended compiler flag -Xannotation-default-target=param-property, a bare @Description on a
primary constructor parameter also applies to the property. If you do not enable the flag, use @param:Description for
constructor-declared properties.
Modern LLMs (OpenAI GPT-4, Anthropic Claude, etc.) use structured function calling to interact with your code. They require a specific JSON schema format that describes available functions, their parameters, and types.
LLM APIs need to know:
This library automatically generates schemas that comply with the OpenAI function calling specification, making it easy to expose Kotlin functions to LLMs.
@Description("Get current weather for a location")
fun getWeather(
@Description("City and country, e.g. 'London, UK'")
location: String,
@Description("Temperature unit")
unit: String = "celsius"
): WeatherInfo {
return WeatherInfo(20.0, unit)
}
val generator = ReflectionFunctionCallingSchemaGenerator.Default
val schema = generator.generateSchema(::getWeather)The generated schema follows the LLM function calling format:
{
"type": "function",
"name": "getWeather",
"description": "Get current weather for a location",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and country, e.g. 'London, UK'"
},
"unit": {
"type": "string",
"description": "Temperature unit"
}
},
"required": [
"location",
"unit"
],
"additionalProperties": false
}
}@Description annotationsdata class Config(val port: Int = 8080))strict: true enables
OpenAI's strict mode for reliable parsing["string", "null"] instead of nullable: true
Note: Function parameter defaults (e.g.,
unit: String = "celsius") cannot be extracted via reflection, but nested data class property defaults are fully supported.
// Define your functions
@Description("Search the knowledge base")
fun searchKnowledge(
@Description("Search query") query: String,
@Description("Max results") limit: Int = 10
): String = TODO()
@Description("Calculate order total with tax")
fun calculateTotal(
@Description("Item prices") prices: List<Double>,
@Description("Tax rate as decimal") taxRate: Double = 0.0
): Double = TODO()
// Generate schemas
val generator = ReflectionFunctionCallingSchemaGenerator.Default
val schemas = listOf(::searchKnowledge, ::calculateTotal)
.map { generator.generateSchema(it) }
// Serialize to JSON
val jsonSchemas = schemas.map { Json.encodeToString(it) }
// Or get as JsonObject
val schemaObjects = schemas.map { it.encodeToJsonObject() }The generated schemas can be sent to any LLM API that supports function calling (OpenAI, Anthropic, etc.). Integration with specific LLM providers requires their respective client libraries.
Nullable parameters are represented as union types:
@Description("Update user profile")
fun updateProfile(
@Description("User ID") userId: String,
@Description("New name, if changing") name: String? = null,
@Description("New email, if changing") email: String? = null
): Boolean = TODO("does not matter")// ...Generates:
{
"properties": {
"userId": {
"type": "string",
"description": "User ID"
},
"name": {
"type": [
"string",
"null"
],
"description": "New name, if changing"
},
"email": {
"type": [
"string",
"null"
],
"description": "New email, if changing"
}
},
"required": [
"userId",
"name",
"email"
]
}Note: Even nullable parameters are in required array. The null type in the union indicates optionality.
For more details on function calling schemas and OpenAI compatibility, see kotlinx-schema-json/README.md.
Generate function schemas at compile time with zero runtime overhead. KSP generates type-safe extensions for all your annotated functions, with APIs that reflect where functions actually live in your code.
Annotate package-level functions to generate top-level schema accessors:
@Schema
@Description("Sends a greeting message to a person")
fun greetPerson(
@Description("Name of the person to greet")
name: String,
@Description("Optional greeting prefix (e.g., 'Hello', 'Hi')")
greeting: String = "Hello",
): String = "$greeting, $name!"
// Generated: top-level functions
val schema = greetPersonJsonSchemaString()Annotate class methods to generate KClass extensions on the containing class:
class UserService {
@Schema
@Description("Registers a new user in the system")
fun registerUser(
@Description("Username for the new account")
username: String,
@Description("Email address")
email: String,
): String = "User registered"
}
// Generated: KClass extension on UserService
val schema = UserService::class.registerUserJsonSchemaString()Annotate companion methods to generate KClass extensions on the companion object itself:
class DatabaseConnection {
companion object {
@Schema
@Description("Creates a new database connection")
fun create(
@Description("Database host")
host: String,
@Description("Database port")
port: Int = 5432,
): DatabaseConnection = TODO()
}
}
// Generated: KClass extension on companion object
val schema = DatabaseConnection.Companion::class.createJsonSchemaString()This API correctly reflects that companion functions belong to the companion object, not the parent class.
Annotate object methods to generate KClass extensions on the object type:
object ConfigurationManager {
@Schema
@Description("Loads configuration from a file")
fun loadConfig(
@Description("Configuration file path")
filePath: String,
@Description("Whether to create file if it doesn't exist")
createIfMissing: Boolean = false,
): Map<String, String> = TODO()
}
// Generated: KClass extension on object
val schema = ConfigurationManager::class.loadConfigJsonSchemaString()KSP generates schema accessor functions that match where your functions live:
| Function Type | Annotate | Generated API | Example |
|---|---|---|---|
| Top-level | Package function | Top-level accessor | greetPersonJsonSchemaString() |
| Instance | Class method |
KClass extension |
UserService::class.registerUserJsonSchemaString() |
| Companion | Companion method |
Companion::class extension |
DatabaseConnection.Companion::class.createJsonSchemaString() |
| Object | Object method |
Object::class extension |
ConfigurationManager::class.loadConfigJsonSchemaString() |
For each annotated function, you get:
{functionName}JsonSchemaString(): String — returns the schema as a JSON string{functionName}JsonSchema(): FunctionCallingSchema — returns the schema object (requires
withSchemaObject = true)Generated schemas follow the OpenAI function calling format:
{
"type": "function",
"name": "greetPerson",
"description": "Sends a greeting message to a person",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the person to greet"
},
"greeting": {
"type": "string",
"description": "Optional greeting prefix"
}
},
"required": [
"name",
"greeting"
],
"additionalProperties": false
}
}OpenAI Strict Mode: All parameters are marked as required by default, even those with default values. This ensures compatibility with OpenAI Structured Outputs.
| Feature | KSP (Compile-time) | Reflection (Runtime) |
|---|---|---|
| Performance | Zero runtime cost | Small reflection overhead |
| Platforms | Multiplatform | JVM only |
| Default values | Tracked but not extracted (KSP limitation) | Fully extracted from data classes |
| When to use | Your annotated functions | Third-party functions, dynamic scenarios |
Suspend functions work identically to regular functions. The generated schemas don't expose the suspend modifier—they describe parameter types only:
@Schema
@Description("Fetches user data asynchronously")
suspend fun fetchUserData(
@Description("User ID to fetch") userId: Long,
): UserData = TODO()
// Generated API works the same way
val schema = fetchUserDataJsonSchemaString()You don't need to change your existing code!
kotlinx-schema recognizes description annotations from multiple frameworks by their simple name, allowing you to generate schemas from code that uses annotations from other libraries.
The library automatically recognizes these description annotations by default:
| Annotation | Simple Name | Library/Framework | Example |
|---|---|---|---|
kotlinx.schema.Description |
Description |
kotlinx-schema | @Description("User name") |
ai.koog.agents.core.tools.annotations.LLMDescription |
LLMDescription |
Koog AI agents | @LLMDescription("Query text") |
com.fasterxml.jackson.annotation.JsonPropertyDescription |
JsonPropertyDescription |
Jackson | @JsonPropertyDescription("Email") |
com.fasterxml.jackson.annotation.JsonClassDescription |
JsonClassDescription |
Jackson | @JsonClassDescription("User model") |
dev.langchain4j.model.output.structured.P |
P |
LangChain4j | @P("Search query") |
The introspector matches annotations by their simple name only, not the fully qualified name. This means:
Annotation detection is configurable via kotlinx-schema.properties loaded from the classpath.
The configuration file is optional — if not provided or fails to load, the library uses sensible defaults.
By default, the library recognizes:
Annotation names: Description, LLMDescription, JsonPropertyDescription, JsonClassDescription, P Attribute names: value, description
To customize, place kotlinx-schema.properties in your project's resources:
# Add your custom annotations to the defaults
introspector.annotations.description.names=Description,MyCustomAnnotation,DocString
introspector.annotations.description.attributes=value,description,textNote: The library falls back to built-in defaults if the configuration file is missing or cannot be loaded.
// Your custom annotation
package com.mycompany.annotations
annotation class ApiDoc(val text: String)
// Usage in your models
@ApiDoc(text = "Customer profile information")
data class Customer(
@ApiDoc(text = "Unique customer identifier")
val id: Long,
val name: String
)Update kotlinx-schema.properties:
introspector.annotations.description.names=Description,ApiDoc
introspector.annotations.description.attributes=value,description,textNow the schema generator will recognize @ApiDoc and extract descriptions from its text parameter.
If your project already uses Jackson for JSON serialization, you can generate schemas from existing Jackson-annotated classes without any modifications. This is particularly useful for REST APIs and Spring Boot applications where Jackson annotations are already present.
// Existing code with Jackson annotations - NO CHANGES NEEDED!
@JsonClassDescription("Customer profile data")
data class Customer(
@JsonPropertyDescription("Unique customer ID")
val id: Long,
@JsonPropertyDescription("Full name")
val name: String,
@JsonPropertyDescription("Contact email")
val email: String
)
// Generate JSON schema without modifying the code
val generator = kotlinx.schema.generator.json.ReflectionClassJsonSchemaGenerator.Default
val schema = generator.generateSchema(Customer::class)
// Schema includes all Jackson descriptions!LangChain4j uses the @P annotation for parameter descriptions in AI function calling. The library recognizes these
annotations automatically, enabling seamless integration with existing LangChain4j codebases.
// Code using LangChain4j annotations
data class SearchQuery(
@P("Search terms")
val query: String,
@P("Maximum results to return")
val limit: Int = 10
)
// Generate schema for LLM function calling
val generator = ReflectionFunctionCallingSchemaGenerator.Default
val schema = generator.generateSchema(SearchQuery::class.constructors.first())Koog AI framework uses @LLMDescription for documenting agent tools and parameters. The library supports both the
verbose description = syntax and the shorthand form, making migration from Koog straightforward.
@LLMDescription(description = "Product with pricing information")
@Schema
data class Product(
@LLMDescription(description = "Product identifier")
val id: Long,
@LLMDescription("Product name")
val name: String,
@LLMDescription("Unit price")
val price: Double,
)If multiple description annotations are present on the same element, the library uses this precedence order:
@Description (kotlinx-schema's own annotation)Tip: For best compatibility, prefer @Description from kotlinx-schema when writing new code, but existing
annotations from other libraries work seamlessly.
For manual schema construction, use the kotlinx-schema-json module. It provides type-safe Kotlin models compliant with the JSON Schema Draft 2020-12 specification and a DSL for building JSON Schema definitions programmatically, with full kotlinx-serialization support.
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-schema-json:<version>")
}Quick Example:
val schema = jsonSchema {
property("id") {
required = true
string { format = "uuid" }
}
property("email") {
required = true
string { format = "email" }
}
property("age") {
integer {
minimum = 0.0
maximum = 150.0
}
}
// Polymorphic types with discriminators
property("role") {
oneOf {
discriminator(propertyName = "type") {
"admin" mappedTo "#/definitions/AdminRole"
"user" mappedTo {
property("type") { string { constValue = "user" } }
property("permissions") { array { ofString() } }
}
}
}
}
}Features:
📖 For comprehensive documentation, see kotlinx-schema-json/README.md covering:
For build instructions, development setup, and contribution guidelines, see CONTRIBUTING.md.
Tip: If you use @Description on primary constructor parameters, enable
-Xannotation-default-target=param-property in Kotlin compiler options so the description applies to the backing
property.
This project and the corresponding community are governed by the JetBrains Open Source and Community Code of Conduct. Please make sure you read and adhere to it.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.