Python’s agent frameworks have already cycled through several rounds — LangChain, LlamaIndex, and then Google’s own ADK. Kotlin has been dead quiet the whole time — not that nobody wanted to build one, but there was nothing official to lean on, and nobody quite dared put it in production.

Google’s adk-kotlin hit 1.0 GA on September 7. 1.1.0 came on September 15, and the Ktor 3.6.0 artifacts landed on Maven Central on September 16. I took those three and built something that actually works: an agent that can go verify for itself whether a given KMP library supports iOS, with the server and the client in the same KMP project.

One thing up front. I stepped on a landmine while writing this: the request body I copied out of the source returned a 400 on a real server, and with no error message at all. It took me the better part of half an hour to pin down. It was a version issue. That gets its own section later, because it’s more worth remembering than the tutorial itself.

What this agent solves

The question KMP developers ask most often is: does this library support iOS?

READMEs often don’t answer it, or answer with something out of date. The authoritative source is the Gradle module metadata the library publishes to Maven Central — that .module file is generated by the build, and what it lists is the platform variants this particular compile actually produced.

So the tool does something very direct: pull down the .module, parse out every variant name, and count how many mention iOS.

@Tool
fun checkLibraryPlatforms(
  @Param("Maven group id, e.g. com.google.adk") group: String,
  @Param("Maven artifact id, e.g. google-adk-kotlin-core") artifact: String,
  @Param("Version to check, e.g. 1.1.0") version: String,
): String {
  val url = "https://repo1.maven.org/maven2/${group.replace('.', '/')}/" +
    "$artifact/$version/$artifact-$version.module"
  // ... fetch, parse, count iOS variants
}

@Tool and @Param are ADK annotations. Why they matter comes later.

Structure: why two modules

adk-kotlin-agent-quickstart/
├── shared/          KMP module
│   └── src/
│       ├── commonMain/   Ktor client + protocol DTOs (shared by jvm / iOS)
│       ├── jvmMain/      CLI entry point + CIO engine
│       └── iosMain/      Darwin engine
└── agent-server/    pure JVM module: ADK agent + HTTP server

It looks like it should fit in one module. It doesn’t. google-adk-kotlin-webserver depends internally on Ktor 2.3.13, while the client needs Ktor 3.6.0. Put them together and Gradle’s “highest version wins” drags the server up to Ktor 3 too, but ADK’s code was compiled against the Ktor 2 API and blows up at runtime.

Not a guess. After it came up I looked at the classpath, and the server really was this set:

ktor-server-netty-jvm-2.3.13.jar
ktor-server-core-jvm-2.3.13.jar
ktor-client-core-jvm-2.3.13.jar
...

The client side is on 3.6.0. Physically separated, everybody’s happy.

Version pinning

KSP has to pair exactly with Kotlin. This is the easiest place to bury a bug. I copied the exact combination the google/adk-kotlin repo uses for its own build:

[versions]
kotlin = "2.3.21"
ksp = "2.3.9"          # bump with kotlin, don't touch it alone
adk = "1.1.0"
ktor = "3.6.0"

Three server dependencies:

dependencies {
  implementation(libs.adk.core)        // agents / models / tools / sessions / runners
  ksp(libs.adk.processor)              // generate tool schemas at compile time
  implementation(libs.adk.webserver)   // expose the agent over HTTP
}

How a tool becomes a schema

This is the biggest difference between ADK for Kotlin and the Python version. Python leans on runtime reflection. Kotlin does it at compile time with KSP.

This one line inside KmpSupportAgent is the key:

tools = KmpLibraryService().generatedTools()

I never wrote a single line of generatedTools() anywhere in the project. After compilation it turns up at:

agent-server/build/generated/ksp/main/kotlin/com/example/adk/server/
├── CheckLibraryPlatformsTool.kt
├── ListAvailableVersionsTool.kt
└── KmpLibraryService_GeneratedTools.kt

The last one contains:

public fun KmpLibraryService.generatedTools(): List<FunctionTool> = listOf( ... )

The upside is no reflection at runtime, which keeps R8/obfuscation, Native images and startup time clean. The cost is that changing a tool signature forces a recompile, and dynamic tool registration is off the table.

The agent itself

object KmpSupportAgent {
  @JvmField
  val rootAgent = LlmAgent(
    name = "kmp_library_platform_agent",
    model = Gemini(name = "gemini-3.1-flash-lite"),
    instruction = Instruction("""
      You are an assistant for Kotlin Multiplatform developers.
      When the user asks whether a library supports a platform, never answer
      from memory. Call `checkLibraryPlatforms` and read the result.
      ...
    """.trimIndent()),
    tools = KmpLibraryService().generatedTools(),
  )
}

A note on @JvmField. Every agent’s rootAgent in the official examples carries it. I copied it at first too and assumed it was required. All it does is turn a Kotlin object property into a real static field (instead of INSTANCE.getRootAgent()), which makes life easier for Java callers. I removed it, recompiled, ran it again — the server still came up and /list-apps still returned. Because AdkServerConfig.inMemory(agent) takes the agent object directly and never goes through reflection. Copying it is fine, just don’t believe it won’t start without it.

One thing I deliberately spelled out in the instruction: platform.type is an aggregate marker. Kotlin/Native targets all report native, and which Apple target it actually is only shows up in the variant name. Leave that out and the model happily reads the word native and draws its own conclusion, when it can’t actually get from native to whether iOS is in there.

Starting the server

fun main() {
  val config = AdkServerConfig.inMemory(KmpSupportAgent.rootAgent)
  AdkDevServer(config).start(wait = true)
}

AdkDevServer hangs a dev UI off the side. The routes look like this:

MethodPathPurpose
GET/list-appsWhich agents exist, i.e. what you can put in appName
GET/versionServer version
POST/apps/{appName}/users/{userId}/sessionsCreate a session, returns the session id
POST/runRun a turn, returns the full event list at once
POST/run_sseRun a turn, streams back over SSE
GET/dev-uiDev UI

Three of them you can verify directly:

$ curl -s localhost:8080/list-apps
["kmp_library_platform_agent"]

$ curl -s localhost:8080/version
{"version":"1.1.0","language":"kotlin","language_version":"21.0.10"}

$ curl -s -X POST -H "Content-Type: application/json" -d '{}' \
    localhost:8080/apps/kmp_library_platform_agent/users/demo-user/sessions
{"id":"b86f7324-81db-49df-8007-1700077ebdb4","appName":"kmp_library_platform_agent",...}

/run_sse uses plain SSE, one data: <json> line per event followed by a blank line. The server implements it by hand because Ktor 2.x has no SSE plugin. The comment in the source is a good read:

Ktor 2.x has no SSE plugin, so stream manually. Use async respondBytesWriter, not respondTextWriter, whose blocking bridge parks via a coroutines internal gone in 1.11.0.

Ktor 2.x has no SSE plugin, so stream it by hand. Use the async respondBytesWriter, not respondTextWriter — its blocking bridge depends on an internal implementation that was already removed in kotlinx-coroutines 1.11.0.

In other words, the comment is warning you about itself: bump the coroutines version and this API may break.

Client: written in commonMain

This is where the actual cross-platform part lives. Request building and SSE parsing all sit in commonMain without touching platform APIs:

fun run(prompt: String, sessionId: String? = null): Flow<Event> = channelFlow {
  val sid = sessionId ?: createSession()
  httpClient.preparePost("$baseUrl/run_sse") {
    contentType(ContentType.Application.Json)
    setBody(RunRequest(appName, userId, sid, Content.text("user", prompt), streaming = true))
  }.execute { response ->
    val channel = response.bodyAsChannel()
    while (true) {
      val line = channel.readLine() ?: break
      if (!line.startsWith("data:")) continue
      val payload = line.removePrefix("data:").trim()
      if (payload.isEmpty()) continue
      val event = json.decodeFromString<Event>(payload)
      send(event)
      if (event.turnComplete) break
    }
  }
}

Only the engine is per-platform, closed off behind one expect/actual:

// commonMain
expect fun createPlatformHttpClient(json: Json): HttpClient

// jvmMain
actual fun createPlatformHttpClient(json: Json) = HttpClient(CIO) { ... }

// iosMain
actual fun createPlatformHttpClient(json: Json) = HttpClient(Darwin) { ... }

The loop exits on two fields: partial marks a streaming chunk that isn’t finished, turnComplete marks the end of the turn. Without turnComplete you have to infer it from the connection closing, and that drags you all the way to a timeout.

That 400

Now the trap I mentioned at the top.

I wrote the request body from my own understanding, using the app_name / user_id / new_message naming — after all, that’s how Python ADK does it, and I really did see ApiModels.kt in the repo source hanging an alias on every multi-word property:

@JsonNames("app_name") val appName: String,
@JsonNames("user_id") val userId: String,

Send it:

HTTP/1.1 400 Bad Request
Content-Length: 0

No body, no stack trace, not a single ERROR line in the server log. So I tried camelCase and it came back 500 — which means deserialization passed and the failure happened further down (the model call). In other words:

  • camelCase → parses, walks all the way to the Gemini SDK
  • snake_case → never reaches business logic

@JsonNames is the “alternate name” mechanism in kotlinx-serialization. In theory both spellings should be accepted. I even wrote a minimal repro with the same annotation and the same Json config, and snake_case parsed fine. So it isn’t kotlinx.

It’s the version. I went and read the tag:

v1.1.0's ApiModels.kt:
  data class AgentRunRequest(
    val appName: String,
    val userId: String,
    ...
  )
  → not a single @JsonNames

The commit that added those two aliases, 94dcdd13 (feat: accept snake_case and camelCase in web server request bodies), is dated 2026-09-15, landed on main, and went in after the v1.1.0 tag. There’s also a test file in the repo that says the server reads snake_case — it describes main’s behavior, not the 1.1.0 you can actually download.

Decode snake_case with the real AgentRunRequest and the real adkJson and the error is unambiguous:

kotlinx.serialization.MissingFieldException: Fields [appName, userId] are required
for type with serial name '...AgentRunRequest', but they were missing

So the rule is: for 1.1.0, always camelCase. snake_case waits for the next release.

The lesson here isn’t “read the tag”. It’s “reading main’s source is not the same as reading the version you depend on”. If I hadn’t actually sent the request and tried it, the sample code in this post would be copied from unreleased behavior and 400 on the first run.

If you want a self-check trick, the /version endpoint is genuinely useful — the server reports its own version, and then you compare against that tag’s source, which beats guessing against main.

What the tools actually print

This part doesn’t need a model. The tools are pure Kotlin and run directly:

./gradlew :agent-server:runTools

First, cut into ADK itself:

com.google.adk:google-adk-kotlin-core:1.1.0
platform.type values: androidJvm, common, jvm
variants mentioning iOS: 0
Verdict: no iOS target. This library cannot be consumed from an
iosArm64 / iosSimulatorArm64 source set.

Now Ktor for contrast:

io.ktor:ktor-client-core:3.6.0
platform.type values: common, js, jvm, native, wasm
variants mentioning iOS: 9
  - iosArm64ApiElements-published
  - iosSimulatorArm64ApiElements-published
  - iosX64ApiElements-published
  ... (9 total, across Api / Metadata / Sources)
Verdict: iOS targets are published, so Apple targets can depend on it.

Same function, same decision logic, and the two libraries come out opposite. That contrast gets its own note below.

Version lookup and the error path while we’re here:

com.google.adk:google-adk-kotlin-core — latest=1.1.0
recent: 0.5.0, 0.6.0, 0.7.0, 0.8.0, 0.9.0, 1.0.0, 1.0.1, 1.1.0

Could not fetch metadata for com.google.adk:does-not-exist:9.9.9 (HTTP 404).
The coordinates or version are probably wrong.

The error path returns a human sentence instead of throwing. What the tool hands back is a string. An exception cuts the turn off right there, and the model can’t even say “that library doesn’t exist”.

The cross-platform part

shared is genuinely cross-platform. I actually compiled it:

$ ./gradlew :shared:compileKotlinIosSimulatorArm64 :shared:compileKotlinIosArm64
BUILD SUCCESSFUL in 33s

Same commonMain code, both JVM and iOS pass.

agent-server can only ever run on JVM (or Android). It’s not a config problem — the Gradle variants google-adk-kotlin-core publishes to Maven Central are only common / androidJvm / jvm, and google-genai-kotlin is the same. No iOS variant, no Native variant.

So if you want to run the agent on iOS there are two roads: put it on the server and let iOS be a client only (which is the structure of this project), or wait for upstream to add Native support. I filed an issue on the ADK repo about it, #504, no reply yet.

Side note: this “agent lives on the server only” split is actually the more sensible call in most cases — model keys never ship in the client bundle, and tool execution plus permission checks stay somewhere you control.

How to run

export GOOGLE_API_KEY=...
./run-stack.sh

The script does four things: installs the server and starts it on 8080, polls /list-apps until it’s really up, runs the client, then tears the server down and passes the client’s exit code through untouched.

(The first version of that script leaked: it started the service through the launcher script Gradle generates, and that script runs java as a child process. kill only killed the wrapper, so the server was orphaned and sat on 8080 forever. Starting java directly is what makes $! the server itself.)

Or split it:

./gradlew :agent-server:run     # terminal 1
./gradlew :shared:runClient     # terminal 2

Last thing, let me be clear about how far I verified, so you don’t hit a step down when you follow along.

What works: the whole project compiles (including KSP codegen); the server starts; /list-apps, /version and session creation all return 200; the client’s /run_sse request is accepted by the server and reaches the model call layer in com.google.genai.kotlin.

What doesn’t: a real model conversation. That needs your own Gemini API key, which I don’t have. So I didn’t paste the full conversation output for the “does it support iOS” question from the top of the post. You only see that with a valid key attached, and I’m not going to paste output I never ran.

Code is at https://github.com/trewvip-arch/adk-kotlin-agent-quickstart

Apache-2.0. Open an issue in the repo if something’s wrong.