Don’t forget to share it with your network!
Suryaprakash Narsinghbhai Sharma
Sr Developer, Softices
Artificial Intelligence
26 June, 2026
Suryaprakash Narsinghbhai Sharma
Sr Developer, Softices
AI has now become a core feature in modern applications. Businesses today want AI-powered experiences that work seamlessly across multiple platforms without maintaining separate codebases. This is where Kotlin Multiplatform (KMP) shines.
With Kotlin Multiplatform, developers can share business logic, networking, state management, and even UI across Android, iOS, Desktop, and Web applications. By combining KMP with modern AI providers such as Groq, OpenAI, and Gemini, teams can significantly reduce development time while delivering a consistent AI experience across platforms.
In this guide, you'll explore how to build a cross-platform AI chat application using Kotlin Multiplatform, Compose Multiplatform, Ktor, and AI APIs from a single codebase.
Traditionally, building an AI-powered application required separate implementations for Android, iOS, Desktop, and Web platforms. It is expensive, time-consuming, creates inconsistent user experiences and longer release cycles.
Kotlin Multiplatform solves these issues by enabling developers to write shared code once and deploy it across multiple platforms.
Building a fully functional AI chat application that works on:
Platform |
Network Engine |
UI Framework |
Key Note |
|---|---|---|---|
| Android | OkHttp | Jetpack Compose | Add INTERNET permission |
| iOS | Darwin (NSURLSession) | Compose Multiplatform | Export XCFramework |
| Desktop | OkHttp | Compose Desktop | Package as .dmg/.msi/.deb |
| Web | Fetch API | Compose Web | Use backend proxy for API key |
The majority of the application resides inside the shared module, enabling maximum code reuse.
To build a production-ready AI chat application, the following stack is recommended:
Component |
Technology |
Version |
Use |
|---|---|---|---|
| Language | Kotlin Multiplatform | 2.0.0 | Used to share business logic across platforms. |
| UI Framework | Compose Multiplatform | 1.6.11 | Provides a unified UI framework for Android, iOS, Desktop, and Web. |
| HTTP Client | Ktor | 2.3.12 | Handles API communication with AI providers. |
| JSON Serialization | kotlinx.serialization | 1.7.1 | Handles request and response serialization. |
| AI Provider | Groq (Free) / OpenAI / Google Gemini API | - | Generate AI responses using LLM APIs. |
| Coroutines | kotlinx.coroutines | 1.8.1 | Manages asynchronous operations efficiently. |
A clean architecture keeps AI integrations scalable and maintainable.
commonMain/ ├── model/ ← Data classes (ChatMessage, ChatRequest) ├── network/ ← OpenAIClient, HttpClient setup ├── AIPresenter.kt ← Shared state management └── ui/ ← Shared Compose UI Platform-Specific: ├── androidMain/ ← OkHttp engine + Android integration ├── iosMain/ ← Darwin engine + iOS bridge ├── desktopMain/ ← OkHttp engine + Desktop app └── jsMain/ ← JS engine + Web deployment
The easiest way to start is using the official KMP wizard:
settings.gradle.kts
rootProject.name = "AIChatDemo"
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
include(":androidApp")
include(":shared")
include(":desktopApp")
include(":webApp")
gradle/libs.versions.toml
[versions]
agp = "8.2.2"
kotlin = "2.0.0"
compose-multiplatform = "1.6.11"
androidx-activityCompose = "1.9.0"
kotlinx-coroutines = "1.8.1"
kotlinx-serialization = "1.7.1"
ktor = "2.3.12"
[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version = "2.8.2" }
compose-material-icons = { module = "androidx.compose.material:material-icons-extended", version = "1.6.8" }
markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version = "0.27.0" }
markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version = "0.27.0" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
build.gradle.kts
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.kotlinCocoapods) apply false
alias(libs.plugins.kotlinSerialization) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.compose.multiplatform) apply false
}
shared/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler)
}
kotlin {
androidTarget {
compilations.all { kotlinOptions { jvmTarget = "17" } }
}
iosX64()
iosArm64()
iosSimulatorArm64()
jvm("desktop")
js(IR) {
browser()
binaries.executable()
}
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.markdown.renderer)
implementation(libs.markdown.renderer.m3)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
val desktopMain by getting {
dependencies {
implementation(libs.ktor.client.okhttp)
}
}
val jsMain by getting {
dependencies {
implementation(libs.ktor.client.js)
}
}
}
}
commonMain/model/ChatMessage.kt
@Serializable data class ChatMessage( val role: String, // "user" or "assistant" val content: String )
commonMain/model/ChatRequest.kt
@Serializable data class ChatRequest( val model: String, val messages: List<Message>, val max_tokens: Int = 1000 ) @Serializable data class Message( val role: String, val content: String )
commonMain/model/ChatResponse.kt
@Serializable data class ChatResponse( val choices: List<Choice>? = null, val error: OpenAIError? = null ) @Serializable data class Choice( val message: Message, val finish_reason: String? = null ) @Serializable data class OpenAIError( val message: String, val type: String? = null, val code: String? = null )
commonMain/network/NetworkEngine.kt
// commonMain expect fun getNetworkEngine(): HttpClientEngineFactory<*> // androidMain actual fun getNetworkEngine(): HttpClientEngineFactory<*> = OkHttp // iosMain actual fun getNetworkEngine(): HttpClientEngineFactory<*> = Darwin // desktopMain actual fun getNetworkEngine(): HttpClientEngineFactory<*> = OkHttp // jsMain actual fun getNetworkEngine(): HttpClientEngineFactory<*> = Js
commonMain/network/HttpClientFactory.kt
val json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true // ✅ Ensures model field is sent
}
fun createHttpClient(): HttpClient {
return HttpClient(getNetworkEngine()) {
install(ContentNegotiation) {
json(json)
}
expectSuccess = false // ✅ Handle errors manually
}
}
commonMain/network/OpenAIClient.kt
class OpenAIClient(private val apiKey: String) {
private val client = createHttpClient()
suspend fun chat(messages: List<Message>): String {
return try {
val requestBody = ChatRequest(
model = "llama-3.3-70b-versatile", // Groq free model
messages = messages,
max_tokens = 1000
)
val httpResponse = client.post(
"https://api.groq.com/openai/v1/chat/completions"
) {
header("Authorization", "Bearer $apiKey")
contentType(ContentType.Application.Json)
setBody(requestBody)
}
val rawBody = httpResponse.bodyAsText()
val parsed = json.decodeFromString<ChatResponse>(rawBody)
when {
parsed.error != null -> "API Error: ${parsed.error.message}"
parsed.choices != null -> parsed.choices
.firstOrNull()?.message?.content ?: "Empty"
else -> "No response"
}
} catch (e: Exception) {
"Exception: ${e.message}"
}
}
}
commonMain/AIPresenter.kt
data class ChatState(
val messages: List<ChatMessage> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
class AIPresenter(apiKey: String = APIConstant.GROQ_API_KEY) {
private val client = OpenAIClient(apiKey)
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val _state = MutableStateFlow(ChatState())
val state: StateFlow<ChatState> = _state
fun sendMessage(userInput: String) {
if (userInput.isBlank()) return
_state.value = _state.value.copy(
messages = _state.value.messages + ChatMessage("user", userInput),
isLoading = true
)
scope.launch {
try {
val reply = client.chat(
_state.value.messages.map { Message(it.role, it.content) }
)
_state.value = _state.value.copy(
messages = _state.value.messages + ChatMessage("assistant", reply),
isLoading = false
)
} catch (e: Exception) {
_state.value = _state.value.copy(
messages = _state.value.messages +
ChatMessage("assistant", "Error: ${e.message}"),
isLoading = false
)
}
}
}
fun clearChat() {
_state.value = ChatState()
}
}
Shared Chat UI
commonMain/ui/ChatScreen.kt
@Composable
fun ChatScreen(presenter: AIPresenter) {
val state by presenter.state.collectAsState()
ChatScreenContent(
state = state,
onSend = { presenter.sendMessage(it) },
onClear = { presenter.clearChat() }
)
}
@Composable
fun ChatScreenContent(
state: ChatState,
onSend: (String) -> Unit,
onClear: () -> Unit
) {
var input by remember { mutableStateOf("") }
val listState = rememberLazyListState()
LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty())
listState.animateScrollToItem(state.messages.size - 1)
}
Scaffold(
containerColor = Color(0xFFF9F9F9),
topBar = { ChatTopBar(onClear) },
bottomBar = {
ChatInputBar(
input = input,
isLoading = state.isLoading,
onInputChange = { input = it },
onSend = {
if (input.isNotBlank()) {
onSend(input)
input = ""
}
}
)
}
) { padding ->
if (state.messages.isEmpty() && !state.isLoading) {
EmptyState(Modifier.padding(padding))
} else {
LazyColumn(
state = listState,
modifier = Modifier.padding(padding)
) {
items(state.messages) { ChatBubble(it) }
if (state.isLoading) {
item { TypingIndicator() }
}
}
}
}
}
@Composable
fun ChatBubble(message: ChatMessage) {
val isUser = message.role == "user"
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start
) {
Box(
modifier = Modifier
.widthIn(max = 680.dp)
.clip(RoundedCornerShape(
topStart = if (isUser) 18.dp else 4.dp,
topEnd = if (isUser) 4.dp else 18.dp,
bottomStart = 18.dp,
bottomEnd = 18.dp
))
.background(if (isUser) Color(0xFF6C47FF) else Color.White)
.padding(horizontal = 16.dp, vertical = 10.dp)
) {
if (isUser) {
Text(message.content, color = Color.White)
} else {
// ✅ Render Markdown for AI responses
Markdown(
content = message.content,
colors = markdownColor(text = Color(0xFF1A1A1A)),
typography = markdownTypography(
text = TextStyle(fontSize = 15.sp, lineHeight = 22.sp),
code = TextStyle(fontFamily = FontFamily.Monospace)
)
)
}
}
}
}
androidApp/build.gradle.kts
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "com.yourpackage.android"
compileSdk = 34
defaultConfig {
applicationId = "com.yourpackage.android"
minSdk = 24
targetSdk = 34
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(project(":shared"))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.compose.material.icons)
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
}
<!-- ⚠️ INTERNET permission is REQUIRED --> <uses-permission android:name="android.permission.INTERNET" /> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/Theme.AppCompat"> <activity android:name=".MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application>
AIViewModel.kt
class AIViewModel : ViewModel() {
private val presenter = AIPresenter()
val state: StateFlow<ChatState> = presenter.state.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
ChatState()
)
fun ask(input: String) = presenter.sendMessage(input)
fun clearChat() = presenter.clearChat()
}
MainActivity.kt
class MainActivity : ComponentActivity() {
private val vm: AIViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val state by vm.state.collectAsState()
ChatScreenContent(
state = state,
onSend = { vm.ask(it) },
onClear = { vm.clearChat() }
)
}
}
}
shared/iosMain/MainViewController.kt
import androidx.compose.ui.window.ComposeUIViewController
import platform.UIKit.UIViewController
fun MainViewController(): UIViewController {
val presenter = AIPresenter()
return ComposeUIViewController {
ChatScreen(presenter = presenter)
}
}
# Debug (simulator) ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 # Release (device + all architectures) ./gradlew :shared:assembleSharedReleaseXCFramework
iosApp/ContentView.swift
import SwiftUI
import shared
struct ContentView: View {
var body: some View {
ComposeView()
.ignoresSafeArea(.all)
}
}
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
return MainViewController() // ✅ Calls Kotlin directly
}
func updateUIViewController(_ vc: UIViewController, context: Context) {}
}
iosApp/iOSApp.swift
import SwiftUI
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
desktopApp/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler)
}
kotlin {
jvm("desktop")
sourceSets {
val desktopMain by getting {
dependencies {
implementation(project(":shared"))
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(libs.kotlinx.coroutines.swing)
}
}
}
}
compose.desktop {
application {
mainClass = "MainKt"
nativeDistributions {
targetFormats(Dmg, Msi, Deb)
packageName = "AIChatDemo"
packageVersion = "1.0.0"
}
}
}
desktopApp/src/main/kotlin/Main.kt
import androidx.compose.ui.window.application
import androidx.compose.ui.window.Window
import androidx.compose.runtime.remember
fun main() = application {
val presenter = remember { AIPresenter() }
Window(
onCloseRequest = ::exitApplication,
title = "AI Chat"
) {
ChatScreen(presenter = presenter)
}
}
# Run in development ./gradlew :desktopApp:run # Package for macOS ./gradlew :desktopApp:packageDmg # Package for Windows ./gradlew :desktopApp:packageMsi # Package for Linux ./gradlew :desktopApp:packageDeb
Important Security Note: Never call OpenAI/Groq APIs directly from the browser — your API key will be exposed! Always use a backend proxy.
webApp/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
}
kotlin {
js(IR) {
browser {
commonWebpackConfig {
cssSupport { enabled.set(true) }
}
}
binaries.executable()
}
sourceSets {
val jsMain by getting {
dependencies {
implementation(project(":shared"))
implementation("org.jetbrains.kotlin-wrappers:kotlin-react:18.2.0-pre.654")
implementation("org.jetbrains.kotlin-wrappers:kotlin-react-dom:18.2.0-pre.654")
}
}
}
}
webApp/src/jsMain/kotlin/Main.kt
fun main() {
val presenter = AIPresenter()
renderComposable(rootElementId = "root") {
ChatScreen(presenter = presenter)
}
}
# Development with hot reload ./gradlew :webApp:browserDevelopmentRun # Production build ./gradlew :webApp:browserProductionWebpack # Output: webApp/build/dist/js/productionExecutable/
Get your free API key at console.groq.com
Setting |
Value |
|---|---|
| Base URL | https://api.groq.com/openai/v1/chat/completions |
| Free Model | llama-3.3-70b-versatile |
| Fast Model | llama3-8b-8192 |
| Daily Limit | 14,400 requests/day |
| Rate Limit | 30 requests/minute |
Get your free API key at aistudio.google.com
Setting |
Value |
|---|---|
| Base URL | https://generativelanguage.googleapis.com/v1beta/openai/chat/completions |
| Free Model | gemini-2.0-flash |
| Daily Limit | 1,500 requests/day |
| Rate Limit | 15 requests/minute |
Get your API key at platform.openai.com
Setting |
Value |
|---|---|
| Base URL | https://api.openai.com/v1/chat/completions |
| Recommended Model | gpt-4o-mini (cheap + fast) |
| Best Model | gpt-4o (most capable) |
| Pricing | Pay per token, no free tier |
Since all providers use OpenAI-compatible format, only two things change:
val requestBody = ChatRequest(
model = "llama-3.3-70b-versatile", // ← Change model
messages = messages
)
val httpResponse = client.post("https://api.groq.com/openai/v1/chat/completions") {
header("Authorization", "Bearer $apiKey") // ← Change URL
// ...
}
Error |
Fix |
|---|---|
| Field 'choices' is required |
Add choices: List<Choice>? = null (make nullable)
and enable ignoreUnknownKeys = true.
|
| You must provide a model parameter |
Add encodeDefaults = true to the Json configuration and
pass the model explicitly in ChatRequest.
|
| insufficient_quota | Your API key has no available credits. Switch to Groq (free tier) or add billing to your account. |
| RESOURCE_EXHAUSTED (Gemini) | The Gemini free tier daily limit has been reached. Wait 24 hours or switch to Groq. |
| Unresolved reference: viewModel() |
Add the lifecycle-viewmodel-compose dependency or use
by viewModels() in your Activity.
|
| MissingPropertyException: com |
Check the syntax in your root build.gradle.kts,
especially the plugins block.
|
| Icons not found |
Add the
androidx.compose.material:material-icons-extended
dependency.
|
| CORS error (Web) | Do not call AI APIs directly from the browser. Use a backend proxy server instead. |
Never commit your API key to Git!
# local.properties (git-ignored) GROQ_API_KEY=gsk_xxxxxxxxxxxx
androidApp/build.gradle.kts
val localProps = gradleLocalProperties(rootDir)
buildConfigField("String", "GROQ_KEY", "\"${localProps["GROQ_API_KEY"]}\"")
val presenter = AIPresenter(BuildConfig.GROQ_KEY)
Platform |
Entry File |
State Management |
One-liner |
|---|---|---|---|
| Android | MainActivity.kt |
AIViewModel (by viewModels()) |
setContent { ChatScreenContent(...) } |
| iOS | iOSApp.swift |
AIPresenter in iosMain |
ComposeView → MainViewController() |
| Desktop | Main.kt |
AIPresenter directly |
Window { ChatScreen(presenter) } |
| Web | Main.kt (jsMain) |
AIPresenter directly |
renderComposable { ChatScreen(presenter) } |
AIChatDemo/ ├── gradle/libs.versions.toml ├── build.gradle.kts # Root plugins ├── settings.gradle.kts # Include all modules │ ├── shared/ │ ├── build.gradle.kts │ └── src/ │ ├── commonMain/kotlin/ │ │ ├── model/ # ChatMessage, ChatRequest, etc. │ │ ├── network/ # OpenAIClient, HttpClientFactory │ │ ├── AIPresenter.kt # Shared state management │ │ └── ui/ChatScreen.kt # Shared Compose UI │ ├── androidMain/ # OkHttp engine │ ├── iosMain/ # Darwin engine + MainViewController │ ├── desktopMain/ # OkHttp engine │ └── jsMain/ # JS engine │ ├── androidApp/ # MainActivity, AIViewModel ├── desktopApp/ # Main.kt entry ├── webApp/ # Main.kt (jsMain) └── iosApp/ # Xcode project └── ContentView.swift # ComposeView bridge
Kotlin Multiplatform has transformed how developers build cross-platform applications. By combining KMP, Compose Multiplatform, Ktor, and modern AI APIs, teams can create powerful AI chat applications for Android, iOS, Desktop, and Web using a single codebase.
The result is faster development, lower maintenance costs, and a consistent user experience across every platform.
As AI continues to become a core feature of modern software, Kotlin Multiplatform offers one of the most efficient ways to build and scale AI-powered applications without duplicating effort.