Google ADK for Kotlin 1.0 Makes Multiplatform Agents a JVM Design Problem
Portability Is a Contract, Not a Target List
Google ADK for Kotlin 1.0 is now generally available. Its promise is attractive: define an agent once, then run it on Android and on server-side Kotlin/JVM. The release announcement lists hierarchical agents, compaction, human-in-the-loop confirmation, generated tools, resumable sessions, and Java interoperability as first-class capabilities.
That is a useful starting point, but “Android and JVM” is not a portability result. It is a list of targets. The real question is whether the same agent core has the same behavior when its model streams, tools block, process disappears, credential acquisition differs by platform, and Java code calls it.
My conclusion is deliberately narrower than the marketing claim: ADK can support one shared orchestration core, but not one undifferentiated runtime. Common code should own agent policy, event types, orchestration, and interfaces. Platform adapters should own cancellation of blocking work, storage, credentials, transports, model capabilities, and Java-facing boundaries.
That division follows the shape of the codebase. The ADK Kotlin repository separates core agents, models, tools, sessions, memory, artifacts, and runners from processor, webserver, A2A, LiteRT-LM, Firebase Android, and ML Kit Android modules. The documented KMP claim is credible as a core claim. It is not evidence that every integration belongs in commonMain.
Cancellation Is Not Resumption
Kotlin gives the common core a strong cancellation model. Cancellation propagates through a Job, suspension points check it, and structured concurrency prevents child work from silently outliving its parent. But cancellation is cooperative. CPU-heavy or blocking work must check explicitly; on the JVM, runInterruptible can help with interruptible blocking operations. The coroutine cancellation documentation makes this a lifecycle and resource contract, not a magic property of suspend.
That contract crosses every adapter:
| Operation | Shared concern | Platform-specific risk |
|---|---|---|
| Model streaming | Stop producing and close the stream | Native or blocking inference may ignore coroutine cancellation |
| Tool I/O | Cancel child work and release resources | Files, sockets, and SDK calls have different interruption behavior |
| Persistence | Avoid committing a half-written event | Transaction and dispatcher semantics differ |
| Android lifecycle | Stop work when the owner disappears | Process death is not the same as a cancelled Job |
| Server shutdown | Drain or cancel in-flight runs | A server adapter should define bounded shutdown and request-deadline behavior |
The release history provides a concrete warning. ADK’s changelog records a fix to release a cancelled LiteRT-LM conversation promptly instead of stalling. That is backend-specific engineering: the common API can propagate cancellation, but the model adapter still has to release its own resources.
Resumption is a different mechanism. ADK 1.0 describes pausing, serializing, and restoring interactions, including continuation after a long-running tool returns a value. A cancelled coroutine has stopped executing; a resumable invocation has durable state from which execution can continue. Treating the two as interchangeable is how an Android app loses an apparently “cancelled” action or a server repeats a side effect after recovery.
A portable core should therefore define cancellation semantics and resumable event transitions. Each adapter must test them. In particular, cancel a streamed response, interrupt tool I/O, kill the Android process, restart it, and resume a pending confirmation or long-running tool. Compilation cannot answer those questions.
Session State Is an Event Log Problem
A session is more than a conversation string. ADK describes it as identity, chronological events, session-scoped state, and update time; the runner appends events and uses them to update stored state. The session documentation therefore makes serialization, ordering, replay, and atomicity part of the persistence contract.
The storage choices expose the platform split. An in-memory service loses conversation data after a process restart, while the documented service family includes cloud-backed persistence. ADK Kotlin 1.0 also advertises Room/SQLite persistent sessions for Android, and the Android examples show a Room-backed conversation surviving restarts.
Those implementations can satisfy the same interface without having the same operational behavior:
| Concern | Android | JVM/server |
|---|---|---|
| Durable session | Room/SQLite | Cloud-backed or server database |
| Failure boundary | Process can be killed at any time | Graceful shutdown is possible but not guaranteed |
| Ownership | Usually one device/user | Concurrent requests and workers |
| Retention | Device storage, migration, cleanup | TTL, tenancy, backups, access policy |
| Recovery | Replay after process death | Replay after retry or failover |
A common SessionService interface is useful. A common persistence implementation usually is not. expect/actual is one way to make that boundary visible, but an injected SessionStore interface is often easier to test and to evolve. Either approach must specify event IDs, ordering, serialization versions, duplicate handling, and transaction boundaries.
Keep three kinds of state separate:
- Session state: events and working state needed to continue an invocation.
- Semantic memory: information deliberately retained for future conversations.
- Model caches and artifacts: files, embeddings, model packages, or tool outputs with their own lifecycle.
Room does not automatically solve memory policy, and a cloud session service does not automatically make an on-device model available after restart. The implementation should make those ownership and retention decisions explicit.
Tools Are Capabilities, Not Schemas
ADK’s KSP tool generation is a genuine portability win. @Tool and @Param can produce schemas at compile time without runtime reflection, and suspend tool functions fit the coroutine model. The Kotlin announcement supports the claim that tool contracts can be shared more reliably.
But a schema says what a tool accepts, not what it is allowed to do. A tool may read a local file on Android, call a privileged service on a server, or require credentials whose exposure would be unacceptable in an APK. That is a security-design consideration, not a portability guarantee. ToolContext exposes session state, artifacts, memory, authentication, and invocation identity, so it is a capability boundary that needs a platform security review (context documentation).
Confirmation helps, but it is not authorization by itself. ADK supports requireConfirmation = true; dynamic conditions can inspect ToolContext, and advanced confirmation pauses until a serializable response arrives. The confirmation documentation labels the feature experimental and notes limitations. A confirmation prompt is a user or policy decision point, not proof that the underlying credential is safe to use.
Tool selection is also runtime behavior. The Kotlin changelog records context-aware ToolFilter/ToolPredicate, a suspend MCP header provider, and a fix ensuring that only an agent’s own confirmations are resumed. Those details matter when multiple agents, users, or transports share a process.
The repository’s MCP issue is a useful counterexample: the Android support request reports that the Kotlin MCP toolset is under jvmMain and depends on JVM-oriented clients and transports. A tool can have a common schema while its transport is unavailable on Android. The portable abstraction should describe required capabilities and authorization policy; the adapter should decide whether a concrete tool is present, safe, and reachable.
Model Abstraction Does Not Mean Capability Parity
A shared Model/LlmAgent shape is still valuable. It lets orchestration reason about messages, events, tools, and streaming without embedding one vendor SDK everywhere. ADK’s model documentation describes direct model registries, connector wrappers, and runtime routing or failover (model documentation). That is a sensible abstraction boundary.
It is not a promise that models behave alike. The current repository says LiteRT-LM supports tool/function calling on Android and JVM/desktop, Firebase AI supports cloud tool calling, and ML Kit Gemini Nano is Android-only and does not yet support tool calling (backend matrix). The same agent policy may therefore encounter a model that can call tools, one that cannot, one that streams differently, or one that requires a network.
The Android examples add practical constraints: LiteRT-LM receives a model file path and runs offline after acquisition, while Firebase examples require network access. Model packaging, device memory, download/update policy, credentials, and connectivity belong in the adapter. They should not leak into the common agent’s decision logic as a collection of platform checks.
Represent capability explicitly instead:
interface AgentModel { val capabilities: ModelCapabilities
suspend fun generate(request: ModelRequest): ModelResponse}
data class ModelCapabilities( val streaming: Boolean, val toolCalling: Boolean, val offline: Boolean,)This is an architectural sketch, not an ADK API claim. The point is that the common policy can reject or alter a plan when toolCalling is unavailable, while Android and JVM implementations own credential acquisition, inference resources, and stream cancellation. A shared constructor is less useful than a truthful capability contract.
Java Interop Is a Second API Design
Kotlin 1.0’s Java work is substantial. The release lists Java-friendly builders, Publisher/Future examples, Java ports of callbacks, memory, compaction, HITL, and long-running-tool examples, plus reflective construction of tools from Java methods (release notes). That makes adoption possible for existing JVM services.
It does not make Kotlin APIs automatically pleasant from Java. Kotlin properties become getters and setters; top-level functions use generated facade classes unless renamed; default arguments do not become Java overloads unless overloads are generated. These are bytecode facts documented in Calling Kotlin from Java.
The official Android interop guidance adds the practical rules: prefer SAM interfaces over Kotlin function types returning Unit, use @JvmOverloads deliberately, annotate checked exceptions with @Throws, and use @JvmStatic or @JvmField where Java callers expect static members (Android interop guidance). Read-only Kotlin collections also need defensive treatment when a Java caller can mutate the underlying object.
ADK’s examples expose streams through Publisher and RxJava/Reactor adapters, while the Kotlin entry point collects a coroutine Flow. That is the correct shape: a deliberate bridge. Java callers should not be forced to understand suspend, Flow, or Kotlin nullability merely because the implementation is multiplatform.
Design the Java surface as an API, not as generated fallout:
- Offer builders or factories with explicit required values.
- Return
Publisher,CompletionStage, or a blocking method according to the service’s execution model. - Define nullability and error behavior in Java terms.
- Keep coroutine and dispatcher details behind the bridge.
- Test Java invocation of tools, callbacks, streaming, confirmation, and cancellation.
Takeaway: The Portability Test I Would Require
A defensible architecture shares the following: agent policy, event types, orchestration, tool schemas and contracts, and model/session interfaces. It isolates dispatchers, blocking or native inference cancellation, secure credential stores, Room or cloud persistence, MCP transports, model capability declarations, and Java bridges. KMP makes this split explicit: common code can use only APIs available to every declared target, while expect declarations require an actual implementation for each target (KMP project structure, platform APIs).
Before calling the core portable, run the same behavioral matrix on Android and JVM:
| Test | What it proves |
|---|---|
| Cancel a model stream | The adapter stops production and releases resources |
| Cancel during tool I/O | Blocking work and credentials are not left active |
| Kill and restart Android | Session events and pending work recover correctly |
| Replay a long-running tool | Resumption does not duplicate side effects |
| Resume confirmation | Serialized approval is scoped to the right agent and invocation |
| Swap in a no-tool-calling model | Capability mismatch is handled explicitly |
| Invoke through Java | The bridge preserves errors, streams, nullability, and cancellation |
| Attempt an unavailable MCP tool | Transport availability is enforced rather than assumed |
The right claim for ADK Kotlin 1.0 is not “write once, run anywhere.” It is more useful: write the agent’s decisions once, then implement the runtime contracts where Android and JVM genuinely differ. That is still a strong portability story. It just makes the seams part of the design instead of hiding them behind a target list.
References
- ADK for Kotlin v1.0.0 release
- Announcing ADK for Kotlin 1.0
- google/adk-kotlin repository
- ADK Kotlin CHANGELOG
- Cancellation and timeouts
- The basics of Kotlin Multiplatform project structure
- Use platform-specific APIs
- Calling Kotlin from Java
- Kotlin-Java interop guide
- Get action confirmation for ADK Tools
- Agent context
- AI Models for ADK agents
- Session: Tracking individual conversations
- ADK Kotlin Android examples README
- Feature request: Android support for remote MCP via Streamable HTTP