😸 kt-schema

Function Calling

Table of contents

LLM APIs (OpenAI, Anthropic) and the Model Context Protocol (MCP) call your code through tools: each tool is a name, a description and a JSON Schema for its parameters. kt-schema builds that definition from a Kotlin function, so parameter names, types, nullability and @Description text stay in one place.

Provider formats

The generated FunctionCallingSchema serializes to the OpenAI Responses API function tool shape: type, name, description, strict and parameters at the top level. Other APIs take the same parameters object under a different key:

APIWrapperParameters keystrict
OpenAI Responses APIas generatedparameterstop-level strict: true
OpenAI Chat Completionsnest name/description/parameters under functionparametersinside function
Anthropic Messages APIname, description, input_schemainput_schematop-level strict: true
MCP toolname, title, description, inputSchemainputScheman/a

The default strict output has the shape OpenAI strict mode and Anthropic strict tool use require: every property is in required, additionalProperties is false, and optional values are expressed as ["<type>", "null"]. Anthropic strict tool use also rejects numeric and string-length keywords (minimum, maxLength, …). Those come from validation constraints and from the built-in ranges of Byte, Short, Char and the unsigned types, so in parameters you send there with strict: true, drop the constraints and use Int, Long or String instead.

Basic Usage

kotlin
@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)

Generated Schema

The generated schema follows the LLM function calling format:

json
{
    "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
    }
}

Key Features

Note: Function parameter defaults (e.g., unit: String = "celsius") cannot be extracted via reflection, unlike nested data class property defaults.

Working with Multiple Functions

kotlin
// 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() }

Send the result through your provider’s client library; see Provider formats for the key each API expects the parameters under.

Nullable Parameters

Nullable parameters are represented as union types:

kotlin
@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:

json
{
    "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 kt-schema-json/README.md .

Compile-time Function Schema Generation (KSP)

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.

Top-Level Functions

Annotate package-level functions to generate top-level schema accessors:

kotlin
@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()

Instance/Member Functions

Annotate class methods to generate KClass extensions on the containing class:

kotlin
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()

Companion Object Functions

Annotate companion methods to generate KClass extensions on the companion object itself:

kotlin
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.

Singleton Object Functions

Annotate object methods to generate KClass extensions on the object type:

kotlin
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()

What Gets Generated

KSP generates schema accessor functions that match where your functions live:

Function TypeAnnotateGenerated APIExample
Top-levelPackage functionTop-level accessorgreetPersonJsonSchemaString()
InstanceClass methodKClass extensionUserService::class.registerUserJsonSchemaString()
CompanionCompanion methodCompanion::class extensionDatabaseConnection.Companion::class.createJsonSchemaString()
ObjectObject methodObject::class extensionConfigurationManager::class.loadConfigJsonSchemaString()

For each annotated function, you get:

Schema Format

Generated schemas use the OpenAI Responses API function tool shape (see Provider formats ):

json
{
    "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
    }
}

Strict mode: All parameters are marked as required by default, even those with default values, as OpenAI strict mode requires. With a non-strict config (lenient, openapi), parameters that have a Kotlin default value are omitted from required; KSP can’t read the default’s value, so no default is emitted.

KSP vs Runtime Reflection

FeatureKSP (Compile-time)Reflection (Runtime)
PerformanceZero runtime costSmall reflection overhead
PlatformsMultiplatformJVM only
Default valuesTracked but not extracted (KSP limitation )Fully extracted from data classes
When to useYour annotated functionsThird-party functions, dynamic scenarios

Suspend Functions

Suspend functions work identically to regular functions. The generated schemas don’t expose the suspend modifier—they describe parameter types only:

kotlin
@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()

See Also