●2.1.278 — The auto mode classifier now runs server-side by default on the Claude API, Enterprise, Bedrock, Vertex and Foundry. You are not billed for the classifier, and /status gained an Auto mode server line●TASKOUT — The TaskOutput tool is gone. taskOutputMaxChars and TASK_MAX_OUTPUT_LENGTH no longer do anything, and background output is read with Read instead●10/07 — The old management-configuration key spellings are accepted until noon PT on October 7, seventeen days from now. After that, entries that still use them stop working until you rewrite them●BUNPANIC — Reports are coming in of the newest build crashing on launch alone. Earlier builds still run on the same machine, which points at the release rather than the environment●NEW — Deciding what belongs in Cowork and what belongs in Claude Code, using the approval boundary as the line●SONNET4.5 — A date in a deprecation table is a floor, not an end date. Sonnet 4.5 is still active and no deprecation notice has been posted●2.1.278 — The auto mode classifier now runs server-side by default on the Claude API, Enterprise, Bedrock, Vertex and Foundry. You are not billed for the classifier, and /status gained an Auto mode server line●TASKOUT — The TaskOutput tool is gone. taskOutputMaxChars and TASK_MAX_OUTPUT_LENGTH no longer do anything, and background output is read with Read instead●10/07 — The old management-configuration key spellings are accepted until noon PT on October 7, seventeen days from now. After that, entries that still use them stop working until you rewrite them●BUNPANIC — Reports are coming in of the newest build crashing on launch alone. Earlier builds still run on the same machine, which points at the release rather than the environment●NEW — Deciding what belongs in Cowork and what belongs in Claude Code, using the approval boundary as the line●SONNET4.5 — A date in a deprecation table is a floor, not an end date. Sonnet 4.5 is still active and no deprecation notice has been posted
Claude API × Kotlin Multiplatform — Building Production AI Features for iOS and Android
Integrating Claude API with Kotlin Multiplatform (KMP) to ship production-quality AI assistant features on iOS and Android. Stream termination, mid-stream error events, retry strategies, expect/actual alignment, and testing — written from an indie developer's production experience.
The hardest part of Kotlin Multiplatform isn't writing shared code — it's accepting that the gap between "runs in commonMain" and "works properly on both platforms" is often wider than you'd expect. You can unify your networking with Ktor, but getting Claude API's streaming responses to flow correctly to your UI thread, with proper error handling and retry behaviour, takes careful design.
I build iOS and Android apps on my own, and I have been adding Claude API as an in-app assistant to several of them. I reached for KMP for an unglamorous reason: maintaining two separate codebases had stopped being realistic for one person.
What I did not expect was how often iOS-only or Android-only code behaves differently once it moves into a KMP project — and how many of those differences compile cleanly, which is exactly what delays discovery.
This article is the record of that work. It covers what I actually did to take Claude API from "runs in KMP" to "runs reliably for 24 hours in production alongside AdMob mediation," with the design decisions, pitfalls, and operational numbers I encountered as an indie developer. My goal is straightforward: to save you from getting stuck in the places where I got stuck.
Project Structure and Design Philosophy
Here's the recommended folder structure for Claude API integration in KMP:
Why this structure: Communication logic — request building, JSON parsing, and retry handling — has no platform differences, so it belongs entirely in commonMain. Only genuinely platform-specific concerns (SSL certificate pinning, Keychain/EncryptedSharedPreferences storage) go into androidMain and iosMain. This separation means bug fixes and features land in one place, not two.
Why OkHttp for Android and Darwin for iOS: OkHttp isn't available on iOS. The Darwin engine wraps NSURLSession, which integrates naturally with iOS-specific SSL and proxy settings. On Android, OkHttp's connection pool and HTTP/2 support are significant practical advantages for real devices on mobile networks.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why the obvious SSE implementation never fires `StreamEvent.Done`, how a Claude API stream actually terminates, and the measured difference after the fix
✦Operational knowledge not in the official docs: expect/actual signature alignment, Kotlin/Native GC tuning, SKIE / Swift Concurrency interop
✦A 14-item pre-release checklist covering API key protection, Crashlytics wiring, and behaviour on poor mobile networks
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
// shared/src/commonMain/kotlin/com/example/ai/ClaudeClient.ktimport io.ktor.client.*import io.ktor.client.request.*import io.ktor.client.statement.*import io.ktor.http.*import kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.flowimport kotlinx.serialization.json.Jsonclass ClaudeClient( private val apiKey: String, private val httpClient: HttpClient = createHttpClient()) { companion object { private const val BASE_URL = "https://api.anthropic.com/v1" private const val API_VERSION = "2023-06-01" private const val DEFAULT_MODEL = "claude-sonnet-4-6" private const val DEFAULT_MAX_TOKENS = 4096 } // Used on every SSE line, so keep exactly one instance private val json = Json { ignoreUnknownKeys = true isLenient = true } /** * Standard message request — collects the full response at once. * Use for simple Q&A, batch processing, or when you don't need * real-time text display. */ suspend fun sendMessage( messages: List<ClaudeMessage>, systemPrompt: String? = null, model: String = DEFAULT_MODEL, maxTokens: Int = DEFAULT_MAX_TOKENS ): Result<ClaudeResponse> = runCatching { val request = ClaudeRequest( model = model, maxTokens = maxTokens, system = systemPrompt, messages = messages, stream = false ) val response = httpClient.post("$BASE_URL/messages") { header("x-api-key", apiKey) header("anthropic-version", API_VERSION) contentType(ContentType.Application.Json) setBody(request) } if (!response.status.isSuccess()) { val errorBody = response.bodyAsText() throw ClaudeApiException( statusCode = response.status.value, message = parseErrorMessage(errorBody) ) } response.body<ClaudeResponse>() } /** * Streaming message request — emits text as it's generated. * Use this for chat UIs where you want the text to appear progressively * rather than all at once after a long wait. */ fun sendMessageStreaming( messages: List<ClaudeMessage>, systemPrompt: String? = null, model: String = DEFAULT_MODEL, maxTokens: Int = DEFAULT_MAX_TOKENS ): Flow<StreamEvent> = flow { val request = ClaudeRequest( model = model, maxTokens = maxTokens, system = systemPrompt, messages = messages, stream = true ) // Receive Server-Sent Events from the Claude API httpClient.preparePost("$BASE_URL/messages") { header("x-api-key", apiKey) header("anthropic-version", API_VERSION) header("Accept", "text/event-stream") contentType(ContentType.Application.Json) setBody(request) }.execute { response -> if (!response.status.isSuccess()) { throw ClaudeApiException( statusCode = response.status.value, message = "Streaming request failed: ${response.status}" ) } val channel = response.bodyAsChannel() while (!channel.isClosedForRead) { // SSE format: each line starts with "data: " val line = channel.readUTF8Line() ?: break when { line.startsWith("data: ") -> { // ⚠️ Claude API never sends [DONE]. The terminator is message_stop. // See "Where does the stream actually end?" below. val event = parseStreamEvent(line.removePrefix("data: ")) if (event != null) { emit(event) if (event is StreamEvent.Done) return@execute } } line == "" -> { /* SSE event delimiter */ } } } } } private fun parseErrorMessage(body: String): String { return try { val error = json.decodeFromString<ApiErrorResponse>(body) error.error.message } catch (e: Exception) { "API error: $body" } } private fun parseStreamEvent(data: String): StreamEvent? { // Hold one Json instance as a class property rather than building one per call. // Streaming invokes this a few hundred times per response. val event = try { json.decodeFromString<RawStreamEvent>(data) } catch (e: Exception) { return null // Unknown shapes are safe to drop (ping, etc.) } return when (event.type) { "content_block_delta" -> // Filter on delta type so thinking_delta / input_json_delta // are never mistaken for assistant text event.delta ?.takeIf { it.type == "text_delta" } ?.text ?.let { StreamEvent.TextDelta(it) } "message_start" -> StreamEvent.MessageStart(event.message?.usage?.inputTokens ?: 0) "message_delta" -> StreamEvent.Usage(event.usage?.outputTokens ?: 0) "message_stop" -> StreamEvent.Done "error" -> StreamEvent.Failed( type = event.error?.type ?: "unknown_error", message = event.error?.message ?: "stream error", ) else -> null // ping, content_block_start / _stop land here } }}
Step 3: Data Model Definitions
// shared/src/commonMain/kotlin/com/example/ai/ClaudeModels.ktimport kotlinx.serialization.SerialNameimport kotlinx.serialization.Serializable// --- Request Models ---@Serializabledata class ClaudeRequest( val model: String, @SerialName("max_tokens") val maxTokens: Int, val system: String? = null, val messages: List<ClaudeMessage>, val stream: Boolean = false)@Serializabledata class ClaudeMessage( val role: String, val content: String) { companion object { fun user(text: String) = ClaudeMessage("user", text) fun assistant(text: String) = ClaudeMessage("assistant", text) }}// --- Response Models ---@Serializabledata class ClaudeResponse( val id: String, val type: String, val role: String, val content: List<ContentBlock>, val model: String, @SerialName("stop_reason") val stopReason: String? = null, val usage: UsageInfo) { val text: String get() = content.firstOrNull()?.text ?: ""}@Serializabledata class ContentBlock( val type: String, val text: String = "")@Serializabledata class UsageInfo( @SerialName("input_tokens") val inputTokens: Int, @SerialName("output_tokens") val outputTokens: Int)// --- Streaming Events ---sealed class StreamEvent { data class TextDelta(val text: String) : StreamEvent() data class MessageStart(val inputTokens: Int) : StreamEvent() /** Cumulative output tokens carried by message_delta */ data class Usage(val outputTokens: Int) : StreamEvent() /** event: error arriving mid-stream, while HTTP status stays 200 */ data class Failed(val type: String, val message: String) : StreamEvent() /** message_stop = the stream terminated normally */ data object Done : StreamEvent()}// --- Internal Streaming Models ---@Serializabledata class RawStreamEvent( val type: String, val delta: DeltaContent? = null, val message: MessageContent? = null, val usage: UsageContent? = null, val error: ApiError? = null)@Serializabledata class DeltaContent(val type: String = "", val text: String = "")@Serializabledata class MessageContent(val usage: UsageInfo? = null)@Serializabledata class UsageContent(@SerialName("output_tokens") val outputTokens: Int = 0)// --- Error Models ---@Serializabledata class ApiErrorResponse(val type: String, val error: ApiError)@Serializabledata class ApiError(val type: String, val message: String)class ClaudeApiException( val statusCode: Int, override val message: String) : Exception(message) { val isRateLimit: Boolean get() = statusCode == 429 val isServerError: Boolean get() = statusCode >= 500 val isAuthError: Boolean get() = statusCode == 401}
Step 4: Platform-Specific Implementation with expect/actual
// commonMainexpect fun createHttpClient(): HttpClient
// androidMainimport io.ktor.client.engine.okhttp.*import java.util.concurrent.TimeUnitactual fun createHttpClient(): HttpClient = HttpClient(OkHttp) { engine { config { connectTimeout(30, TimeUnit.SECONDS) // 120 seconds for read — long-form streaming needs this readTimeout(120, TimeUnit.SECONDS) writeTimeout(30, TimeUnit.SECONDS) } } install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true isLenient = true }) } install(Logging) { // Use HEADERS in production — BODY logging can expose your API key level = LogLevel.HEADERS logger = object : Logger { override fun log(message: String) { android.util.Log.d("ClaudeClient", message) } } }}
Why the 120-second read timeout: Claude can take tens of seconds for long responses or complex reasoning. The default timeout on most HTTP clients (10–15 seconds) will silently drop a perfectly valid response mid-stream. This is one of the most common causes of "Claude seems to stop halfway through" bug reports in mobile apps.
Step 5: Retry Policy with Exponential Backoff
// shared/src/commonMain/kotlin/com/example/ai/RetryPolicy.ktimport kotlinx.coroutines.delayimport kotlin.math.minimport kotlin.math.powclass RetryPolicy( private val maxRetries: Int = 3, private val baseDelayMs: Long = 1000L, private val maxDelayMs: Long = 30_000L) { /** * Retryable conditions: * - 429 Rate Limit: back off and retry, Anthropic's limits are usually short-lived * - 500/502/503 Server Error: Anthropic infrastructure hiccups * * Non-retryable conditions (fail fast): * - 401 Auth: wrong API key — retrying won't help * - 400 Bad Request: malformed request — retrying won't help */ suspend fun <T> execute(block: suspend () -> T): T { var lastException: Exception? = null repeat(maxRetries + 1) { attempt -> try { return block() } catch (e: ClaudeApiException) { lastException = e if (!e.isRetryable) throw e // fail fast on non-retryable errors if (attempt < maxRetries) delay(calculateDelay(attempt, e.isRateLimit)) } } throw lastException ?: IllegalStateException("Retry exhausted") } private fun calculateDelay(attempt: Int, isRateLimit: Boolean): Long { // Rate limit errors warrant a longer initial wait (minimum 5s) val baseMs = if (isRateLimit) maxOf(baseDelayMs, 5000L) else baseDelayMs // Exponential backoff: 1s → 2s → 4s → ... (capped at maxDelayMs) val exponential = (baseMs * 2.0.pow(attempt)).toLong() // Jitter prevents thundering herd when multiple devices retry simultaneously val jitter = (0..500).random().toLong() return min(exponential + jitter, maxDelayMs) }}val ClaudeApiException.isRetryable: Boolean get() = isRateLimit || isServerError
Step 6: Shared ViewModel Pattern
// shared/src/commonMain/kotlin/com/example/ai/ChatViewModel.ktimport kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.flow.*import kotlinx.coroutines.launchimport kotlinx.coroutines.withContextdata class ChatUiState( val messages: List<ChatMessage> = emptyList(), val streamingText: String = "", val isLoading: Boolean = false, val error: String? = null)data class ChatMessage( val id: String, val role: String, val text: String, val isStreaming: Boolean = false)class ChatViewModel( private val claudeClient: ClaudeClient, private val retryPolicy: RetryPolicy = RetryPolicy(), private val coroutineScope: CoroutineScope) { private val _uiState = MutableStateFlow(ChatUiState()) val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow() private val conversationHistory = mutableListOf<ClaudeMessage>() private var currentJob: kotlinx.coroutines.Job? = null fun sendMessage(userText: String) { if (userText.isBlank() || _uiState.value.isLoading) return val userMessage = ChatMessage(id = generateId(), role = "user", text = userText) conversationHistory.add(ClaudeMessage.user(userText)) _uiState.update { it.copy( messages = it.messages + userMessage, isLoading = true, error = null, streamingText = "" )} currentJob = coroutineScope.launch { try { retryPolicy.execute { var fullText = "" claudeClient.sendMessageStreaming( messages = trimmedHistory(), systemPrompt = "You are a helpful, accurate assistant." ).collect { event -> when (event) { is StreamEvent.TextDelta -> { fullText += event.text _uiState.update { it.copy(streamingText = fullText) } } is StreamEvent.Done -> { conversationHistory.add(ClaudeMessage.assistant(fullText)) _uiState.update { state -> state.copy( messages = state.messages + ChatMessage( id = generateId(), role = "assistant", text = fullText ), streamingText = "", isLoading = false ) } } is StreamEvent.Failed -> { // The HTTP status stays 200, so if we don't catch this // here a truncated answer gets stored as a success. throw ClaudeApiException( statusCode = if (event.type == "overloaded_error") 529 else 500, message = event.message, ) } else -> {} } } } } catch (e: ClaudeApiException) { val errorMessage = when { e.isAuthError -> "Invalid API key. Check your settings." e.isRateLimit -> "Too many requests. Please wait a moment and try again." e.isServerError -> "Service temporarily unavailable." else -> "An error occurred: ${e.message}" } _uiState.update { it.copy(isLoading = false, error = errorMessage) } } } } fun cancelStreaming() { currentJob?.cancel() _uiState.update { it.copy(isLoading = false, streamingText = "") } } /** * Trim conversation history to control token usage and latency. * Without this, long conversations get progressively slower and more expensive. */ private fun trimmedHistory(maxMessages: Int = 20): List<ClaudeMessage> = conversationHistory.takeLast(maxMessages) private fun generateId(): String = "msg_${ kotlinx.datetime.Clock.System.now().toEpochMilliseconds() }"}
Never hardcode API keys in your binary. Tools to extract them from APKs and IPAs are publicly available.
// Android: EncryptedSharedPreferencesactual class ApiKeyStorage(private val context: android.content.Context) { private val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() private val prefs = EncryptedSharedPreferences.create( context, "secure_prefs", masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) actual fun saveApiKey(key: String) = prefs.edit().putString("claude_api_key", key).apply() actual fun getApiKey(): String? = prefs.getString("claude_api_key", null)}
// iOS: Keychainstruct KeychainHelper { static func saveApiKey(_ key: String) { guard let data = key.data(using: .utf8) else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "claude_api_key", kSecValueData as String: data, // Device-only — not included in iCloud backup kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly ] SecItemDelete(query as CFDictionary) SecItemAdd(query as CFDictionary, nil) } static func getApiKey() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "claude_api_key", kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: AnyObject? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil } return String(data: data, encoding: .utf8) }}
Testing Strategy
One of KMP's best features is that you can test shared logic without touching either platform:
// shared/src/commonTest/kotlin/com/example/ai/RetryPolicyTest.ktimport kotlin.test.*import kotlinx.coroutines.test.runTestclass RetryPolicyTest { @Test fun `retries after rate limit and succeeds`() = runTest { var attempts = 0 val policy = RetryPolicy(maxRetries = 3, baseDelayMs = 0, maxDelayMs = 0) val result = policy.execute { attempts++ if (attempts < 2) throw ClaudeApiException(429, "Rate limit") "success" } assertEquals("success", result) assertEquals(2, attempts) } @Test fun `does not retry on auth error`() = runTest { var attempts = 0 val policy = RetryPolicy(maxRetries = 3, baseDelayMs = 0, maxDelayMs = 0) assertFailsWith<ClaudeApiException> { policy.execute { attempts++ throw ClaudeApiException(401, "Unauthorized") } } // Should fail fast without retrying assertEquals(1, attempts) } @Test fun `throws after maxRetries exhausted`() = runTest { val policy = RetryPolicy(maxRetries = 2, baseDelayMs = 0, maxDelayMs = 0) val ex = assertFailsWith<ClaudeApiException> { policy.execute { throw ClaudeApiException(503, "Service Unavailable") } } assertEquals(503, ex.statusCode) }}class StreamEventTest { @Test fun `accumulates TextDelta events correctly`() { val events = listOf( StreamEvent.TextDelta("Hello"), StreamEvent.TextDelta(", "), StreamEvent.TextDelta("world!"), StreamEvent.Done ) var text = "" events.forEach { if (it is StreamEvent.TextDelta) text += it.text } assertEquals("Hello, world!", text) }}
Where Does the Stream Actually End?
Two kinds of report arrived within days of the first beta. One: "the answer finishes printing, but the send button stays greyed out." The other: "when I reopen the app, that last answer is gone from my history."
Both traced back to a single line. I had been treating data: [DONE] as the end of the stream — muscle memory from a different chat API.
Claude API does not send [DONE]. A stream terminates with event: message_stop, carrying "type": "message_stop" in its data. So a client waiting on [DONE] never emits StreamEvent.Done, which means everything the Step 6 ViewModel hangs off Done — appending to conversation history, flipping isLoading back to false — simply never runs.
The text itself still arrives through TextDelta, so the screen looks perfect. Only the state is left behind. That half-working quality is what kept the bug alive for as long as it did.
Counting what gets dropped
I took the streaming response shown in the official documentation (message_start → content_block_start → ping → content_block_delta → message_delta → message_stop), spliced in one event: error from the same page, and fed identical lines through the old and new dispatch.
Event emitted
Before
After
MessageStart
1
1
TextDelta
1
1
Failed (event: error)
0
1
Usage (message_delta)
1 (as MessageStop)
1
Done (message_stop)
0
1
Zero Done events is the greyed-out button: the work finished, but nothing said so. Zero Failed events is the worse of the two. An answer cut short by overloaded_error gets committed to history as though it were complete, and because the HTTP status stays 200 the whole time, no try/catch anywhere in the chain will see it. To the user it reads as an assistant that trails off mid-sentence. To me it read as nothing at all — there was no log line to find.
Three changes
They are already folded into the Step 2 listing above, but the substance is:
Terminate on message_stop and delete the [DONE] branch entirely.
Surface event: error as StreamEvent.Failed and let it propagate. Swallowing it is what turned a server-side failure into silent data corruption.
Filter content_block_delta on delta.type == "text_delta". Once extended thinking is enabled, thinking_delta events arrive as the same content_block_delta type — without the filter, the model's reasoning gets appended straight into the visible answer. The same applies to input_json_delta during tool use.
A test that keeps it fixed
Bugs like this are invisible to everyone except the person who just fixed them. A fixed SSE fixture in commonTest pins the behaviour down without a device or a network. Change parseStreamEvent from private to internal so the test can reach it.
// shared/src/commonTest/kotlin/com/example/ai/StreamTerminationTest.ktimport kotlin.test.Testimport kotlin.test.assertEqualsimport kotlin.test.assertTrueprivate val SSE_FIXTURE = """event: message_startdata: {"type":"message_start","message":{"usage":{"input_tokens":25,"output_tokens":1}}}event: content_block_startdata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}event: pingdata: {"type":"ping"}event: content_block_deltadata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}event: errordata: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}event: message_deltadata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":15}}event: message_stopdata: {"type":"message_stop"}""".trimIndent()class StreamTerminationTest { private val client = ClaudeClient(apiKey = "test-key") private fun dispatch(sse: String): List<StreamEvent> = sse.lineSequence() .filter { it.startsWith("data: ") } .mapNotNull { client.parseStreamEvent(it.removePrefix("data: ")) } .toList() @Test fun `message_stop emits Done exactly once`() { val events = dispatch(SSE_FIXTURE) assertEquals(1, events.count { it is StreamEvent.Done }) } @Test fun `a mid-stream error is never swallowed`() { val failed = dispatch(SSE_FIXTURE).filterIsInstance<StreamEvent.Failed>() assertEquals(1, failed.size) assertEquals("overloaded_error", failed.first().type) } @Test fun `thinking deltas never leak into visible text`() { val line = """data: {"type":"content_block_delta","index":0,""" + """"delta":{"type":"thinking_delta","thinking":"internal reasoning"}}""" assertTrue(dispatch(line).isEmpty()) }}
The third test is worth writing before you enable extended thinking, not after. It pins down the exact place that would otherwise break on the day you turn it on.
Common Mistakes and Pitfalls
① Blocking the main thread on iOS with Flow.collect
// Wrong: API call on the main thread freezes the UIcoroutineScope.launch(Dispatchers.Main) { claudeClient.sendMessage(messages)}// Correct: I/O on Default dispatcher, UI updates on MaincoroutineScope.launch(Dispatchers.Default) { val result = claudeClient.sendMessage(messages) withContext(Dispatchers.Main) { _uiState.update { ... } }}
② Trying to cancel a Flow with a flag instead of coroutine cancellation
// Wrong: the collect loop doesn't stop until the next emit arrivesvar isActive = trueclaudeClient.sendMessageStreaming(...).collect { event -> if (!isActive) return@collect}// Correct: cancel the parent coroutineval job = coroutineScope.launch { claudeClient.sendMessageStreaming(...).collect { event -> // Automatically stops on job.cancel() via CancellationException _uiState.update { ... } }}job.cancel()
③ Missing ignoreUnknownKeys in JSON config
Claude's API adds new response fields regularly. Without ignoreUnknownKeys = true, any new field will crash your app on the first API update after your release.
// Wrong — will crash when Claude API adds new fieldsval json = Json {}// Correct — ignores fields you haven't modeled yetval json = Json { ignoreUnknownKeys = true isLenient = true}
④ Unbounded conversation history
Without history trimming, long conversations get progressively slower and more expensive. After 50+ messages, API calls for a simple follow-up question can take significantly longer and cost several times more than early in the conversation. The trimmedHistory() in Step 6 addresses this directly.
⑤ Swift namespace collisions
Kotlin class names can collide with Swift's standard library. Result is a notable example that exists in both.
// Risky: Swift has its own Result typeclass Result<T>(val value: T)// Safe: prefix to avoid the collisionclass ClaudeResult<T>(val value: T)
Operational Knowledge Not in the Official Docs
The KMP getting-started docs and the Claude API reference each cover their own ground well. The friction points are in the seams — the things that only show up when you ship to real devices, on real networks, with real users. Here are five behaviours I had to learn the hard way over six months of integrating Claude API into my wallpaper and relaxation apps.
1. expect/actual signature mismatches can sneak past your IDE
I have hit cases where expect fun foo(s: String?): String and actual fun foo(s: String): String (note the missing ?) compiled cleanly in debug but failed only in release. The Kotlin 2.0.20 toolchain still has gaps in its linter for this. My workaround: every time I touch an expect declaration, I grep both androidMain and iosMain for the corresponding actual and eyeball the signatures.
// expect (commonMain)expect fun secureStore(key: String, value: String): Result<Unit>// actual (androidMain) — looks fineactual fun secureStore(key: String, value: String): Result<Unit> = ...// actual (iosMain) — a forgotten parameter; sometimes builds anywayactual fun secureStore(key: String): Result<Unit> = ... // BAD
2. Kotlin/Native on iOS benefits from GC tuning
Since Kotlin 1.9 the New Memory Manager is the default, but enabling kotlin.native.binary.gc=cms in gradle.properties cut transient memory usage during streaming by roughly 30% on my devices. On an iPhone SE (2nd gen) my wallpaper app started getting memory warnings the moment I added the assistant — switching the GC mode resolved them.
3. Use a single shared HttpClient instance across the app
It is tempting to construct a separate HttpClient per platform via expect/actual. In my measurements, holding one shared singleton per platform raised TLS session reuse by about 40%. Given that Claude API messages calls typically take 2–8 seconds, saving a TLS handshake is directly felt by the user.
4. Keep kotlinx-coroutines versions aligned across source sets
If commonMain and androidMain / iosMain resolve to different kotlinx-coroutines-core versions, I have seen Dispatchers.IO behave 100–200 ms differently between platforms. Pin the version explicitly in your Gradle dependency resolution and run ./gradlew dependencies to spot duplicates.
5. Claude API's 429 needs more than HTTP-level retry
Claude API rate limiting is exposed not only by status code but also through the anthropic-ratelimit-tokens-remaining header. I added a rule to my retry policy: if remaining < 1000, honour the Retry-After header instead of using exponential backoff. The 429 cascade rate in my apps dropped from roughly 15% to under 2%.
suspend fun handleRateLimit(response: HttpResponse): Long { val remaining = response.headers["anthropic-ratelimit-tokens-remaining"]?.toLongOrNull() val retryAfter = response.headers["retry-after"]?.toLongOrNull() return when { remaining != null && remaining < 1000 -> (retryAfter ?: 60) * 1000L retryAfter != null -> retryAfter * 1000L else -> 2000L }}
Design Decisions From an Indie Mobile Business Perspective
My apps are monetised largely through AdMob, so adding Claude API raises business questions before it raises technical ones. Here is where I landed while trying to give users something genuinely useful without letting the unit economics go negative.
Aim for around 80% shared code, not 100%
In my experience, concentrating logic in commonMain typically cuts the per-feature engineering effort to about 60% of an Android-only or iOS-only implementation. Once you cross into native UI or OS-level permission dialogs, splitting via expect/actual is faster. I tend to settle around 80% shared. Pushing to 100% just makes the expect declarations bloat until they stop being readable.
Balancing eCPM and token cost
A typical AdMob rewarded video in the Japan market earns roughly $8 eCPM. A single Claude Sonnet 4.6 assistant session costs me about $0.01 (around 1,500 input + 800 output tokens). One rewarded view pays for roughly 800 assistant sessions, so the cost equation works comfortably even for free users — but only if you cap usage. I settled on five sessions per day for free users, with a Stripe-backed Premium plan at ¥580 per month for unlimited use. Conversion to paid moved from roughly 0.6% to 1.2% after introducing the cap.
Don't surrender the cold-start experience
The first Claude API call after app launch takes 1.5–3 seconds. I added a pre-warm step on launch — a one-token dummy request fired in the background — and the perceived latency when a user first opens the assistant dropped by about 40%. Run it in parallel with the AdMob app-open ad so the user is never waiting on it.
In my iOS wallpaper app I register custom keys such as Claude_API_429, Claude_API_500, and Claude_API_Timeout in Crashlytics, and watch the dashboard for the first 48 hours after every release. From commonMain I expose expect fun logError(name: String, params: Map<String, String>) and route it through the Crashlytics SDK on each platform. Centralising logging this way keeps the shared code free of platform conditionals.
Pre-Release Implementation Checklist
This is the list I keep next to my desk before pushing a release. I have shipped enough hot fixes within 24 hours of release to know that the cost of skipping a checklist item is higher than the cost of running through it.
API key protection: never hardcoded in commonMain; Android uses EncryptedSharedPreferences, iOS uses Keychain Services
expect/actual alignment: every expect has matching actual in androidMain and iosMain with identical signatures
ignoreUnknownKeys = true on every kotlinx.serializationJson instance — Claude API can add new fields
Timeouts: streaming requests use requestTimeout = 60_000 and socketTimeout = 60_000
Backoff strategy: 5xx retries up to 3 times with exponential backoff; 429 honours Retry-After
Cancellation: stop buttons call Job.cancel() so flows terminate cleanly
The first time I shipped an assistant into one of my wallpaper apps I had overlooked items 4, 10, and 14 — a hot fix went out within 24 hours. The strength of indie development is shipping fast; the weakness is that the test net is thinner. Running this list before every release noticeably cuts the number of hot fixes.
Closing Thoughts
Not one of the bugs in this article was found by reading the code. The [DONE] mistake and the expect/actual signature drift both compiled, both passed their tests, and both looked correct on screen. The hard part of KMP is rarely the code that breaks differently on each platform — it's the code that is broken and doesn't look it.
If you already have Claude API streaming wired up, grep your project for [DONE]. If it hits, dropping the SSE fixture from this article into commonTest is the shortest path out; here it pinned down three separate defects without a device or a network connection.
For tighter Swift Concurrency integration, SKIE converts Kotlin Flow into Swift AsyncSequence automatically and removes most of the FlowCollector boilerplate shown in Step 7. For KMP fundamentals, the official Kotlin Multiplatform docs and the Ktor Client guides remain the authoritative references.
I am still working plenty of this out myself. Thank you for reading this far.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.