
Modern strict MVI plus Clean Architecture with embedded AI-agent debugging via FlightRecorder and MCP, guaranteeing lossless intent queues, deterministic reductions, exactly-once side-effect delivery, and test scaffolding.
The First AI-Agent Native MVI Architecture for Kotlin Multiplatform (KMP).
Kide is a modern, strict MVI (Model–View–Intent) and Clean Architecture library built specifically for the era of AI-assisted development. Targeting Android, iOS, and JVM (desktop), it integrates seamlessly with Compose Multiplatform, Navigation 3, Koin, Decompose, and Voyager.
The library takes its name and logo from the Finnish word for "crystal": kide, reflecting its transparent, predictable, and indestructible architecture.
For a comprehensive overview of core concepts, architecture diagrams, and practical instructions for development, debugging, and testing, check out the Kide Developer Guide.
FlightRecorder. Your AI agent can connect directly to your running app, dispatch intents, read state traces, find bugs, and instantly generate regression tests.ScreenNavKey-based design keeps each destination self-contained — a type-safe key ties a screen to its processor, so features register their own destinations independently, arguments and back stack survive process death, and navigation stays decoupled and testable without a central navigation graph.Kide's AI-first debugging approach (FlightRecorder, interceptors, and MCP agent ports) is something none of the other current MVI libraries offer. As AI coding assistants become mandatory tools for development teams, an architecture built specifically to be understood and debugged by AI is a massive strategic advantage.
Classic MVI debug tooling renders a GUI for human eyes. Kide goes further by targeting the entity that increasingly does your debugging: your AI coding agent.
Using the kide-devtools module, Kide keeps a queryable, causally ordered trace of a processor's life. An embedded MCP server exposes your running app as agent tools:
// In your debug builds:
val recorder = FlightRecorder<SearchIntent, SearchViewState, SearchSideEffect>()
val processor = SearchProcessor(useCase, interceptors = listOf(recorder))
KideDebug.attach("search", processor, recorder)
KideMcpServer.start(context) What can your agent do?
Connect your agent (like Claude or Antigravity) to http://localhost:8765/mcp.
You can now simply ask: "Why is isLoading stuck on the search screen?"
Your agent will read the trace, correlate it with your source code, reproduce the bug by dispatching live intents into your emulator, and generate a Kotest replay scaffold for you automatically.
Kide is highly decoupled. Use only what you need:
| Module | Contents | Depends on |
|---|---|---|
kide |
Core MVI engine: PresentationProcessor, actions, interceptors, KideLog
|
kotlinx-coroutines |
kide-navigation |
Navigation 3 based navigation: ScreenNavKey, AppNavigation, back-stack persistence |
kide, Compose, Navigation 3 |
kide-clean-architecture |
Clean Architecture building blocks: use cases, repositories, services, features | kotlinx-coroutines |
kide-koin |
Koin dependency-injection helpers | Koin |
kide-test |
Fluent testing DSL for PresentationProcessor
|
kide, Turbine, kotlinx-coroutines-test |
kide-clean-architecture-test |
Testing DSL for UseCaseProcessor
|
kide-clean-architecture, Turbine, kotlinx-coroutines-test |
kide-devtools |
Debug tooling: FlightRecorder, MCP agent port, console event streaming |
kide, kotlinx-serialization |
kide-decompose |
InstanceKeeperHost for hosting processors in Decompose |
kide, Essenty |
kide-voyager |
ScreenModelHost for hosting processors in Voyager |
kide, Voyager |
app |
Sample Android application exercising the full stack | all of the above |
Kide is published to Maven Central under the group org.fuusio.kide. The latest
release is 1.1.0.
Make sure mavenCentral() is in your repositories (in settings.gradle.kts):
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Add only the modules you need. In a Kotlin Multiplatform project, put the shared modules
in commonMain:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("org.fuusio.kide:kide:1.1.0") // Core MVI engine
implementation("org.fuusio.kide:kide-navigation:1.1.0") // Navigation 3
implementation("org.fuusio.kide:kide-clean-architecture:1.1.0") // Clean Architecture
implementation("org.fuusio.kide:kide-koin:1.1.0") // Koin DI helpers
implementation("org.fuusio.kide:kide-decompose:1.1.0") // Decompose host
implementation("org.fuusio.kide:kide-voyager:1.1.0") // Voyager host
}
commonTest.dependencies {
implementation("org.fuusio.kide:kide-test:1.1.0") // Presentation testing DSL
implementation("org.fuusio.kide:kide-clean-architecture-test:1.1.0") // Use-case testing DSL
}
}
}For debug builds only, add the agent-native debug tooling:
// e.g. an androidMain / debug source set
implementation("org.fuusio.kide:kide-devtools:1.1.0")For a single-platform (e.g. Android-only) project, declare them in the regular
dependencies { } block instead:
dependencies {
implementation("org.fuusio.kide:kide:1.1.0")
testImplementation("org.fuusio.kide:kide-test:1.1.0")
}If you use a libs.versions.toml catalog, declare a shared version and the artifacts:
[versions]
kide = "1.1.0"
[libraries]
kide = { module = "org.fuusio.kide:kide", version.ref = "kide" }
kide-navigation = { module = "org.fuusio.kide:kide-navigation", version.ref = "kide" }
kide-clean-architecture = { module = "org.fuusio.kide:kide-clean-architecture", version.ref = "kide" }
kide-koin = { module = "org.fuusio.kide:kide-koin", version.ref = "kide" }
kide-decompose = { module = "org.fuusio.kide:kide-decompose", version.ref = "kide" }
kide-voyager = { module = "org.fuusio.kide:kide-voyager", version.ref = "kide" }
kide-test = { module = "org.fuusio.kide:kide-test", version.ref = "kide" }
kide-clean-architecture-test = { module = "org.fuusio.kide:kide-clean-architecture-test", version.ref = "kide" }
kide-devtools = { module = "org.fuusio.kide:kide-devtools", version.ref = "kide" }Then reference them from your build script:
commonMain.dependencies {
implementation(libs.kide)
implementation(libs.kide.navigation)
}
commonTest.dependencies {
implementation(libs.kide.test)
}Kide's presentation layer is a unidirectional data flow built around one class,
PresentationProcessor<I, S, E>:
flowchart TD
UI[Compose UI] -- dispatch(ViewIntent) --> PP[PresentationProcessor]
PP -- map() --> Action[Action]
Action -- reduce --> VS[ViewState]
Action -- async work --> VS
Action -- sideEffect --> SE[SideEffect]
VS -- collectAsState --> UI
SE -- collect --> UIViewIntent describes a user interaction or UI event.Action (or null for a no-op).ViewState (the single source of truth the UI renders),
or emit one-time SideEffects (navigation, toasts, …), or run asynchronous work.AsyncAction runs in its own coroutine; long-running work
never stalls the intent loop. Executions can be coalesced with a cancellation key:
dispatching an action with the same key cancels the previous, still-running one.KideInterceptor.onError) and to the
processor's overridable onError, and the loop continues with the next intent.kotlinx.serialization. No custom saver abstractions required!KideLog), or custom monitoring.UseCaseProcessors that can emit domain-state changes directly to your processors.data class SearchViewState(
val query: String = "",
val results: List<Project> = emptyList(),
val isLoading: Boolean = false,
) : ViewState
sealed interface SearchIntent : ViewIntent {
data class UpdateQuery(val query: String) : SearchIntent
data object TriggerSearch : SearchIntent
}
sealed interface SearchSideEffect : SideEffect {
data class ShowToast(val message: String) : SearchSideEffect
}class SearchProcessor(
private val searchUseCase: SearchGitHubProjectsUseCase,
) : PresentationProcessor<SearchIntent, SearchViewState, SearchSideEffect>(SearchViewState()) {
override suspend fun map(intent: SearchIntent): Action<SearchViewState, SearchSideEffect>? =
when (intent) {
is SearchIntent.UpdateQuery -> reduce { copy(query = intent.query) }
SearchIntent.TriggerSearch ->
if (state.query.isBlank()) {
sideEffect { SearchSideEffect.ShowToast("Query cannot be empty") }
} else {
composite(
reduce { copy(isLoading = true) },
async(cancellationKey = "search") {
val result = searchUseCase.execute(state.query)
reduce { copy(results = result.getOrDefault(emptyList()), isLoading = false) }
},
)
}
}
}Action builders available in map():
| Builder | Action | Runs |
|---|---|---|
reduce { … } |
ReducerAction |
Inline, synchronous state reduction |
sideEffect { … } |
SideEffectAction |
Inline, constructs a side effect from state |
async { … } / useCase { … }
|
AsyncAction |
Own coroutine; reduce { … } from AsyncScope
|
composite(a, b, …) |
CompositeAction |
Contained actions sequentially, in order |
@Composable
fun SearchScreen(ctx: ScreenContext<SearchProcessor>) {
val state by ctx.processor.states.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
ctx.processor.sideEffects.collect { effect ->
when (effect) {
is SearchSideEffect.ShowToast -> /* show it */ Unit
}
}
}
SearchContent(
state = state,
onQueryChange = { ctx.processor.dispatch(SearchIntent.UpdateQuery(it)) },
onSearch = { ctx.processor.dispatch(SearchIntent.TriggerSearch) },
)
}Each destination is described by a ScreenNavKey that links a processor to its screen:
object SearchNavKey : ScreenNavKey<SearchProcessor> {
override val serialKey = "search" // stable across releases and R8
override fun createProcessor(): SearchProcessor = get() // e.g. from Koin
override val screen: @Composable (ScreenContext<SearchProcessor>) -> Unit
get() = { ctx -> SearchScreen(ctx) }
}Register keys at startup (before first composition) and render the nav display:
// Application startup, e.g. from a Feature.initialize():
ScreenNavKeyRegistry.register(SearchNavKey)
// Activity / root composable:
val backStack = rememberAppNavBackStack(HomeNavKey)
AppNavigation(backStack)Navigate with ctx.navigateTo(DetailsNavKey), or backStack.navigateTo(...) /
backStack.pushTo(...) from outside a screen. Destinations that carry arguments
implement saveArgs()/restoreArgs(); the back stack — including arguments — survives
process death.
PresentationProcessor is a plain AutoCloseable class, independent of any UI framework.
A host owns its lifetime and calls close() exactly once:
ViewModelHost (kide-navigation); used automatically
by AppNavigation, which retains one processor per destination across configuration changes.InstanceKeeperHost (kide-decompose).ScreenModelHost (kide-voyager).Persistence is opt-in per destination and uses plain kotlinx-serialization — no custom
saver abstraction. Mark the state class @Serializable, flag ephemera @Transient,
and expose the serializer on the nav key:
@Serializable
data class SearchViewState(
val query: String = "",
@Transient val results: List<Project> = emptyList(),
@Transient val isLoading: Boolean = false,
) : ViewState
object SearchNavKey : ScreenNavKey<SearchProcessor> {
// ...
override val stateSerializer get() = SearchViewState.serializer()
}The host snapshots state lazily — only when the platform saves state, never per emission —
and restores it before first composition after process death (restored state wins;
setup() is skipped). Override PresentationProcessor.onSaveState to prune snapshots, or
return null to skip one. A failed decode (schema change) logs a warning and starts fresh.
Decompose apps get the same behavior via
retainedProcessor(key, stateKeeper, stateSerializer) { ... }.
Persist inputs and identity (query text, selected id), not derived data — keep result
lists @Transient and re-fetch on restore.
KideInterceptor hooks into the MVI lifecycle for logging, analytics, or debugging:
class LoggingInterceptor : KideInterceptor<SearchIntent, SearchViewState, SearchSideEffect> {
override fun onIntent(intent: SearchIntent) { /* … */ }
override fun onStateChanged(oldState: SearchViewState, newState: SearchViewState) { /* … */ }
override fun onSideEffect(sideEffect: SearchSideEffect) { /* … */ }
override fun onError(throwable: Throwable, intent: SearchIntent) { /* … */ }
}Pass interceptors to the processor constructor.
KideLog is a zero-dependency logging facade with severity levels and automatic
class-based tagging. Plug in any backend by assigning a KideLogger:
// Android app with Timber:
KideLog.minLevel = if (BuildConfig.DEBUG) LogLevel.Debug else LogLevel.None
KideLog.logger = KideLogger { level, tag, message, throwable ->
val tree = Timber.tag(tag)
when (level) {
LogLevel.Verbose -> tree.v(throwable, message)
LogLevel.Debug -> tree.d(throwable, message)
LogLevel.Info -> tree.i(throwable, message)
LogLevel.Warning -> tree.w(throwable, message)
LogLevel.Error -> tree.e(throwable, message)
LogLevel.None -> Unit
}
}Inside any class, use the lazy, class-tagged extensions — the tag is derived from the receiver's class name and the message lambda is only evaluated if the entry is logged:
class SearchProcessor : PresentationProcessor<…> {
fun foo() {
logD { "Processing…" } // tag: "SearchProcessor"
logE(exception) { "Search failed" }
}
}📺 Watch the demo video: See how an AI agent uses the embedded Kide MCP server
Classic MVI debug tooling renders a GUI for human eyes. Kide's kide-devtools module
additionally targets the entity that increasingly does the debugging: your AI coding
agent. A FlightRecorder interceptor keeps a queryable, causally ordered trace of a
processor's life (intent → mapped action → state diff → side effect → error), and an
embedded MCP server exposes the running app as agent tools:
flowchart TD
Agent[AI Agent\nClaude / Antigravity] <-->|MCP Protocol\nHTTP| MCP[KideMcpServer]
subgraph Running Application
MCP <-->|Tools:\nget_trace, get_state,\ndispatch_intent| FR[FlightRecorder]
FR -.->|Intercepts Intent, Action,\nState, SideEffect, Error| PP[PresentationProcessor]
UI[App UI] -->|dispatch| PP
PP -->|stateFlow| UI
end// Wiring (debug builds):
val recorder = FlightRecorder<SearchIntent, SearchViewState, SearchSideEffect>()
val processor = SearchProcessor(useCase, interceptors = listOf(recorder))
KideDebug.attach("search", processor, recorder)
KideMcpServer.start(context) // Android: guarded — refuses to start unless debuggableadb forward tcp:8765 tcp:8765
claude mcp add --transport http kide http://localhost:8765/mcp
The agent can now call kide_list_processors, kide_get_state, kide_get_trace
(the causal history with previous-state diffs), kide_dispatch_intent (inject any
@Serializable intent into the live app), and kide_export_regression_test — which
turns a recorded bug session into a kotest replay scaffold. Ask "why is isLoading stuck
on the search screen?" and the agent reads the trace, correlates it with source, and
reproduces the bug by dispatching the same intents. Replay is sound because Kide's
intent loop is lossless and reduces in dispatch order.
KideDevToolsInterceptor complements this with live event streaming to a console
server (KideDevToolsServer) for watching the app as you use it. The server binds to
loopback only; never start it in release builds.
kide-clean-architecture provides the scaffolding for the domain and adapter layers:
marker interfaces for Repository, Service, DataSource, and Manager; base classes
(AbstractRepository, AbstractService, AbstractManager) with a coroutine-dispatcher
dispatch { } helper; a Feature abstraction for modular app assembly; and an
intent-driven use-case pattern:
data class SavedProjectsState(val projects: List<Project> = emptyList()) : State
sealed interface SavedProjectsIntent : UseCaseIntent<SavedProjectsState>
data class SaveProject(val project: Project) : SavedProjectsIntent
class SavedProjectsProcessor(
private val repository: ProjectRepository,
) : AbstractUseCaseProcessor<SavedProjectsState, SavedProjectsIntent>(SavedProjectsState()) {
override suspend fun map(intent: SavedProjectsIntent) {
when (intent) {
is SaveProject -> {
repository.save(intent.project)
reduce { it.copy(projects = it.projects + intent.project) }
}
// Mappings of other intents ...
}
}
}The presentation layer talks to UseCaseProcessor from AsyncActions and can collect its
stateFlow to react to domain-state changes.
Kide is built for testing. Processors are plain classes, and the kide-test module provides a powerful DSL (powered by Turbine) to succinctly test state and side-effect emissions.
Dispatchers.setMain(UnconfinedTestDispatcher())
val processor = SearchProcessor(FakeSearchUseCase())
processor.test {
dispatch(SearchIntent.UpdateQuery("kotlin"))
expectState { it.query == "kotlin" }
expectSideEffect { it is SearchSideEffect.ShowToast }
}Domain use cases get the same treatment. The kide-clean-architecture-test module ships a
matching UseCaseProcessor.test { } DSL for asserting on domain-state emissions:
runTest {
SavedProjectsProcessor(FakeProjectRepository()).test {
skipInitialState()
dispatch(SaveProject(project))
expectState { state -> assertTrue(project in state.projects) }
}
}./gradlew build # compile and test all modules
./gradlew :app:installDebug # install the sample app
Library modules use Kotlin explicit API mode (warning level) and the Kotlin Gradle
plugin's ABI validation: ./gradlew checkKotlinAbi verifies the public ABI against
the committed reference dumps and runs as part of check; after an intentional API
change, regenerate the dumps with ./gradlew updateKotlinAbi.
Publishing is configured via the vanniktech maven-publish plugin
with coordinates org.fuusio.kide:<module> (see gradle.properties).
Kide ships first-class instructions for AI coding agents.
Working on this repo: AGENTS.md.
Building an app with Kide: install the agent skill from
skills/kide/ (SKILL.md + reference.md — Claude Code:
/plugin or copy the directory into .claude/skills/).
Debugging support: Debug builds can additionally expose the running app to agents via the MCP agent port (see Agent-native debugging).
The app module is a complete Android application built with Kide: feature modules with
their own Koin modules and nav keys, Room and DataStore data sources, a GitHub project
search, and MVI processors for every screen. It is the best reference for how the
pieces fit together.
The Kide library is licensed under the Apache License, Version 2.0. See the LICENSE file for details.
The First AI-Agent Native MVI Architecture for Kotlin Multiplatform (KMP).
Kide is a modern, strict MVI (Model–View–Intent) and Clean Architecture library built specifically for the era of AI-assisted development. Targeting Android, iOS, and JVM (desktop), it integrates seamlessly with Compose Multiplatform, Navigation 3, Koin, Decompose, and Voyager.
The library takes its name and logo from the Finnish word for "crystal": kide, reflecting its transparent, predictable, and indestructible architecture.
For a comprehensive overview of core concepts, architecture diagrams, and practical instructions for development, debugging, and testing, check out the Kide Developer Guide.
FlightRecorder. Your AI agent can connect directly to your running app, dispatch intents, read state traces, find bugs, and instantly generate regression tests.ScreenNavKey-based design keeps each destination self-contained — a type-safe key ties a screen to its processor, so features register their own destinations independently, arguments and back stack survive process death, and navigation stays decoupled and testable without a central navigation graph.Kide's AI-first debugging approach (FlightRecorder, interceptors, and MCP agent ports) is something none of the other current MVI libraries offer. As AI coding assistants become mandatory tools for development teams, an architecture built specifically to be understood and debugged by AI is a massive strategic advantage.
Classic MVI debug tooling renders a GUI for human eyes. Kide goes further by targeting the entity that increasingly does your debugging: your AI coding agent.
Using the kide-devtools module, Kide keeps a queryable, causally ordered trace of a processor's life. An embedded MCP server exposes your running app as agent tools:
// In your debug builds:
val recorder = FlightRecorder<SearchIntent, SearchViewState, SearchSideEffect>()
val processor = SearchProcessor(useCase, interceptors = listOf(recorder))
KideDebug.attach("search", processor, recorder)
KideMcpServer.start(context) What can your agent do?
Connect your agent (like Claude or Antigravity) to http://localhost:8765/mcp.
You can now simply ask: "Why is isLoading stuck on the search screen?"
Your agent will read the trace, correlate it with your source code, reproduce the bug by dispatching live intents into your emulator, and generate a Kotest replay scaffold for you automatically.
Kide is highly decoupled. Use only what you need:
| Module | Contents | Depends on |
|---|---|---|
kide |
Core MVI engine: PresentationProcessor, actions, interceptors, KideLog
|
kotlinx-coroutines |
kide-navigation |
Navigation 3 based navigation: ScreenNavKey, AppNavigation, back-stack persistence |
kide, Compose, Navigation 3 |
kide-clean-architecture |
Clean Architecture building blocks: use cases, repositories, services, features | kotlinx-coroutines |
kide-koin |
Koin dependency-injection helpers | Koin |
kide-test |
Fluent testing DSL for PresentationProcessor
|
kide, Turbine, kotlinx-coroutines-test |
kide-clean-architecture-test |
Testing DSL for UseCaseProcessor
|
kide-clean-architecture, Turbine, kotlinx-coroutines-test |
kide-devtools |
Debug tooling: FlightRecorder, MCP agent port, console event streaming |
kide, kotlinx-serialization |
kide-decompose |
InstanceKeeperHost for hosting processors in Decompose |
kide, Essenty |
kide-voyager |
ScreenModelHost for hosting processors in Voyager |
kide, Voyager |
app |
Sample Android application exercising the full stack | all of the above |
Kide is published to Maven Central under the group org.fuusio.kide. The latest
release is 1.1.0.
Make sure mavenCentral() is in your repositories (in settings.gradle.kts):
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Add only the modules you need. In a Kotlin Multiplatform project, put the shared modules
in commonMain:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("org.fuusio.kide:kide:1.1.0") // Core MVI engine
implementation("org.fuusio.kide:kide-navigation:1.1.0") // Navigation 3
implementation("org.fuusio.kide:kide-clean-architecture:1.1.0") // Clean Architecture
implementation("org.fuusio.kide:kide-koin:1.1.0") // Koin DI helpers
implementation("org.fuusio.kide:kide-decompose:1.1.0") // Decompose host
implementation("org.fuusio.kide:kide-voyager:1.1.0") // Voyager host
}
commonTest.dependencies {
implementation("org.fuusio.kide:kide-test:1.1.0") // Presentation testing DSL
implementation("org.fuusio.kide:kide-clean-architecture-test:1.1.0") // Use-case testing DSL
}
}
}For debug builds only, add the agent-native debug tooling:
// e.g. an androidMain / debug source set
implementation("org.fuusio.kide:kide-devtools:1.1.0")For a single-platform (e.g. Android-only) project, declare them in the regular
dependencies { } block instead:
dependencies {
implementation("org.fuusio.kide:kide:1.1.0")
testImplementation("org.fuusio.kide:kide-test:1.1.0")
}If you use a libs.versions.toml catalog, declare a shared version and the artifacts:
[versions]
kide = "1.1.0"
[libraries]
kide = { module = "org.fuusio.kide:kide", version.ref = "kide" }
kide-navigation = { module = "org.fuusio.kide:kide-navigation", version.ref = "kide" }
kide-clean-architecture = { module = "org.fuusio.kide:kide-clean-architecture", version.ref = "kide" }
kide-koin = { module = "org.fuusio.kide:kide-koin", version.ref = "kide" }
kide-decompose = { module = "org.fuusio.kide:kide-decompose", version.ref = "kide" }
kide-voyager = { module = "org.fuusio.kide:kide-voyager", version.ref = "kide" }
kide-test = { module = "org.fuusio.kide:kide-test", version.ref = "kide" }
kide-clean-architecture-test = { module = "org.fuusio.kide:kide-clean-architecture-test", version.ref = "kide" }
kide-devtools = { module = "org.fuusio.kide:kide-devtools", version.ref = "kide" }Then reference them from your build script:
commonMain.dependencies {
implementation(libs.kide)
implementation(libs.kide.navigation)
}
commonTest.dependencies {
implementation(libs.kide.test)
}Kide's presentation layer is a unidirectional data flow built around one class,
PresentationProcessor<I, S, E>:
flowchart TD
UI[Compose UI] -- dispatch(ViewIntent) --> PP[PresentationProcessor]
PP -- map() --> Action[Action]
Action -- reduce --> VS[ViewState]
Action -- async work --> VS
Action -- sideEffect --> SE[SideEffect]
VS -- collectAsState --> UI
SE -- collect --> UIViewIntent describes a user interaction or UI event.Action (or null for a no-op).ViewState (the single source of truth the UI renders),
or emit one-time SideEffects (navigation, toasts, …), or run asynchronous work.AsyncAction runs in its own coroutine; long-running work
never stalls the intent loop. Executions can be coalesced with a cancellation key:
dispatching an action with the same key cancels the previous, still-running one.KideInterceptor.onError) and to the
processor's overridable onError, and the loop continues with the next intent.kotlinx.serialization. No custom saver abstractions required!KideLog), or custom monitoring.UseCaseProcessors that can emit domain-state changes directly to your processors.data class SearchViewState(
val query: String = "",
val results: List<Project> = emptyList(),
val isLoading: Boolean = false,
) : ViewState
sealed interface SearchIntent : ViewIntent {
data class UpdateQuery(val query: String) : SearchIntent
data object TriggerSearch : SearchIntent
}
sealed interface SearchSideEffect : SideEffect {
data class ShowToast(val message: String) : SearchSideEffect
}class SearchProcessor(
private val searchUseCase: SearchGitHubProjectsUseCase,
) : PresentationProcessor<SearchIntent, SearchViewState, SearchSideEffect>(SearchViewState()) {
override suspend fun map(intent: SearchIntent): Action<SearchViewState, SearchSideEffect>? =
when (intent) {
is SearchIntent.UpdateQuery -> reduce { copy(query = intent.query) }
SearchIntent.TriggerSearch ->
if (state.query.isBlank()) {
sideEffect { SearchSideEffect.ShowToast("Query cannot be empty") }
} else {
composite(
reduce { copy(isLoading = true) },
async(cancellationKey = "search") {
val result = searchUseCase.execute(state.query)
reduce { copy(results = result.getOrDefault(emptyList()), isLoading = false) }
},
)
}
}
}Action builders available in map():
| Builder | Action | Runs |
|---|---|---|
reduce { … } |
ReducerAction |
Inline, synchronous state reduction |
sideEffect { … } |
SideEffectAction |
Inline, constructs a side effect from state |
async { … } / useCase { … }
|
AsyncAction |
Own coroutine; reduce { … } from AsyncScope
|
composite(a, b, …) |
CompositeAction |
Contained actions sequentially, in order |
@Composable
fun SearchScreen(ctx: ScreenContext<SearchProcessor>) {
val state by ctx.processor.states.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
ctx.processor.sideEffects.collect { effect ->
when (effect) {
is SearchSideEffect.ShowToast -> /* show it */ Unit
}
}
}
SearchContent(
state = state,
onQueryChange = { ctx.processor.dispatch(SearchIntent.UpdateQuery(it)) },
onSearch = { ctx.processor.dispatch(SearchIntent.TriggerSearch) },
)
}Each destination is described by a ScreenNavKey that links a processor to its screen:
object SearchNavKey : ScreenNavKey<SearchProcessor> {
override val serialKey = "search" // stable across releases and R8
override fun createProcessor(): SearchProcessor = get() // e.g. from Koin
override val screen: @Composable (ScreenContext<SearchProcessor>) -> Unit
get() = { ctx -> SearchScreen(ctx) }
}Register keys at startup (before first composition) and render the nav display:
// Application startup, e.g. from a Feature.initialize():
ScreenNavKeyRegistry.register(SearchNavKey)
// Activity / root composable:
val backStack = rememberAppNavBackStack(HomeNavKey)
AppNavigation(backStack)Navigate with ctx.navigateTo(DetailsNavKey), or backStack.navigateTo(...) /
backStack.pushTo(...) from outside a screen. Destinations that carry arguments
implement saveArgs()/restoreArgs(); the back stack — including arguments — survives
process death.
PresentationProcessor is a plain AutoCloseable class, independent of any UI framework.
A host owns its lifetime and calls close() exactly once:
ViewModelHost (kide-navigation); used automatically
by AppNavigation, which retains one processor per destination across configuration changes.InstanceKeeperHost (kide-decompose).ScreenModelHost (kide-voyager).Persistence is opt-in per destination and uses plain kotlinx-serialization — no custom
saver abstraction. Mark the state class @Serializable, flag ephemera @Transient,
and expose the serializer on the nav key:
@Serializable
data class SearchViewState(
val query: String = "",
@Transient val results: List<Project> = emptyList(),
@Transient val isLoading: Boolean = false,
) : ViewState
object SearchNavKey : ScreenNavKey<SearchProcessor> {
// ...
override val stateSerializer get() = SearchViewState.serializer()
}The host snapshots state lazily — only when the platform saves state, never per emission —
and restores it before first composition after process death (restored state wins;
setup() is skipped). Override PresentationProcessor.onSaveState to prune snapshots, or
return null to skip one. A failed decode (schema change) logs a warning and starts fresh.
Decompose apps get the same behavior via
retainedProcessor(key, stateKeeper, stateSerializer) { ... }.
Persist inputs and identity (query text, selected id), not derived data — keep result
lists @Transient and re-fetch on restore.
KideInterceptor hooks into the MVI lifecycle for logging, analytics, or debugging:
class LoggingInterceptor : KideInterceptor<SearchIntent, SearchViewState, SearchSideEffect> {
override fun onIntent(intent: SearchIntent) { /* … */ }
override fun onStateChanged(oldState: SearchViewState, newState: SearchViewState) { /* … */ }
override fun onSideEffect(sideEffect: SearchSideEffect) { /* … */ }
override fun onError(throwable: Throwable, intent: SearchIntent) { /* … */ }
}Pass interceptors to the processor constructor.
KideLog is a zero-dependency logging facade with severity levels and automatic
class-based tagging. Plug in any backend by assigning a KideLogger:
// Android app with Timber:
KideLog.minLevel = if (BuildConfig.DEBUG) LogLevel.Debug else LogLevel.None
KideLog.logger = KideLogger { level, tag, message, throwable ->
val tree = Timber.tag(tag)
when (level) {
LogLevel.Verbose -> tree.v(throwable, message)
LogLevel.Debug -> tree.d(throwable, message)
LogLevel.Info -> tree.i(throwable, message)
LogLevel.Warning -> tree.w(throwable, message)
LogLevel.Error -> tree.e(throwable, message)
LogLevel.None -> Unit
}
}Inside any class, use the lazy, class-tagged extensions — the tag is derived from the receiver's class name and the message lambda is only evaluated if the entry is logged:
class SearchProcessor : PresentationProcessor<…> {
fun foo() {
logD { "Processing…" } // tag: "SearchProcessor"
logE(exception) { "Search failed" }
}
}📺 Watch the demo video: See how an AI agent uses the embedded Kide MCP server
Classic MVI debug tooling renders a GUI for human eyes. Kide's kide-devtools module
additionally targets the entity that increasingly does the debugging: your AI coding
agent. A FlightRecorder interceptor keeps a queryable, causally ordered trace of a
processor's life (intent → mapped action → state diff → side effect → error), and an
embedded MCP server exposes the running app as agent tools:
flowchart TD
Agent[AI Agent\nClaude / Antigravity] <-->|MCP Protocol\nHTTP| MCP[KideMcpServer]
subgraph Running Application
MCP <-->|Tools:\nget_trace, get_state,\ndispatch_intent| FR[FlightRecorder]
FR -.->|Intercepts Intent, Action,\nState, SideEffect, Error| PP[PresentationProcessor]
UI[App UI] -->|dispatch| PP
PP -->|stateFlow| UI
end// Wiring (debug builds):
val recorder = FlightRecorder<SearchIntent, SearchViewState, SearchSideEffect>()
val processor = SearchProcessor(useCase, interceptors = listOf(recorder))
KideDebug.attach("search", processor, recorder)
KideMcpServer.start(context) // Android: guarded — refuses to start unless debuggableadb forward tcp:8765 tcp:8765
claude mcp add --transport http kide http://localhost:8765/mcp
The agent can now call kide_list_processors, kide_get_state, kide_get_trace
(the causal history with previous-state diffs), kide_dispatch_intent (inject any
@Serializable intent into the live app), and kide_export_regression_test — which
turns a recorded bug session into a kotest replay scaffold. Ask "why is isLoading stuck
on the search screen?" and the agent reads the trace, correlates it with source, and
reproduces the bug by dispatching the same intents. Replay is sound because Kide's
intent loop is lossless and reduces in dispatch order.
KideDevToolsInterceptor complements this with live event streaming to a console
server (KideDevToolsServer) for watching the app as you use it. The server binds to
loopback only; never start it in release builds.
kide-clean-architecture provides the scaffolding for the domain and adapter layers:
marker interfaces for Repository, Service, DataSource, and Manager; base classes
(AbstractRepository, AbstractService, AbstractManager) with a coroutine-dispatcher
dispatch { } helper; a Feature abstraction for modular app assembly; and an
intent-driven use-case pattern:
data class SavedProjectsState(val projects: List<Project> = emptyList()) : State
sealed interface SavedProjectsIntent : UseCaseIntent<SavedProjectsState>
data class SaveProject(val project: Project) : SavedProjectsIntent
class SavedProjectsProcessor(
private val repository: ProjectRepository,
) : AbstractUseCaseProcessor<SavedProjectsState, SavedProjectsIntent>(SavedProjectsState()) {
override suspend fun map(intent: SavedProjectsIntent) {
when (intent) {
is SaveProject -> {
repository.save(intent.project)
reduce { it.copy(projects = it.projects + intent.project) }
}
// Mappings of other intents ...
}
}
}The presentation layer talks to UseCaseProcessor from AsyncActions and can collect its
stateFlow to react to domain-state changes.
Kide is built for testing. Processors are plain classes, and the kide-test module provides a powerful DSL (powered by Turbine) to succinctly test state and side-effect emissions.
Dispatchers.setMain(UnconfinedTestDispatcher())
val processor = SearchProcessor(FakeSearchUseCase())
processor.test {
dispatch(SearchIntent.UpdateQuery("kotlin"))
expectState { it.query == "kotlin" }
expectSideEffect { it is SearchSideEffect.ShowToast }
}Domain use cases get the same treatment. The kide-clean-architecture-test module ships a
matching UseCaseProcessor.test { } DSL for asserting on domain-state emissions:
runTest {
SavedProjectsProcessor(FakeProjectRepository()).test {
skipInitialState()
dispatch(SaveProject(project))
expectState { state -> assertTrue(project in state.projects) }
}
}./gradlew build # compile and test all modules
./gradlew :app:installDebug # install the sample app
Library modules use Kotlin explicit API mode (warning level) and the Kotlin Gradle
plugin's ABI validation: ./gradlew checkKotlinAbi verifies the public ABI against
the committed reference dumps and runs as part of check; after an intentional API
change, regenerate the dumps with ./gradlew updateKotlinAbi.
Publishing is configured via the vanniktech maven-publish plugin
with coordinates org.fuusio.kide:<module> (see gradle.properties).
Kide ships first-class instructions for AI coding agents.
Working on this repo: AGENTS.md.
Building an app with Kide: install the agent skill from
skills/kide/ (SKILL.md + reference.md — Claude Code:
/plugin or copy the directory into .claude/skills/).
Debugging support: Debug builds can additionally expose the running app to agents via the MCP agent port (see Agent-native debugging).
The app module is a complete Android application built with Kide: feature modules with
their own Koin modules and nav keys, Room and DataStore data sources, a GitHub project
search, and MVI processors for every screen. It is the best reference for how the
pieces fit together.
The Kide library is licensed under the Apache License, Version 2.0. See the LICENSE file for details.