Use this file to discover all available pages before exploring further.
Kotlin powers a significant portion of backend infrastructure across the JVM ecosystem, yet most AI agent tutorials target Python exclusively. Koog is JetBrains’ open-source framework that brings first-class agent development to Kotlin, leveraging coroutines for async execution, serialization for structured output, and type safety throughout.This guide walks through three progressively more capable agent patterns, extracted from the tutorial in the tutorials/kotlin-agent-with-koog directory.
This guide targets JVM and Android developers who already know Kotlin basics and Gradle. If you are coming from Python, the inline comments in each code sample call out the Kotlin-specific patterns (coroutines, Elvis operator, use blocks) alongside their Python equivalents.
Kotlin-native
Coroutines, type safety, and Gradle — no Python runtime or virtual environment required.
Annotation-based tools
Expose functions to the LLM with @Tool and @LLMDescription — no schema boilerplate.
Typed responses
Get Kotlin data classes back from the LLM instead of strings that need parsing.
The build.gradle.kts file declares everything the tutorial needs:
plugins { kotlin("jvm") version "2.1.0" kotlin("plugin.serialization") version "2.1.0"}repositories { mavenCentral()}dependencies { // Koog AI agent framework by JetBrains implementation("ai.koog:koog-agents:0.6.2") // Kotlin coroutines (required for suspend-based agent execution) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1") // Kotlin serialization (required for structured output and tool definitions) implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") // SLF4J no-op logger to suppress logging noise in tutorial output implementation("org.slf4j:slf4j-nop:2.0.16")}sourceSets { main { kotlin { // Look for .kt files at the project root rather than src/main/kotlin setSrcDirs(listOf(".")) } }}kotlin { jvmToolchain(17)}
Verify the project compiles before proceeding:
./gradlew build
The first build downloads Gradle and all dependencies — this takes about a minute and only happens once.
A minimal Koog agent needs three things: an executor (the HTTP client that reaches the LLM), a model, and a system prompt. The run() call sends your prompt and returns the model’s text response.
// Step1_HelloAgent.ktimport ai.koog.agents.core.agent.AIAgentimport ai.koog.prompt.executor.clients.openai.OpenAIModelsimport ai.koog.prompt.executor.llms.all.simpleOpenAIExecutor// "suspend fun main()" is Kotlin's async entry point.// In Python terms: async def main()// Koog agents use coroutines, so main() must be suspendable.suspend fun main() { // The "?:" operator (Elvis) provides a fallback if the value is null. // In Python terms: api_key = os.environ.get("OPENAI_API_KEY") or raise ... val apiKey = System.getenv("OPENAI_API_KEY") ?: error("Set the OPENAI_API_KEY environment variable before running this example.") // ".use { ... }" automatically closes the HTTP connection when the block finishes. // In Python terms: with open(...) as f: simpleOpenAIExecutor(apiKey).use { executor -> val agent = AIAgent( promptExecutor = executor, llmModel = OpenAIModels.Chat.GPT4oMini, systemPrompt = "You are a concise assistant. Answer in one or two sentences.", ) val response = agent.run("What makes Kotlin a good language for backend development?") println("Agent response: $response") }}
A basic agent is limited to the knowledge baked into the LLM. Tools let the agent query real systems, perform calculations, or look up data at runtime.Koog uses an annotation-based API. You create a class that implements ToolSet, mark each callable method with @Tool, and add @LLMDescription to every function and parameter so the model knows when and how to invoke them. This follows the ReAct pattern: the LLM reasons about the task, calls a tool, observes the result, and repeats until it has enough information to respond.
// Step2_AgentWithTools.kt (tool definitions)import ai.koog.agents.core.tools.annotations.LLMDescriptionimport ai.koog.agents.core.tools.annotations.Toolimport ai.koog.agents.core.tools.reflect.ToolSet@LLMDescription("Tools for looking up weather, performing calculations, and retrieving facts")class AssistantTools : ToolSet { @Tool @LLMDescription("Get the current weather for a given city. Returns temperature and conditions.") fun getWeather(@LLMDescription("City name, e.g. 'Tokyo'") city: String): String { // In a real app this would call a weather API. // Hardcoded data keeps the tutorial self-contained. val data = mapOf( "tokyo" to "22C, partly cloudy", "london" to "14C, rainy", "new york" to "28C, sunny", "sydney" to "18C, clear skies", ) return data[city.lowercase()] ?: "25C, clear (default forecast)" } @Tool @LLMDescription("Evaluate a basic arithmetic expression and return the numeric result.") fun calculate( @LLMDescription("Arithmetic expression, e.g. '144 / 12'") expression: String ): String { val result = evaluateExpression(expression) return "$expression = $result" } @Tool @LLMDescription("Look up a factual piece of information about a topic.") fun lookupFact( @LLMDescription("Topic to look up, e.g. 'Kotlin'") topic: String ): String { val facts = mapOf( "kotlin" to "Kotlin was created by JetBrains and first released in 2011. " + "It became an official Android language in 2017.", "koog" to "Koog is an open-source AI agent framework by JetBrains for " + "building LLM-powered agents in Kotlin.", ) return facts[topic.lowercase()] ?: "No specific fact found for '$topic'." } private fun evaluateExpression(expr: String): Double { val s = expr.replace(" ", "") return when { "+" in s.drop(1) -> { val i = s.indexOfLast { it == '+' } evaluateExpression(s.substring(0, i)) + evaluateExpression(s.substring(i + 1)) } "-" in s.drop(1) -> { val i = s.indexOfLast { it == '-' } evaluateExpression(s.substring(0, i)) - evaluateExpression(s.substring(i + 1)) } "*" in s -> { val i = s.indexOfFirst { it == '*' } evaluateExpression(s.substring(0, i)) * evaluateExpression(s.substring(i + 1)) } "/" in s -> { val i = s.indexOfFirst { it == '/' } evaluateExpression(s.substring(0, i)) / evaluateExpression(s.substring(i + 1)) } else -> s.toDouble() } }}
// Step2_AgentWithTools.kt (agent setup)import ai.koog.agents.core.agent.AIAgentimport ai.koog.agents.core.tools.ToolRegistryimport ai.koog.agents.core.tools.reflect.asToolsimport ai.koog.prompt.executor.clients.openai.OpenAIModelsimport ai.koog.prompt.executor.llms.all.simpleOpenAIExecutorsuspend fun main() { val apiKey = System.getenv("OPENAI_API_KEY") ?: error("Set the OPENAI_API_KEY environment variable before running this example.") // .asTools() converts the annotated ToolSet class into Koog's internal format. val toolRegistry = ToolRegistry { tools(AssistantTools().asTools()) } simpleOpenAIExecutor(apiKey).use { executor -> val agent = AIAgent( promptExecutor = executor, llmModel = OpenAIModels.Chat.GPT4oMini, systemPrompt = """ You are a helpful assistant with access to tools for weather, calculations, and fact lookup. When asked a question, use the appropriate tools to find the answer. Always use tools when they are relevant rather than guessing. """.trimIndent(), toolRegistry = toolRegistry, ) // This single question forces the agent to use all three tools. val question = "What is the weather in Tokyo, and what is 144 divided by 12? " + "Also, tell me an interesting fact about Kotlin." println("Question: $question\n") // The agent decides which tools to call, what arguments to pass, // and how to compose the final answer — all automatically. val response = agent.run(question) println("Agent response: $response") }}
Run it:
./gradlew step2
To add a new tool, define a new method on AssistantTools with @Tool and @LLMDescription, then re-run. The agent discovers it automatically without any other code changes.
Free-text responses work for conversational interfaces, but production pipelines often need data they can parse directly — to populate a database, trigger downstream actions, or feed into a dashboard. Koog lets you define a Kotlin data class as the response schema and get back a fully deserialized object instead of a string.
Use @Serializable from kotlinx.serialization for JSON conversion and @LLMDescription so the model knows what to fill into each field. On data class constructor parameters, use the @property:LLMDescription use-site target — this is a Kotlin-specific detail required for Koog’s reflection-based schema generation:
// Step3_StructuredOutput.kt (data class)import ai.koog.agents.core.tools.annotations.LLMDescriptionimport kotlinx.serialization.SerialNameimport kotlinx.serialization.Serializable@Serializable@SerialName("CityAnalysis")@LLMDescription("An analysis of a city covering key facts and livability")data class CityAnalysis( @property:LLMDescription("Name of the city") val city: String, @property:LLMDescription("Country where the city is located") val country: String, @property:LLMDescription("Approximate population in millions") val populationMillions: Double, @property:LLMDescription("The city's primary language") val primaryLanguage: String, @property:LLMDescription("Three notable landmarks or attractions") val landmarks: List<String>, @property:LLMDescription("A short summary of the city's character in one or two sentences") val summary: String,)
Pass structuredOutputWithToolsStrategy<CityAnalysis>() as the agent’s strategy. Koog sends the data class schema to the model, allows tool calls in a loop if needed, and returns a deserialized CityAnalysis object when done:
// Step3_StructuredOutput.kt (agent setup)import ai.koog.agents.core.agent.AIAgentimport ai.koog.agents.core.agent.config.AIAgentConfigimport ai.koog.agents.ext.agent.structuredOutputWithToolsStrategyimport ai.koog.prompt.dsl.promptimport ai.koog.prompt.executor.clients.openai.OpenAIModelsimport ai.koog.prompt.executor.llms.all.simpleOpenAIExecutorsuspend fun main() { val apiKey = System.getenv("OPENAI_API_KEY") ?: error("Set the OPENAI_API_KEY environment variable before running this example.") val agentConfig = AIAgentConfig( prompt = prompt("city-analyst") { system("You are a knowledgeable city analyst. When asked about a city, provide accurate structured data.") }, model = OpenAIModels.Chat.GPT4o, maxAgentIterations = 5, ) simpleOpenAIExecutor(apiKey).use { executor -> val agent = AIAgent( promptExecutor = executor, strategy = structuredOutputWithToolsStrategy<CityAnalysis>(), agentConfig = agentConfig, ) // The return type is CityAnalysis — a real Kotlin object, not a string. // No JSON parsing, no regex, no error-prone string splitting. val analysis: CityAnalysis = agent.run("Tell me about Tokyo") // Access typed fields directly println("City: ${analysis.city}") println("Country: ${analysis.country}") println("Population: ${analysis.populationMillions}M") println("Language: ${analysis.primaryLanguage}") println("Landmarks: ${analysis.landmarks.joinToString(", ")}") println("Summary: ${analysis.summary}") }}
Run it:
./gradlew step3
Every value in the output is a typed Kotlin field. analysis.populationMillions is a Double you can do arithmetic with. analysis.landmarks is a List<String> you can iterate over. The framework handles the entire serialization round-trip.
Send a prompt and receive a text response. Covers AIAgent, simpleOpenAIExecutor, and coroutine entry points.
Step 2: Tool calling
Register custom tools with @Tool and ToolRegistry. The agent uses the ReAct loop to call tools and compose answers.
Step 3: Structured output
Define a @Serializable data class and use structuredOutputWithToolsStrategy to get typed objects back from the LLM.
Koog agents run on Kotlin coroutines. Your main() function must be declared as suspend fun main() — a regular fun main() will not compile with Koog’s agent.run() calls.
Change OpenAIModels.Chat.GPT4oMini to OpenAIModels.Chat.GPT4o or any other model constant from the OpenAIModels object. For Step 3, GPT-4o is recommended because structured output with function calling benefits from the more capable model.
Add a real API tool
Replace the hardcoded weather data in AssistantTools.getWeather with an actual HTTP call using ktor-client or OkHttp. The annotation pattern and tool registry stay exactly the same — only the function body changes.
Define your own structured output schema
Change CityAnalysis to a schema that fits your use case — a BookReview, BugReport, or RecipeCard. Update the system prompt to match, then re-run ./gradlew step3. The same framework code handles any @Serializable data class.