Developer Builds a Kotlin Multiplatform Client for Ollama: Two Serialization and Streaming Bugs
A developer showed how to connect a Kotlin Multiplatform mobile app to a local Ollama server: shared UI in Compose, Koin for dependencies, Ktor for networking. The server is opened to phones on the network with a single variable, OLLAMA_HOST=0.0.0.0. Most of the time went to two subtle bugs: a disappearing stream field and a response that arrives all at once instead of as a stream. Both are fixed with a couple of lines if you know where to look.
AI-processed from Habr AI; edited by Hamidun News
Running a local model on a laptop is easy today. Building a proper mobile client for it is harder: one that works on both Android and iPhone without forcing you to write the UI twice. The author of an article on Habr went down this path with Kotlin Multiplatform and honestly described where he stumbled. For anyone planning to do the same, this is more useful than any theory.
The server: three commands and one variable
Ollama is installed via brew or an installation script on Ubuntu. By default the server listens only on localhost, so an emulator and a real phone on the same network cannot reach it. To open access, the server is started with OLLAMA_HOST=0.0.0.0:11434. Then the model is downloaded with ollama pull, and on Macs with Apple chips the author uses MLX builds, for example gemma4:12b-mlx. The ollama ps command shows that the model sits entirely in GPU memory (the output shows PROCESSOR 100% GPU).
The server can be checked with a plain curl to /api/generate. If the request contains stream: false, the response arrives as a single JSON.
The client: Compose, Koin, Ktor
The project is a KMP app with shared UI: Koin handles dependencies and the ViewModel, Ktor handles networking, and Navigation3 handles navigation. The author chose not the global startKoin but the composable KoinApplication. The container is created inside the App() function, so @Preview in Android Studio does not conflict with global state, and the Swift code generated by the IDE does not need to be touched. The price of this approach: part of the API is unavailable, including by inject(), by viewModel() and androidContext(). How to properly split a large application into modules with this approach, the author has not yet figured out.
Xcode and macOS are still required to build for iOS. KMP frees you from writing UI in Swift, but not from Apple's tooling.
Two traps that cost hours
The first trap involves the serializer. By default, Kotlinx.Serialization does not write fields with default values into JSON. If you have stream = false, the field simply disappears from the request, and without it Ollama enables streaming mode. As a result, you wait for a single response but get a scattering of JSON lines. The fix is the setting Json { encodeDefaults = true }. Network Inspector in Android Studio helped find the cause.
The second trap concerns streaming itself. The response arrived all at once at the end, and the author first suspected buffering in OkHttp and even split the code across platforms via expect/actual. The cause turned out to be something else: instead of post() you need to use preparePost() and read the body line by line via execute and bodyAsChannel. Each line is a separate JSON chunk with the fields message, done and error. In the client all of this is turned into a Flow of events: Token, Thinking, Done and Error. Cancelling the coroutine closes the connection and stops generation. Reasoning tokens (Thinking) come separately, if the model emits them.
One more detail: for /api/chat a role is required for every message, while /api/generate has no roles, just a prompt.
What to consider in practice
If the server runs over HTTP, Android needs usesCleartextTraffic="true", and for iOS you will have to add NSAllowsArbitraryLoads to Info.plist. That said, in the author's experiment the iPhone emulator let requests through even without it. In production, unencrypted HTTP and a server on 0.0.0.0 without authorization must not be used: the text says not a word about security, so this is a home or internal test bench, not a ready-made architecture.
The project is still raw: the interface needs polishing, and there is no chat history. The author adds that a client for cloud.ru cloud models with images and audio has already been written. A task to rename the project took the model about five minutes and roughly 50 rubles, though the price depends heavily on the chosen model.
Takeaway for developers: if you are building a mobile client for Ollama, enable encodeDefaults right away, use preparePost for streams right away, and do not look for the problem in OkHttp. The source code is on GitHub (app-z/OllameChatKmp) and can be used as a starter template.
- Nov 12, 2026n8n Releases Nod, an Open-Source Agent That Runs on Your Computer
- Nov 12, 2026Claude Code and Codex Clamp Down on Reverse Engineering: OpenAI Banned Accounts by Mistake
- Nov 12, 2026SpaceWeb QA Engineers Hand Autotest Writing to AI: A Working Script in 50 Minutes
- Nov 12, 2026MiniMax releases H3-Image weights: 4K images in 10–16 seconds and 16 references
Key Questions Answered
What are the key details on The server: three commands and one variable?
Ollama is installed via brew or an installation script on Ubuntu. By default the server listens only on localhost, so an emulator and a real phone on the same network cannot reach it. To open access, the server is started with OLLAMA_HOST=0.0.0.0:11434. Then the model is downloaded with ollama pull, and on Macs with Apple chips the author uses MLX builds, for example gemma4:12b-mlx. The ollama ps command shows that the model sits entirely in GPU memory (the output shows PROCESSOR 100% GPU).
What are the key details on The client: Compose, Koin, Ktor?
The project is a KMP app with shared UI: Koin handles dependencies and the ViewModel, Ktor handles networking, and Navigation3 handles navigation. The author chose not the global startKoin but the composable KoinApplication. The container is created inside the App() function, so @Preview in Android Studio does not conflict with global state, and the Swift code generated by the IDE does not need to be touched.
What are the key details on Two traps that cost hours?
The first trap involves the serializer. By default, Kotlinx.Serialization does not write fields with default values into JSON. If you have stream = false, the field simply disappears from the request, and without it Ollama enables streaming mode. As a result, you wait for a single response but get a scattering of JSON lines. The fix is the setting Json { encodeDefaults = true }. Network Inspector in Android Studio helped find the cause. The second trap concerns streaming itself.
Test Frontier LLMs & Autonomous Agents in One Workspace
Run Claude Opus 5.5, GPT-6, Gemini 3.1, and DeepSeek side-by-side in Nomad AI (nomadnet.ai) — unified model routing, deep research, and multimodal workflows under one subscription.
The AI world, distilled — once a week
Seven stories that actually mattered, hand-picked. No noise, no reposts, no press releases.
Done! Check your inbox for a confirmation.