Ollama server setup
Installing Ollama server
brew install ollama#curl -fsSL https://ollama.com/install.sh | sh
Start the server. If the server needs to be accessible not only on the local machine, but also on an emulator or from any phone on the network, add: OLLAMA_HOST=0.0.0.0. The server can then be accessed via IP
OLLAMA_HOST=0.0.0.0:11434 ollama serveDownload models. For example, gemma4:12b
ollama pull gemma4:12bCheck that the server is running and the model is available. For a MacBook with an ARM architecture, download the MLX model gemma4:12b-mlx. PROCESSOR 100% GPU means that the entire model is loaded into GPU memory, and all computations (token generation) run on the GPU, not the CPU.
ollama show gemma4:12b-mlxRunning model:
#ollama psNAME(同上)? Wait no, must not introduce Chinese. Need preserve exact output header. Input has `NAMEregisterspaces ID ... SIZE PROCESSOR CONTEXT RUNNER UNTIL`. We should keep exactly. Let's reconstruct from text:
NAMEregister spaces ID register spaces SIZE register spaces PROCESSOR register spaces CONTEXT register spaces RUNNER register spaces UNTIL register spaces
Could output same raw spaces. Need not translate output. Continue.gemma4:e4b-mlx 5a08735df03c 9.4 GB 100% GPU 4096 mlx 4 minutes from now
... List heading translate. Let's ensure no accidental alteration. We can simply paste output lines. Need perhaps translation "MLX models" based Russian "cкачиваем mlx модели.gemma..." There is no space after модели due text, but likely sentence malformed. Faithful semantic: "For a MacBook with ARM architecture, we download MLX models. gemma..." Could translate "For a MacBook with an ARM architecture, download the MLX model gemma4:12b-mlx." Good. "PROCESSOR 100% GPU means..." Keep field names. Output only.Ollama server setup
Installing Ollama server
brew install ollamaRunning model:
#ollama psNAME registrant spaces ID registrant spaces SIZE registrant spaces PROCESSOR registrant spaces CONTEXT registrant spaces RUNNER registrant spaces UNTIL registrant spaces
List of all installed models:
#ollama listTest an actual request from the local network
#curl -s http://127.0.0.1:11434/api/generate -d '{ "model": "gemma4:e4b-mlx", "prompt": "Hello", "stream": false }'Or like this:
ollama run gemma4:12b "Explain what Kotlin is"Android Studio Agent
You can try connecting the Agent in Android Studio

Android/iOS client ollama
Everything is set up. Moving on to the mobile client. We create a new KMP project with a shared UI.

We choose Koin as the DI. The UI is shared between the two platforms. We keep it as simple as possible. Koin also supports ViewModel for KMP out of the box. Ktor is used for network requests.
In KMP, you can use the global startKoin, but koinApplication is more convenient for Compose Multiplatform. It creates a local container directly inside the App() Composable function.This prevents conflicts when using @Preview in Android Studio, guarantees the correct lifecycle, and allows you to avoid changes to Swift code for iOS.
Differences between koinApplication and startKoin:
koinApplication returns the container (and its scope) only to you; startKoin also returns it to the global GlobalContext, making scopes visible and alive at the process-wide level. Everything else is a consequence of this single distinction
The scope is “returned”startKoin (global) | KoinApplication (local) | |
|---|---|---|
koinInject<T>() | ✅ | ✅ |
✅ | ✅ | |
by inject() | ✅ | ❌ |
by viewModel() | ✅ | ❌ |
get<T>() (top-level) | ✅ | ❌ |
getKoin() | ✅ | ❌ |
KoinComponent | ✅ | ❌ |
androidContext() | ✅ | ❌ (startKoin required) |
Common entry point in the application:
@Composable@Preview fun App( appDeclaration: KoinAppDeclaration = {} ) { KoinApplication(configuration = koinConfiguration { modules( viewModelModule, commonModule ) }) { MaterialTheme { RootScreen() } } }This is sufficient for an example. Another advantage is that there is no need to modify Swift code (for example, ContentView.swift) generated by Android Studio. We write the code in Android Studio and launch it from there on Android and iPhone.
However, building for iOS will still require Xcode and macOS. KMP frees us from writing the UI in Swift, but does not eliminate the need for Apple tools. In addition, if we need to configure Info.plist (for example, add NSAllowsArbitraryLoads for HTTP) or integrate native SDKs, we will still have to use Xcode.
It is worth noting that KoinApplication may have its own pitfalls. In particular, I have not yet figured out how to properly modularize a large application in this architecture. Another open question is whether it is sufficient to use only koinInject() with Compose, or whether the classic inject() will still be required somewhere. I would appreciate it if someone shared their experience in the comments.
The approach of calling initKoin from iOS and Android:
initKoin()Since our server uses http instead of https, don’t forget usesCleartextTraffic. By the way, the iPhone emulator allowed access without any changes. I think I added NSAllowsArbitraryLoads.
usesCleartextTrafficusesCleartextTraffic is an attribute in AndroidManifest.xml (starting from API 23) that determines whether the app allows unencrypted HTTP traffic (cleartext)
iPhone
<key>NSAppTransportSecurity</key> <dict> <key>NSAllowsArbitraryLoads</key> <true/> </dict>Next, I added Navigation3. A good article on Navigation3 + Koin in Compose Multiplatform. I liked the comments discussing whether the latest state of the screens should be stored.
SavedStateConfigurationencodeDefaults
It is worth mentioning the two Ollama response modes - streaming and non-streaming
In Ollama, this is controlled by the stream field in requests to /api/generate and /api/chat. Do not set stream = false by default! The serializer will omit this field if you set it to false. Ollama will return a stream by default, which will result in an unexpected outcome. This can be fixed through the serializer settings, but I spent time figuring out where stream had gone in the request. View->Tool Windows->App Inspector->Network Inspector comes to the rescue.
OllamaRequest(model = settings.getCurrentModel().id, prompt = prompt, stream = false)Request model:
@Serializable data class OllamaRequest( val model: String, val prompt: String, val stream: Boolean // do not assign false here )TAG314@@“By default, Kotlinx.Serialization does not serialize fields whose values match the default values (in this case, false). Ollama, not finding an explicit stream: false in the JSON, enables streaming mode by default. The encodeDefaults = true setting saves the situation.”TAG330@@If you make a request to the chat, you need to specify a role.In /api/chat, the role is required for every message. In /api/generate, there are no roles at all—there is simply a prompt
The magic is herepackage com.kmp.ollamechat.repository import com.example.chatkmpdontshareui.repository.model.OllamaRequest import com.example.chatkmpdontshareui.repository.model.OllamaResponse import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType class OllamaClient( private val settings: Settings, private val client: HttpClient ) { suspend fun sendPrompt(serverUrl: String, prompt: String): Result<String> { return try { val response: OllamaResponse = client.post("$serverUrl/api/generate") { contentType(ContentType.Application.Json) // Insert the request object. Ktor serializes it to JSON automatically. setBody( OllamaRequest( model = settings.getCurrentModel().id, prompt = prompt, stream = false ) ) // If you are using Ollama Cloud, uncomment: // header(HttpHeaders.Authorization, "Bearer YOUR_KEY") }.body() // Receive and deserialize the response Result.success(response.response ?: "Unknown Error") } catch (e: Exception) { Result.failure(e) } } }Stream responsesFlow MagicHello
{"model":"gemma4:e4b-mlx","created_at":"2026-11-09T03:41:29.399462Z","message":{"role":"assistant","content":"Hello"},"done":false}! How can I help? 😊package com.kmp.ollamechat.client import com.kmp.ollamechat.chat.ChatRequest import com.kmp.ollamechat.chat.OllamaChatChunk import com.kmp.ollamechat.chat.OllamaChatRequest import com.kmp.ollamechat.repository.Settings import com.kmp.ollamechat.repository.model.StreamOllamaEvent import io.ktor.client.HttpClient import io.ktor.client.plugins.ResponseException import io.ktor.client.request.preparePost import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsChannel import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.utils.io.readLine import kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.flow import kotlinx.serialization.json.Json /** * streamChat() * Streaming request to /api/chat. * Returns Flow: tokens as they are generated + Done/Error. * The Flow is cancellable—cancelling the coroutine closes the connection and stops generation. */ class StreamOllamaClient( private val settings: Settings, private val client: HttpClient, private val json: Json ) { fun streamChat( serverUrl: String, messages: List , modelId: String, ): Flow = flow { // Variable for tracking the final status: SUCCESS, ERROR, or PREMATURE_CLOSE var finalEvent: StreamOllamaEvent? = null try { client.preparePost("$serverUrl/api/chat") { contentType(ContentType.Application.Json) setBody( OllamaChatRequest( model = modelId, messages = messages, keepAlive = "10m", stream = true ) ) }.execute { response -> val channel = response.bodyAsChannel() while (!channel.isClosedForRead) { val line = channel.readLine() ?: break if (line.isBlank()) continue val chunk = try { json.decodeFromString (line) } catch (e: Exception) { finalEvent = StreamOllamaEvent.Error("Failed to parse chunk: $line", e) break // Just exit the loop; execution will continue below } if (chunk.error != null) {finalEvent = StreamOllamaEvent.Error(chunk.error) break } val msg = chunk.message msg?.thinking?.takeIf { it.isNotEmpty() }?.let { emit(StreamOllamaEvent.Thinking(it)) } msg?.content?.takeIf { it.isNotEmpty() }?.let { emit(StreamOllamaEvent.Token(it)) } if (chunk.done) { finalEvent = StreamOllamaEvent.Done(chunk.evalCount) break } } // If the loop was interrupted but finalEvent was not set (for example, the server closed the channel prematurely) val eventToSend = finalEvent ?: StreamOllamaEvent.Done(null) emit(eventToSend) } } catch (e: ResponseException) { emit(StreamOllamaEvent.Error("HTTP ${e.response.status.value}", e)) } catch (e: Exception) { emit(StreamOllamaEvent.Error(e.message ?: "Unknown error", e)) } } } On screen, it looks like this:
Streaming responseThe streaming response did not work right away. I quickly found out that the data was being sent all at once rather than in chunks. Everything was updated at the very end of the response. I spent a long time trying to solve it. Several hours, actually. At first, I thought OkHttp was buffering the data. I remembered expect/actual and split the implementation by platform. That did not help.
Turned out I was using the wrong method: instead of post(), I needed to use preparePost(). Although post() also has execute() under the hood.
After switching to preparePost(), the output started working.
In addition to the main response, the Thinking response is also processed.
View the code on Git:
https://github.com/app-z/OllameChatKmp
The UI needs some polishing, and chat history saving needs to be added.
All that’s left is to feed the model the company’s documentation—and the perfect AI support chatbot is ready. Well, almost...
I also wrote a client for cloud.ru’s cloud models (with image and audio uploading). Though, that’s a whole other story. The main thing to know is that cloud tokens burn very quickly. But the speed is also impressive—for a project renaming task, the model took only 5 minutes and about 50 rubles. The final cost and speed depend directly on the selected model.
Write!
