DevFest Chennai 2026
Kotlin
Over the Air
Ship a Compose screen to users in minutes. No store release.
Aditya ShindeKetoyVM
Co-speakerCompany
Follow along
These slides, on your phone.
slides.ketoy.dev/devfest-chennai-26
Scan now. Everything on stage is in your pocket.
Your fix is ready today.
Your users get it next week.
Store reviewhours to 2 days
Users update1–2 weeks · some never
Server-driven UI in production
Airbnb · Lyft · Netflix · Spotify
Code over the air in production
React Native (Expo Updates) · Flutter (Shorebird)
Kotlin and Compose?
Your Diwali banner has to be live on Diwali morning.
Not when the last user finally updates.
Describing a screen
vs shipping a program
Server-driven UIsends a description
{"type":…}JSON description
Interpretermaps it to widgets
Fixed widgetsno logic, no new UI
Kotlin over the airsends a program
Screen.ktCompose code
.ktx bundlesigned bytecode
Sandboxed VMstate, Flow, M3
SDUI changes what a screen looks like.
OTA changes what it does.
Both run only what the app chose to expose. We call those capabilities.
The app is the operating system.
The server ships programs.
From .kt to a phone in five steps
02Compilerchecks the sandbox
KBC = Ketoy bytecode, our own small instruction set.
The compiler
Plain Kotlin in. Bytecode out.
@KetoyComposablefun TodoListScreen() {val todos by observeTodos().collectAsState(emptyList())LazyColumn(Modifier.padding(16.dp)) {items(todos) { todo ->Text(todo.title, fontWeight = FontWeight.Bold)}}}
One annotation.Everything this screen reaches compiles to KBC. Everything else stays normal Android code.
The compiler
Plain Kotlin in. Bytecode out.
@KetoyComposablefun TodoListScreen() {val todos by observeTodos().collectAsState(emptyList())LazyColumn(Modifier.padding(16.dp)) {items(todos) { todo ->Text(todo.title, fontWeight = FontWeight.Bold)}}}
Real state and Flow.collectAsState, remember and ViewModel run in the VM, with real recomposition.
The compiler
Plain Kotlin in. Bytecode out.
@KetoyComposablefun TodoListScreen() {val todos by observeTodos().collectAsState(emptyList())LazyColumn(Modifier.padding(16.dp)) {items(todos) { todo ->Text(todo.title, fontWeight = FontWeight.Bold)}}}
Real Compose calls.Parameters are matched by name, so a Material3 upgrade doesn't break old bundles.
The compiler
Plain Kotlin in. Bytecode out.
@KetoyComposablefun TodoListScreen() {val todos by observeTodos().collectAsState(emptyList())Toast.makeText(context, "hi", Toast.LENGTH_SHORT).show()}
This doesn't compile.The build fails, and the message names the fix.
e:KetoyBC: Direct access to android.widget.Toast. Expose a capability instead.
The compiler
Your app moved to AGP 9. The bundle didn't notice.
The KBC compiler ships its own Kotlin (2.4.10). Your app's Kotlin and AGP versions don't matter.
What it costs you
Two files. One annotation.
app/build.gradle.kts
plugins {alias(ketoy.plugins.compiler)}dependencies {implementation(ketoy.bundles.core)implementation(ketoy.annotations)}ketoy {exportFromAppModule.set(true)}
AboutScreen.kt
@KetoyComposablefun AboutScreen() {Text("Vanakkam!")} // in your NavHostKetoyScreen(entryPoint = "AboutScreen",nativeFallback = { AboutNative() },)
./gradlew :app:ketoyBundle → assets/ketoy/main.ktx
LIVE
Demo
Edit TodoListScreen.kt→ketoyBundle→upload→the phone updates
The .ktx file
A program you can ship
KTOYmagic
Headerversion
Manifestswhat it needs
Codecompressed
SignatureEd25519
Checked first, against a key inside your APK
1.6KB
a real screen on Google Play
≈0.7MB
the whole SDK in your APK
0
bytes parsed before the signature verifies
On the phone
Verified before it's even read
01Verify signaturekey in the APK
02Read sectionsversion check
03Check needsevery capability exists, by name
04Activateor fall back to the native screen
One byte changed? It stops at step 1 and shows your native screen.
Something we got wrong
Every bundle we shipped was Brotli.
Brotli decoding needed a JVM library, so our "portable" format was Android-only. Now it's DEFLATE: zlib exists everywhere.
A VM that can only reach what you hand it
KBC VM
Compose adapters
Navigation
Room · DataStore
Your capabilities
Ktor network
Dispatchers
Raw Android API
Reflection
GlobalScope
DEX / JVM classes
Real coroutinessuspend, Flow, cancellation
One VM per processstate survives rotation
Nothing executableno reflection, no JIT on iOS
Host registers observeTodos. Bundle declares it needs observeTodos. Missing? Rejected at load, by name.
One signed bundle. Three renderers.
main.ktx · 7.6 KB · one signing key
12:35
Todos
Vanakkamit'sDevFest
Buy filter coffee
Finish the slides
Dark mode
Live on Google Play
9:41
Todos
Vanakkamit'sDevFest
Buy filter coffee
Finish the slides
Dark mode
Real VStack and Text
9:41
Todos
Vanakkamit'sDevFest
Buy filter coffee
Finish the slides
Dark mode
Material3 on Skia, 30 of 30
✓Play review: passed
App Store: built for guideline 2.5.2, submission pending
At scale
Many bundles. One signed graph.
checkout.ktxfeature
offers.ktxfeature
main.ktxexpect
androidactual
iosactual
expect / actual, at the bundle boundary. Signed and switched on as one.
Canary1% → 25% → 100%, per device
Kill switchpin a bundle, revoke a key
Automatic rollbacka crash returns to the last good one
Native fallback, alwaysbad or missing: your native screen
No control plane. Your CDN, your remote config, your rules.
Honestly
When not to use this
Only need copy and layout?
Use SDUI or remote config. It's simpler.
Need a platform API the app doesn't expose?
That's an app release. By design.
Canvas on the SwiftUI backend?
Not yet. It fails by name, not silently.
If you remember three things
1The app is the OS. The server ships programs.
2One annotation. The compiler is the sandbox.
3Signed before parsed. Native fallback, always.
Your fix is ready today. Your users get it today.
Thank you
Aditya ShindeCo-speaker#DevFestChennai