Ollama server setup

Installing Ollama server

brew install ollama
Ubuntu

#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 serve

Download models. For example, gemma4:12b

ollama pull gemma4:12b

Check 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-mlx

Running model:

#ollama ps

NAME(同上)? 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 ollama
Ubuntu
OLLAMA_HOST=0.0.0.0:11434 ollama serve
ollama pull gemma4:12b
ollama show gemma4:12b-mlx

Running model:

#ollama ps

NAME 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 list
NAME registrant spaces ID registrant spaces SIZE registrant spaces MODIFIED registrant spaces nomic-embed-text:latest 0a109f422b47 274 MB 24 hours ago qwen3.5:9b-mlx 203e30078279 8.9 GB 2 days ago   gemma4:12b-mlx ded7a2735003 7.7 GB 2 days ago  gemma4:e4b-mlx 5a08735df03c 9.5 GB 2 days ago

Test 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 }'
Hello
{ "model": "gemma4:e4b-mlx", "created_at": "2026-11-06T18:05:23.518505Z", "response": "Hello! 😊 How can I help you?", "done": true, "done_reason": "stop", "context": [2,105,9731,...], "total_duration": 269059958, "load_duration": 638667, "prompt_eval_count": 18, "prompt_eval_cached_count": 17, "prompt_eval_duration": 9167, "eval_count": 8, "eval_duration": 266089083 }

Or like this:

ollama run gemma4:12b "Explain what Kotlin is"

Android Studio Agent

You can try connecting the Agent in Android Studio

ollama AI Agent Android Studio
ollama AI Agent 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.

Новый проект
New project

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”
// koinApplication — the scope is “returned” only to you val koinApp = koinApplication { modules(appModule) } val sessionScope = koinApp.koin.createScope("session") // no one other than this code sees sessionScope sessionScope.close() koinApp.close() // everything is dead // startKoin — the scope is “returned” to GlobalContext startKoin { modules(appModule) } val sessionScope = GlobalContext.get().createScope("session") // now the scope is visible to the entire process through GlobalContextstopKoin() // everything is dead
API availability: startKoin (global) vs composable KoinApplication (local)
TAG129@@

koinViewModel<T>()

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()
fun MainViewController() = ComposeUIViewController( configure = { initKoin() } ) { App() }
// android:name=".MyApp" class MyApp : Application(), KoinComponent { override fun onCreate() { super.onCreate() initKoin { androidLogger() androidContext(this@MyApp) } } }
fun initKoin(appDeclaration: KoinAppDeclaration = {}) = startKoin { appDeclaration() modules( databaseModule, networkModule, repositoryModule, viewmodelModule ) }

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.

usesCleartextTraffic
android:usesCleartextTraffic="true"

usesCleartextTraffic 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.

SavedStateConfiguration
val config = remember { SavedStateConfiguration { serializersModule = SerializersModule { polymorphic(NavKey::class) { subclassesOfSealed<Route>() } } } } val backStack = rememberNavBackStack(config, HomeRoute) // Current route (last in the stack) — for the title / bottom bar val currentRoute: NavKey? = backStack.lastOrNull()

encodeDefaults

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)
TAG300@@

Request model:

@Serializable data class OllamaRequest( val model: String, val prompt: String, val stream: Boolean // do not assign false here )
TAG307@@It can be fixed with the serializer settings:json(Json { ignoreUnknownKeys = true isLenient = true encodeDefaults = true // ← ensures that stream is included in the JSON })
TAG312@@
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 here
package 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 responses

Hello

{"model":"gemma4:e4b-mlx","created_at":"2026-11-09T03:41:29.399462Z","message":{"role":"assistant","content":"Hello"},"done":false}! How can I help? 😊
Flow Magic
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 response

The 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!