A long-form walkthrough of how Ketoy actually works, written for the kind of developer who reads “server-driven UI on Android” and immediately asks “…how though?”
A ride through a K2 IR compiler plugin, a custom register-based bytecode, an on-device DEX JIT, a KSP code generator, and roughly a hundred small engineering decisions that each looked easy until they weren’t.
You write plain Kotlin and Jetpack Compose; we compile every screen, every helper, every view model in your app into a single signed binary; your APK ships with that binary; and a register-based VM inside your app runs it at native speed. When you push an updated binary to your CDN, every device picks it up on the next cold start - no Play Store release, no APK update, no DSL, no JSON schema. Just Kotlin.
Server-driven UI on Android has been a rich problem space for years. Most approaches plateau the moment a real product feature introduces a loop, a conditional, a coroutine, a captured-variable lambda, or business logic that wants to live next to the UI that uses it.
At that scale the choice tends to be: invent a tiny programming language inside JSON (badly), or ship a parallel SDK that lags real Compose by six months, or write thirty bespoke adapters to make one screen work. Each of those trades has good reasons.
Ketoy takes a different trade. Don’t translate Kotlin into a new format and hope the format stays expressive - ship Kotlin itself. The same @Composable function you’d write in a native app, compiled to a custom bytecode (KBC), packaged in a signed binary (.ktx), and executed on-device by a VM that talks to actual androidx.compose.material3.Button, actual viewModelScope, actual SavedStateHandle, actual collectAsState.
The mental model is unapologetic: your host APK is the operating system, the server ships programs. Think Hermes for React Native, except scoped to a Kotlin/Compose world and built around the idea that the language you already write is the wire format.
Every layer exists because the layer above creates a problem. The rest of this page walks it top to bottom.
@Ketoy* annotations on entry points.The promise we hold tightest: you don’t learn a new component model.
@KetoyEntryPoint
@Composable
fun CheckoutScreen(vm: CheckoutViewModel = hiltViewModel()) {
val state by vm.state.collectAsState()
Scaffold(topBar = { TopAppBar(title = { Text("Checkout") }) }) { padding ->
LazyColumn(modifier = Modifier.padding(padding)) {
items(state.cart) { item ->
ProductCard(item, onRemove = { vm.dispatch("remove", item.id) })
}
}
}
}That’s a complete Ketoy screen. It would also compile as a regular Android app with no Ketoy involvement at all. The collectAsState, the captured item.id in the lambda, the Modifier.padding chain, the Scaffold content slot - those are all real Compose, validated by the real Compose compiler, then re-lowered by us into bytecode for the remoted path.
@KetoyEntryPoint - marks a top-level screen the host can render by name.@KetoyComposable - marks a helper composable reachable from inside the closure (a list row, a section header, a dialog).@KetoyViewModel - marks a view model that lives in KBC land and is bridged into the host’s Hilt graph.@KetoyCapabilityStub(id, name) - declares a native bridge: the function shape is the KBC-side surface, the implementation is wired by the host.That’s the entire surface for marking exportable code. Everything else in your app - data classes, sealed classes, enum classes, helper functions, extension functions, suspend functions - gets pulled into the bundle automatically by the compiler plugin’s closure walk the moment it’s reachable from something you marked.
Flow, StateFlow, SharedFlow, all the standard operators.SavedStateHandle.LazyColumn per-item lambdas that capture both an iterator variable and outer state.K2 exposes the IR backend. By the time we get a turn, type checking and lowering are already done; the program is a tree of IrFunction, IrCall, IrConst, IrWhen, IrTry, IrFunctionExpression (lambdas), IrComposite (Compose’s slot-table bookkeeping). We register an IrGenerationExtension, walk that tree, and emit bytecode.
@Ketoy*-annotated declaration. Also collect every top-level function in the module..ktx file and sign it with Ed25519.KBC is a register-based bytecode (think Dalvik, not the JVM stack). Each function has a register file; instructions read from and write to numbered slots. The current opcode table holds 107 entries across these families:
LOAD_INT, LOAD_STRING, LOAD_NULL, LOAD_UNIT, MOVE.
Signed int/long/float/double arithmetic plus CMP_EQ, CMP_LT and the rest.
JUMP, JUMP_IF_TRUE, JUMP_IF_FALSE, RETURN, NOP.
NEW_INSTANCE, GET_FIELD, SET_FIELD, INSTANCEOF, CAST, SAFE_CAST, BOX, UNBOX - all on a sandboxed KBCHeap.
LIST_NEW, LIST_GET, LIST_ADD, LIST_SIZE, MAP_NEW, MAP_PUT, MAP_GET.
STRING_CONCAT (variadic), STRING_LENGTH, STRING_SUBSTR, INT_TO_STRING.
INVOKE_CAPABILITY plus _SUSPEND, _FLOW, and _VOID variants - the only door to the host.
SUSPEND_POINT, RESUME_VALUE, RESUME_EXCEPTION, LAUNCH, ASYNC, AWAIT, WITH_CONTEXT.
FLOW_EMIT, FLOW_COLLECT, COLLECT_AS_STATE.
COMPOSE_REMEMBER, COMPOSE_STATE, COMPOSE_DERIVED_STATE, COMPOSE_LAUNCHED_EFFECT, COMPOSE_SIDE_EFFECT, COMPOSE_DISPOSABLE_EFFECT.
THROW, TRY_CATCH, END_TRY - real Kotlin exception semantics, real handler stacks.
COMPOSABLE_CALL (0xB0) and CONSTRUCT_JVM (0xB1) - the two opcodes that bridge KBC and Compose.
A register ISA was chosen because the source language is too. The JVM is stack-based, which would force a synthetic operand stack on top of Kotlin’s locals - extra opcodes, slower interpretation, harder JIT translation. Dalvik picked registers for the same reason; we followed.
An innocent-looking question:
Text(text = "Hi", fontWeight = FontWeight.Bold, fontSize = 16.sp)
How do you encode that into bytecode? A naive “generic call with positional args” instruction collapses the moment you remember that Text has 15 parameters, OutlinedTextField has 22, Material3 adds new ones every minor release, and half the parameter types are compound objects - a TextStyle containing a Shadow containing an Offset, or a TextFieldColors with 30+ fields.
A flat List<Any?> argument list looks attractive until you also remember you can’t serialise half of those values: they’re closures, lambdas, Compose internals that only have meaning inside the composition. We tried hand-rolled adapters first. They worked for a while and then they didn’t.
So we built the Two-Layer Parameter System.
KBCValue is a sealed type with 35 variants, each carrying a one-byte wire tag. It models “everything that can appear as a parameter to a Compose function, encoded statically”: sentinels (Default, Null, NoCallback, …), primitives, Compose units (Dp, Sp, ColorARGB), Compose token IDs (FontWeightInt, TextAlignId, KeyboardTypeId, …), references (Register, ModifierRef, FunctionRef), and closures (ClosureRef(fnIdx, capturedRegs)).
The tag bytes let us encode sparse parameter lists compactly. A Text("Hi") call doesn’t specify all 14 other arguments - unspecified slots are simply absent on the wire, and the adapter on the runtime side fills them with Compose’s actual defaults. Typed enum IDs preserve intent: FontWeight.Bold is a singleton; encoding it as IntVal(700) would lose the type signal at the call site.
KBCValue covers the bulk of UI parameters. The remaining cases are compound values - TextStyle(...), KeyboardOptions(...), RoundedCornerShape(...), ButtonDefaults.buttonColors(...). Their shapes are open.
Enter CONSTRUCT_JVM. At KBC level it says: “Build me a JVM object via constructor adapter ID 0x0004 with the following parameter slots filled in. Store the result in register r5.” The runtime looks up adapter 0x0004 (which is KeyboardOptions), collects the supplied parameters into a KBCParamSet, and invokes the real constructor.
For factories that need to read MaterialTheme - anything in ButtonDefaults, CardDefaults, TopAppBarDefaults - we have an objectFactory= variant in the catalog. Construction runs inside the composition phase, so theme-aware reads work correctly.
Every forbidden API in KBC source becomes a typed compile error. Not a runtime crash, not a silent fallback - a build failure with a fix suggestion and a docs URL:
KetoyBC: Direct access to android.content.SharedPreferences Reached via: CheckoutScreen → loadCachedTodos → SharedPreferences.getString Why: KBC bundles cannot touch Android APIs directly. Use a capability. Fix: Wrap SharedPreferences access as a @KetoyCapabilityStub … Docs: https://ketoy.dev/docs/capabilities/android-apis
The walk is breadcrumbed: when a forbidden API is six calls deep, the diagnostic points at the leaf and prints the path back to the entry point so the developer knows which screen owns the problem.
The single most important property of the compiler plugin - and the thing that makes Ketoy adoption realistic for existing apps.
When you adopt Ketoy, you don’t migrate your whole app. You annotate a few entry points, and the plugin walks the transitive closure rooted at each marker.
@KetoyViewModel-marked class and the plain Kotlin types it touches.@KetoyCapabilityStub-marked declaration - becomes INVOKE_CAPABILITY* opcodes pointing at host-registered functions.Application class, your MainActivity, your Android service classes.Consider:
val title = "Hello"
Column { Text(title) }The Column { Text(title) } lambda is a content slot - Compose inserts it into the slot table at render time. When we emit it as a KBC function, it gets its own function index. Fine. But inside the lambda, Text(title) reads title from the outer scope. A naive emit produces an unbound-local error.
The fix is closure conversion at IR time. A ClosureAnalyzer walks the lambda body looking for outer-scope reads. Those become captured registers, prepended to the lambda’s formal parameters. At the call site, we emit ClosureRef(fnIdx, [r3, r7, ...]) - a KBCValue variant carrying parent-frame register indices.
The runtime snapshots each captured register eagerly at slot-resolution time, then prepends those snapshots to the lambda’s argument list. Compose lambdas can fire arbitrarily late; eager snapshots match Kotlin’s actual closure semantics - the lambda sees what title meant at the call site.
LazyColumn was the bonus boss fight. items(list) { item -> Card(item) } takes both an iterator variable and outer captures. The runtime exposes a getItemContentSlot(idx) resolver that snapshots captures eagerly, then appends item after them, then dispatches. Same mechanism, one more parameter slot.
We keep this honest with a dedicated :ketoy-closure-fixtures module shipping audited capture patterns - single capture, multi-capture, navigation-onClick capture, nested two-level capture, conditional capture, non-capturing baseline - exercised on every release to catch regressions before they hit production.
So a COMPOSABLE_CALL(adapterId = BUTTON, paramCount = 10, …) reaches the runtime. Who calls androidx.compose.material3.Button for real? A typed adapter.
KBCComposableAdapter(
id = KBCAdapterIds.BUTTON,
fqName = "androidx.compose.material3.Button",
paramCount = 10,
) { p ->
Button(
onClick = p.getLambda(0) ?: {},
modifier = p.getModifier(1),
enabled = if (p.isDefaulted(2)) true else p.getBool(2, true),
shape = p.getShape(3) ?: ButtonDefaults.shape,
// ... five more typed parameters with proper defaults ...
) {
p.getContentSlot(9)?.invoke()
}
}KBCParamSet is the typed bridge between the binary KBCValue encoding and real Compose types. It exposes 30+ typed getters - getString, getColor, getDp, getModifier, getFontWeight, getTextStyle, getShape, getKeyboardType, getImeAction, getLambda, getContentSlot, getItemContentSlot, getPaddingValuesContentSlot, getImageVector, getFontFamily, and the rest - each one a sealed when() dispatch with no reflection, no string parsing, no positional List<Any?> contracts.
We originally hand-rolled the adapters. That died exactly the way you’d expect: Button has 10 parameters, our hand-roll had 4; TextField has 22, our hand-roll had 5; Material3 1.5 added minLines, Material3 1.7 deprecated autoCorrect; every time someone hand-counted paramCount = 4, six styling levers silently disappeared from the remoted version.
The fix is the ComposeAdapterCatalog, generated by KSP from the real function declarations on the build classpath. The catalog knows, per adapter: adapter ID, FQ name, parameter count - all read from real KSP type info, never hand-counted. A ParamDescriptor per parameter: source index, name, type FQ name, classified ParamKind, nullability, whether the parameter has a default in the Compose source. For content slots, the receiver scope (plain, ColumnScope, RowScope, BoxScope, PaddingValues).
Today the catalog ships full typed coverage for Text, Column, Row, Box, Scaffold, Surface, Card, Spacer, Button, IconButton, TextField, Checkbox, Switch, AsyncImage, TopAppBar, Icon, and the matching constructor adapters for KeyboardOptions, KeyboardActions, RoundedCornerShape, and the Material3 *Defaults.colors() / *Defaults.elevation() factory family.
Adding a new component is one line in the scan-roots file plus ./gradlew :ketoy-adapters-material3:kspRelease. We made it easier to add components than to argue about whether to add them. KBCAdapterIds.APP_SPECIFIC_START (0x4000) reserves the upper ID space for host apps to register their own.
A .ktx is a Brotli-compressed, Ed25519-signed binary container. Every screen, every helper, every view model, every entry point collapses into a single bundle. One file, a handful of kilobytes, signed once, shipped from your CDN to every user.
Header (14 bytes) magic = "KTOY" formatVersion = u16 minRuntimeVer = u16 flags = u32 // DEBUG_INFO_PRESENT, UNSIGNED sectionCount = u16 Sections (ordered, compressed or raw per kind) STRING_POOL // UTF-8, deduped, indexed by u16 ADAPTER_MANIFEST // every adapter ID the bundle uses CONSTRUCTOR_MANIFEST // every constructor ID the bundle uses CAPABILITY_MANIFEST // every capability ID the bundle calls MODIFIER_TABLE // modifier descriptor blobs (Brotli) FUNCTION_TABLE // function metadata: name, regs, params, flags CODE // raw KBC bytecode for every function (Brotli) DEBUG_INFO // source line numbers (optional) ENTRY_POINTS // exported function names BUNDLE_METADATA // bundle ID, descriptors, minAppVersion Trailer signature = 64 bytes // Ed25519 over everything above
LOAD_INT opcode is 6 bytes total; a sparse parameter is “one byte index plus the KBCValue payload.” For a bundle that ships an entire app’s worth of screens in single-digit kilobytes, every byte matters.minAppVersion after shipping v2, instead of bumping the format version we appended an Int to BUNDLE_METADATA and made the reader fall back to 0 when fewer than 4 bytes remain. Costs you exactly four bytes.The runtime supports four bundle sources, modelled as a sealed type:
Preloaded(bundle) - already parsed in memory.Raw(bytes) - parse through parseBundle.Asset(path) - read from the APK assets, trust anchored to your APK.Remote(url, headers) - HTTPS fetch with on-device ETag caching to context.cacheDir/ketoy_bundles/<sha256(url)>.ktx plus a sidecar .etag. 304 serves cache. Network down? Serve cache. No cache and offline? Throw cleanly.The host APK ships a 32-byte Ed25519 public key in assets/ or res/raw/. The private key lives in your CI secret store and signs each bundle at build time via the ketoyBundle Gradle task. The runtime refuses any bundle whose signature doesn’t verify.
Bundles only activate at process start. Two slots exist per bundleId: an active slot (the bundle in memory powering every KetoyScreen, set once at process start, never reassigned) and a staged slot (downloaded, verified, validated, minAppVersion-checked, lives on disk, becomes active on next cold start).
We do not hot-swap bundles mid-composition for three concrete reasons: view models carry live state (viewModelScope coroutines, in-flight Flow collectors, SavedStateHandle contents); capability ID stability is per-bundle; composition continuity matters more than freshness. Users tolerate “the new screen appears next time I open the app” far better than “the form I was filling out reloaded and lost my input.”
Every bundle carries a minAppVersion: Int. When the runtime loads a bundle, it compares against the host APK’s PackageInfo.longVersionCode. If the bundle requires a newer host, the runtime does not activate it. Instead it walks the bundle cache MRU-first for the most recent last-known-good entry whose minAppVersion is satisfied. If none exists, every KetoyScreen falls through to its native fallback.
When your app starts, a single KetoyRuntime instance gets created (typically via Hilt). It holds three registries - CapabilityRegistry, KBCAdapterRegistry, KBCConstructorRegistry - populated at startup with every capability and adapter the host wants to expose.
composable("checkout") {
KetoyScreen(entryPoint = "CheckoutScreen") {
CheckoutScreenNative() // native fallback — required trailing lambda
}
}The runtime resolves the active bundle, looks up the entry point by name, constructs a KetoyVirtualViewModel (a real Android ViewModel wrapping a KetoyVM tied to viewModelScope), and dispatches the entry-point function. Bundle absent or entry-point missing? Run the trailing lambda - no error UI, no spinner.
while (true) {
val opcode = code[pc].toInt() and 0xFF
when (opcode) {
LOAD_INT -> regs[dst] = readInt(code, pc + 2); pc += 6
ADD_INT -> regs[dst] = regs[a].toInt() + regs[b].toInt(); pc += 4
JUMP_IF_TRUE -> pc = if (regs[r] == true) target else pc + 4
COMPOSABLE_CALL -> dispatchComposable(...)
SUSPEND_POINT -> return SUSPENDED
// ... 100+ more
}
}The real version dispatches 107 opcodes, handles exception handler stacks, suspend state machines, dev-overlay event emission, and Tier-1 JIT short-circuiting. The skeleton is honest.
Suspend functions don’t have a “just call them” representation in any bytecode. In regular Kotlin, the compiler lowers each suspend function into a class with a label field, a switch on the label, and a sequence of Continuation objects. We do the equivalent at KBC level - emitting INVOKE_CAPABILITY_SUSPEND, SUSPEND_POINT, and RESUME_VALUE opcodes.
The KBCCoroutineEngine owns a real SupervisorJob(viewModelScope.coroutineContext.job). This works for real coroutines all the way down: launch, async, await, withContext(Dispatchers.IO), Flow.collect, collectAsState, LaunchedEffect, MutableStateFlow, MutableSharedFlow, the full Flow operator set - every one wired to genuine kotlinx primitives. Put a delay(1000) inside KBC and it behaves the way you’d hope.
Compose is opinionated: only @Composable functions can read snapshot state, call other composables, install effects. The KBC interpreter isn’t @Composable. So we use a two-phase model:
COMPOSE_REMEMBER, COMPOSE_STATE, or COMPOSE_DERIVED_STATE, it creates real snapshot objects but queues side effects (LaunchedEffect, SideEffect, DisposableEffect) as PendingEffect records.@Composable): a thin wrapper walks the pending-effect queue and registers each effect the regular way. COMPOSABLE_CALL reaches an adapter invocation, the adapter calls real Button(...) / Text(...), and that participates in real recomposition.For hot paths, we ship an optional on-device JIT using DexMaker. A KBCProfiler counts function invocations; when one crosses the hot threshold (default: 100 calls), the JIT compiles it asynchronously. DexMaker generates raw DEX bytecode; InMemoryDexClassLoader loads it from a ByteBuffer. The compiled method becomes a regular reflective invocation target.
The translator covers a whitelist of “pure logic” opcodes: loads, arithmetic for int/long/float/double, comparisons, jumps, basic casts. Capabilities, coroutines, Compose ops, integer DIV/MOD - those stay in the interpreter, and the JIT bails cleanly. Benchmark gate: ≥1.5× speedup on tight arithmetic loops. JIT failures never propagate. API 25 and below get no JIT (DexClassLoader requirement) - the interpreter remains the path of record.
This is also the place where Play Store’s DPA §4.4 stays clean - KBC is not downloaded executable bytecode, it’s our own bytecode interpreted on-device, and the JIT generates fresh DEX on the user’s own device (the same thing ART does constantly). We never download pre-built DEX or JVM bytecode.
Two layers, both load-bearing. Runtime side: KetoyVirtualViewModel is a real Android ViewModel that owns a KetoyVM per screen. It exposes state: StateFlow<Map<String, Any?>>, the typical getState/setState/observeState surface, and a dispatch(eventName, payload) entry point that invokes the KBC function registered for that event.
KBC side: developers extend KetoyBaseViewModel from @KetoyViewModel-annotated classes. Four lateinit properties get bound by the runtime after construction: viewModelScope, plus the getState / setState / observeState lambda surface. The view model class compiles to KBC. Its viewModelScope is the same scope as the host KetoyVirtualViewModel - coroutines launched there cancel when the screen leaves the back stack.
Every other piece of the runtime cooperates to enforce one property: a .ktx bundle cannot do anything the host hasn’t explicitly sanctioned.
KBC code cannot touch android.* / androidx.* directly, read files via java.io / java.nio / kotlin.io, use kotlin.reflect.*, launch coroutines outside the host-provided scope, or open sockets. If your KBC source tries, the compiler rejects it. So how does anything actually happen on-device? Capabilities.
@KetoyCapabilityStub(id = 0x4001, name = "ObserveTodos") fun TodoRepository.observeTodos(): Flow<List<Todo>> = throw NotImplementedError()
The compiler plugin emits INVOKE_CAPABILITY_FLOW 0x4001 instead of a direct call. The runtime dispatches to the registered Kotlin function on the host’s actual Hilt-injected repository. Capabilities come in four flavours: sync, suspend, flow, and @Composable. We ship 67 built-in IDs:
override fun buildRegistry(): CapabilityRegistry =
CapabilityRegistry().apply {
registerCoreCapabilities(context, dataStore = settingsStore)
registerNavigationCapabilities(navigator)
KBCRoomBridge(this) {
observeList(OBSERVE_TODOS) { todoDao.observeAll() }
findOne(FIND_TODO) { args -> todoDao.find(args[0] as Long) }
mutate(TOGGLE_TODO) { args -> todoDao.toggle(args[0] as Long) }
}
registerFlow(OBSERVE_DARK_MODE) { _ -> settings.darkModeFlow }
registerSuspend(SET_DARK_MODE) { args -> settings.setDarkMode(args[0] as Boolean) }
}Capabilities are the only door from KBC into the outside world. That gives you a clean security audit point: review which capabilities your host registers, and that’s exhaustively the set of things any future bundle (signed by your CDN’s key) can ever do. Even a perfectly valid signed bundle can only do what the host exposes.
The codebase is structured around the responsibility boundaries we’ve walked through.
A typical host app pulls ketoy-runtime, ketoy-hilt, ketoy-capabilities-core, ketoy-capabilities-navigation, and ketoy-adapters-material3. The BOM (dev.ketoy.vm:ketoy-bom) lets consumers pin one version line for the whole stack. All published modules ship to Maven Central under dev.ketoy.vm as a single atomic release - every module’s coordinates land together in one Sonatype Portal staging deployment.
End to end.
./gradlew :app:assembleRelease
id("dev.ketoy.compiler")), declares the BOM, and configures the ketoy { … } block.compileReleaseKotlin task.@KetoyEntryPoint-annotated screens trigger the closure walk: every reachable composable, helper, view model, data class, and helper function is pulled in.KtxWriter serialises everything to one .ktx file. Ed25519Signer appends the signature.ketoyBundle task copies the signed .ktx into src/main/assets/ketoy/main.ktx. The host APK packages it like any other asset.Two modes are supported. The default for new adopters is in-tree mode: the bundle source lives inside your app module, the closure walk keeps native code and KBC code disjoint, and the plugin auto-wires asset merge dependencies. A dedicated-module mode is available for teams that want bundle source physically separated from the host. For production updates, you upload the same .ktx to your CDN. The runtime’s Remote source picks it up on next cold start, ETag-cached, signature-verified, and rolled back automatically if it requires a newer host APK than the user has installed.
We ship a ketoy-test artifact with three primitives that make KBC code unit-testable without an emulator.
KBCBuilder - a DSL that emits raw KBC instructions (loadInt(r0, 5), addInt(r0, r0, r1), composableCall(BUTTON) { … }) so you can hand-author tiny bundles for tests without going through the full compiler.FakeAdapterRegistry and FakeConstructorRegistry - record-only doubles that capture every dispatched call with its KBCParamSet. Assert on call count, captured parameters, or supply per-call substitutions.KetoyTestRuntime - a JVM-friendly runtime wrapper using a debug KetoyConfig (no signature verification, JIT off, dev tools on). Most unit tests run on plain JUnit5 with no Android emulator.For end-to-end checks - real Compose lowering, real .ktx round-trip, real captures - we use kctfork, a library that drives the actual Kotlin compiler in-process, wired with the real Compose plugin. The dedicated :ketoy-closure-fixtures module ships audited capture patterns (single, multi, conditional, nested-lambda, navigation-onClick, non-capturing baseline) that get exercised on every release. Each fixture is real Kotlin source, compiled through the real pipeline, byte-inspected after.
For debug builds, the runtime emits a live overlay showing every COMPOSABLE_CALL dispatched, every CONSTRUCT_JVM invoked, every capability called with arguments, recomposition counts per screen, and JIT-hot function counters.
The event bus is a MutableSharedFlow with DROP_OLDEST overflow, constructed only when the host APK has FLAG_DEBUGGABLE. Release builds never allocate the bus - every emit site is null-guarded so the disabled path is “not even a system clock read.” Zero overhead is the contract, not a goal.
Half of engineering is deciding what to leave out. These aren’t limitations - they’re the product.
GlobalScope or unstructured concurrency. Every coroutine is scoped to a parent. Cancellation is contractual.select { }, custom CoroutineContext elements, Kotlin/Native intrinsics, or any of the long tail.List<Any?> for composable parameters. Anywhere, ever. The whole Two-Layer Parameter System exists so we never recreate JSON SDUI’s “parse the parameter array and hope” failure mode.object singletons in execution paths. Everything is scoped to a KetoyRuntime instance - multi-tenant, multi-bundle ready by construction.A constrained KBC is what makes the rest of the model - fast updates, audited security, predictable performance, single-bundle delivery - work at all.
Let’s trace one rendered button end to end.
@KetoyEntryPoint
@Composable
fun CheckoutScreen() {
Button(
onClick = { /* dispatch */ },
enabled = true,
modifier = Modifier.padding(16.dp),
) {
Text("Place Order", fontWeight = FontWeight.Bold)
}
}IrGenerationExtension registered by the Ketoy plugin sees the resulting IR.@KetoyEntryPoint CheckoutScreen and seeds the closure walk.Button, Modifier.padding, Text, and FontWeight.Bold via the catalog and token registry. The onClick lambda is non-capturing, so it lowers as a plain FunctionRef.COMPOSABLE_CALL for Text with StringLiteral("Place Order") at param 0 and FontWeightInt(700) at the fontWeight slot; a modifier descriptor entry [Padding(start=16, top=16, end=16, bottom=16)] interned into the modifier table; a COMPOSABLE_CALL for Button referencing the onClick FunctionRef, a ModifierRef, Bool(true), and a content slot for the inner Text.KtxWriter packs sections; Brotli compresses CODE and MODIFIER_TABLE; Ed25519 signs the trailer..ktx lands in app/src/main/assets/ketoy/main.ktx.KetoyRuntime loads on first injection. The Asset("ketoy/main.ktx") source is verified against the public key, parsed, validated against the registries.KetoyScreen(entryPoint = "CheckoutScreen") { CheckoutScreenNative() }. The runtime resolves the entry point, constructs the KetoyVirtualViewModel, and dispatches.COMPOSABLE_CALL BUTTON. Looks up the generated ButtonAdapter. The adapter calls real androidx.compose.material3.Button(...) with resolved params: onClick lambda backed by vm.launchFunction(fnIdx), modifier built from the cached table entry, enabled true, content slot wired to a small recursive interpret call for the inner Text.When the user taps the button, the onClick lambda routes through vm.launchFunction, the interpreter dispatches the lambda body, and any state writes flow back through VM_SET_STATE capabilities into the shared MutableStateFlow. Compose recomposes the affected slots. Everything else is exactly what would have happened in a native app.