DevFest Chennai 2026

Kotlin
Over the Air

Ship a Compose screen to users in minutes. No store release.

Aditya ShindeKetoyVM Co-speakerCompany

Your fix is ready today.
Your users get it next week.

Fix mergedDay 0
Build + QADay 1–2
Store reviewhours to 2 days
Staged rolloutdays
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

01SourceKotlin + Compose
02Compilerchecks the sandbox
03.ktxa signed file
04VMinside your app
05Composenative UI

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 signature

key in the APK

02Read sections

version check

03Check needs

every capability exists, by name

04Activate

or 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 coroutines

suspend, Flow, cancellation

One VM per process

state survives rotation

Nothing executable

no 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

Android Compose
12:35
Todos
Vanakkamit'sDevFest
Buy filter coffee
Finish the slides
Dark mode

Live on Google Play

iOS SwiftUI
9:41
Todos
Vanakkamit'sDevFest
Buy filter coffee
Finish the slides
Dark mode

Real VStack and Text

iOS Compose MP
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.

Canary

1% → 25% → 100%, per device

Kill switch

pin a bundle, revoke a key

Automatic rollback

a crash returns to the last good one

Native fallback, always

bad 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

QR code for github.com/KetoyDev/KetoyVM
Aditya ShindeCo-speaker#DevFestChennai