Your AI can now ship complete native Android features, not just code snippets. StemJSON is a declarative language describing a full feature — screens, interactions, data, navigation — and StemRuntimeSDK runs it as native Jetpack Compose on-device. AI authors the feature; users get native Android.
- Requirements
- Installation
- Quick Start
- Zip-Packaged Modules
- Core API
- State Observation & Events
- Module Lifecycle
- License & Watermark
- Navigation Embedding
- Custom Repositories
- Custom Services
- Error Handling
- Diagnostics & Logging
- Module JSON
- Coroutines & Threading
- Privacy & Security
- Contributing
- License
| Dependency | Minimum |
|---|---|
| Android | 7.0 (API 24) |
| compileSdk | 35 |
| Kotlin | 2.0 |
| Android Gradle Plugin | 8.0 |
| Jetpack Compose | BoM 2024.12.01 |
| Core-library desugaring | enabled (desugar_jdk_libs 2.0+) |
Desugaring is required because the SDK uses java.time.* on minSdk 24. Without it, :checkDebugAarMetadata fails with a clear message.
Add the Maven mirror to settings.gradle.kts:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://raw.githubusercontent.com/vkrychun/stem-runtime-kotlin/main") }
}
}In your app module's build.gradle.kts:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
compileSdk = 35
defaultConfig { minSdk = 24 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
buildFeatures { compose = true }
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
implementation("com.stemjson:stem-runtime-sdk:1.0.0")
}The AAR's POM and Module Metadata bring the transitive Compose, Coil, and Media3 graph — no extra implementation lines needed.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import com.stemjson.runtime.StemRender
import com.stemjson.runtime.StemRuntime
import com.stemjson.runtime.StemValidationOutcome
class DashboardActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MaterialTheme { Dashboard() } }
}
}
@Composable
private fun Dashboard() {
val context = LocalContext.current
val runtime = remember { StemRuntime(context) }
var render by remember { mutableStateOf<StemRender?>(null) }
LaunchedEffect(Unit) {
val bytes = context.assets.open("dashboard.json").use { it.readBytes() }
val outcome = runtime.validate(bytes)
if (outcome is StemValidationOutcome.Success) render = outcome.render
}
render?.Render() ?: CircularProgressIndicator()
}Three steps in practice: create a runtime, validate JSON bytes (single file or zip-packaged module), embed the returned render — StemRender.Render() is a @Composable function. The SDK accepts either a single .json file or a zip-packaged module and picks the loader from the byte stream — no flag required.
Use a zip when a module needs bundled assets, localisation, or sub-modules.
my_feature.zip
├── main.json ← required — the module root
├── details.json ← sub-module, loaded via file://details.json
├── localization/
│ ├── en.strings ← "key" = "value"; format
│ └── uk.strings
└── assets/
└── logo.png ← loaded via file://assets/logo.png
- Package resources are referenced with
file://<relative-path>and take precedence over host-app resources with the same path. - A zip without
main.jsonat the root fails validation. .stringsfiles underlocalization/backl10n://sources and thelocalize(key, fallback)expression function. The runtime falls back to the host app's resources if a key is missing.
See StemJSON Specification §14 for the full package format.
The entry point. Create one per app or feature scope.
public class StemRuntime(
context: Context,
public val configuration: Diagnostics.Configuration = Diagnostics.Configuration(),
)// Default — picks up the application context for you
val runtime = StemRuntime(context)
// With diagnostics
val runtime = StemRuntime(
context,
Diagnostics.Configuration(enabled = true, minLevel = StemSeverity.WARNING),
)All chainable configurators return this, so they compose fluently:
val runtime = StemRuntime(context)
.navigationEmbedded()
.license("…")
.watermarkPosition(StemWatermarkPosition.TopEnd)For repository / service / picker registration, see the Custom Repositories and Custom Services sections.
public suspend fun validate(
bytes: ByteArray,
ignore: Set<StemSeverity> = emptySet(),
): StemValidationOutcomeignore suppresses the listed severity levels from causing a Failure (e.g. setOf(StemSeverity.WARNING, StemSeverity.NOTE)).
StemValidationOutcome is a sealed interface; the validation report is present on both branches — even a successful validation may carry advisory notes/warnings:
public sealed interface StemValidationOutcome {
public val report: StemValidationReport
public data class Success(val render: StemRender, override val report: StemValidationReport) : StemValidationOutcome
public data class Failure(override val report: StemValidationReport) : StemValidationOutcome
public fun renderOrNull(): StemRender?
public fun onSuccess(block: (StemRender) -> Unit): StemValidationOutcome
public fun onFailure(block: (StemValidationReport) -> Unit): StemValidationOutcome
}Typical handling:
runtime.validate(bytes)
.onSuccess { render = it }
.onFailure { Log.w("Stem", it.render()) }StemValidationReport.render() is the formatted, human- and machine-readable form:
=== Validation Report: 2 errors, 1 warning ===
[V002] [error] login_btn V002: Value 'repositoryId' is missing
[N007] [warning] product_list N007: Endpoint returned 404 for products.json
Each line: [code] [severity] <path> <code>: <message>. The format is designed for AI-in-the-loop authoring: feed the report back to the model and it will revise the StemJSON module until validation passes.
The renderable handle returned by validate. Embed it via its @Composable Render() function or read named context values through the indexed-access operator:
// 1. Embed in Compose
render.Render()
// 2. Read named context values declared in the module's JSON `context`
val title: String? = render["title"]
val icon: String? = render["icon"]render.Render() is the only composition entry point. It runs the SDK integrity checks and wraps the module body with the unlicensed-build watermark on every composition — both happen unconditionally and cannot be skipped by host code.
Each StemRender carries a stable unitId so it can be used as a key in LaunchedEffect, DisposableEffect, remember(render.unitId) { … }, and Compose recomposition.
public fun subscribe(
key: String,
render: StemRender,
handler: (Any?) -> Unit,
): Closeableval handle: Closeable = runtime.subscribe("cartCount", render) { value ->
updateBadge(value as? Long ?: 0L)
}
// later
handle.close()public fun stream(key: String, render: StemRender): Flow<Any?>LaunchedEffect(render.unitId) {
runtime.stream("cartCount", render).collect { value ->
updateBadge(value as? Long ?: 0L)
}
}Emitted values are JSON-shaped — null, String, Long, Double, Boolean, List<Any?>, or Map<String, Any?>. Cast to your expected type inside the collector. The stream is distinctUntilChanged, so identical consecutive values do not re-emit.
runtime.subscribe is preferable when the consumer is not already inside a coroutine scope; runtime.stream is preferable when composing with other flows or applying operators.
public fun trigger(event: String, data: Any)runtime.trigger("themeChanged", mapOf("mode" to "dark"))data accepts any JSON-shaped value — a Map<String, Any?> payload is typical, but a primitive or list is also fine. The value is bound into the matching onCustom handler's context. Inside the module JSON, read fields as @{<action.id>.<field>}. Always pass every field the handler needs in the payload — path predicates with @{…} are not supported inside filter values (see StemJSON spec §6.2.1).
A module cannot terminate itself — it only mutates its own state. The host observes a sentinel state key and calls kill:
public suspend fun kill(render: StemRender)LaunchedEffect(render.unitId) {
runtime.stream("onClose", render).collect { value ->
if (value == true) {
runtime.kill(render)
onDismiss()
}
}
}kill resets the rendered module to its initial JSON state. The render handle stays valid — composing it again shows the module fresh, with an empty navigation stack and the JSON-declared initial state. Inherited context, registered dependencies, named actions, packaged localisation, and the component palette are retained.
To fully release every runtime-held resource (cancel coroutines, drop every store), call release():
public fun release()After release() the runtime instance is unusable; create a fresh StemRuntime(context) for a new feature scope.
Unlicensed builds display a small "Powered by StemJSON" badge over every rendered module on physical devices. A valid license key suppresses the badge; the corner is configurable:
val runtime = StemRuntime(context)
.license("a1b2c3d4e5f6…") // suppresses the badge
.watermarkPosition(StemWatermarkPosition.TopEnd) // moves it (no-op when licensed)StemWatermarkPosition values: BottomEnd (default), BottomStart, TopEnd, TopStart. End/Start honour the device locale's layout direction.
The license key is issued for your app's packageName; an invalid or wrong-package key is dropped silently. Removing the badge requires a valid key — watermarkPosition only moves it.
By default a module creates its own navigation stack. When the module is pushed inside a host navigation flow, call .navigationEmbedded() so internal navigation components participate in the host's stack instead:
val runtime = StemRuntime(context).navigationEmbedded()With this enabled, link destinations and navigate push actions land on the host's stack; system back and pop operations sync automatically.
link.destinationmust have"type": "module". Ascroll/vstackplaced there renders but itsevents(notablyonAppear) will not fire. Always wrap pushed layouts as{ "type": "module", "state": {…}, "children": [ … ] }. See StemJSON spec §link.
Do not use .navigationEmbedded() for self-contained modules (tab root, modal presentation) — they manage their own navigation.
Register custom factories via:
public fun register(kind: StemDependencyKind, factory: StemDependencyFactory): StemRuntimeThe factory receives a StemDependencyContext (id, category, kind, JSON-shaped config: Map<String, Any?>) and returns a StemDependable.
Built-in repositories, registered automatically and overrideable:
| Key | Built-in implementation |
|---|---|
StemRepositoryType.REMOTE |
OkHttp HTTP/REST client |
StemRepositoryType.LOCAL |
SQLite-backed document storage |
StemRepositoryType.SECURED |
EncryptedSharedPreferences via Tink |
StemRepositoryType.PHOTOS |
Android Photo Picker — StemRender.Render() auto-registers the launcher; no host wiring needed |
The StemRepository contract:
public interface StemRepository : StemDependable {
public suspend fun read(params: Map<String, Any?>): Any?
public suspend fun create(params: Map<String, Any?>): Any?
public suspend fun update(params: Map<String, Any?>): Any?
public suspend fun delete(params: Map<String, Any?>): Any?
}params and return values are JSON-shaped (String / Long / Double / Boolean / null / List<Any?> / Map<String, Any?>). Throw StemActionException(code, message) to route to the action's output.failure chain — code and message get bound under @{actionId.code} / @{actionId.message}.
Implement only the operations you support by extending StemRepositoryAdapter — unimplemented methods throw unsupportedOperation for you:
class ProductRepository(id: String, private val baseUrl: String) : StemRepositoryAdapter(id) {
override suspend fun read(params: Map<String, Any?>): Any? = okHttp.get("$baseUrl/products")
}Registration follows the same shape as services — either override a built-in kind, or define your own StemDependencyKind:
// Override the built-in REMOTE repository for every module that declares `kind: "remote"`
runtime.register(StemRepositoryType.REMOTE) { ctx ->
ProductRepository(ctx.id, ctx.config["baseUrl"] as String)
}
// Or define a new repository kind:
enum class AppRepository(override val kind: String) : StemDependencyKind {
PRODUCTS("products");
override val category: String = "repository"
}
runtime.register(AppRepository.PRODUCTS) { ctx ->
ProductRepository(ctx.id, ctx.config["baseUrl"] as String)
}The matching JSON dependencies entry references it by category + kind:
{
"dependencies": [
{
"id": "productsApi",
"category": "repository",
"kind": "products",
"config": { "baseUrl": "https://example.com/v1" }
}
]
}For streaming sources (WebSocket, Firestore listener, SSE), also implement StemListenable to back the spec's listen action. listen(params) returns a Flow<Any?> of JSON-shaped values:
class TickerRepository(
override val id: String,
private val socket: WebSocket,
) : StemRepositoryAdapter(id), StemListenable {
override fun listen(params: Map<String, Any?>): Flow<Any?> =
socket.observe(channel = params["channel"] as String)
}The dispatcher cancels the underlying coroutine when the listen action is cancelled or the module is killed, so cooperatively-cancellable collect is all the implementation needs.
The photos repository works out of the box — StemRender.Render() registers an Android Photo Picker launcher for the lifetime of every composition. Override it only to swap in an in-app gallery, a pre-curated set, or a test double. Implement StemPhotoPicker.pick(selectionLimit, mediaTypes) to return a list of URI strings:
class CuratedGalleryPicker(private val library: List<String>) : StemPhotoPicker {
override suspend fun pick(
selectionLimit: Int,
mediaTypes: List<String>,
): List<String> = library.take(selectionLimit)
}
runtime.setPhotoPicker(CuratedGalleryPicker(myUriList))setPhotoPicker returns the runtime for fluent chaining and accepts null to clear a previously installed custom picker (the runtime falls back to the auto-registered default).
Worked patterns live in stem-examples-kotlin.
Services handle operations outside CRUD semantics — analytics, biometrics, camera, deep links, health, and so on.
Built-in services, registered automatically:
| Key | Built-in implementation |
|---|---|
StemServiceType.AUDIO |
System sounds and haptics (uses VIBRATE) |
StemServiceType.PUSH |
Local notifications (uses POST_NOTIFICATIONS on API 33+) |
StemServiceType.LOCATION |
FusedLocationProviderClient — requires ACCESS_FINE_LOCATION / ACCESS_COARSE_LOCATION host-side |
The StemService contract:
public interface StemService : StemDependable {
public suspend fun execute(input: Any?): Any?
}input is the resolved JSON-shaped payload from the action — typically a Map<String, Any?>, sometimes a primitive. Throw StemActionException to trigger output.failure.
class AnalyticsService(override val id: String) : StemService {
override suspend fun execute(input: Any?): Any? {
analytics.track(input as Map<String, Any?>)
return null // fire-and-forget — return a Map / List / primitive for output.success
}
}Define a new dependency kind for services the SDK doesn't ship as a built-in, then register your factory against it:
enum class AppService(override val kind: String) : StemDependencyKind {
ANALYTICS("analytics"),
BIOMETRICS("biometrics");
override val category: String = "service"
}
runtime.register(AppService.ANALYTICS) { ctx ->
AnalyticsService(ctx.id)
}The matching JSON dependencies entry uses the category + kind discriminator:
{
"dependencies": [
{ "id": "appAnalytics", "category": "service", "kind": "analytics" }
]
}To replace a built-in (a host with its own audio engine, a test double for push, etc.), pass the built-in StemServiceType to register instead of your custom enum — the rest of the call is identical.
Worked patterns live in stem-examples-kotlin.
Validation surfaces a StemValidationReport; runtime failures inside repositories and services surface as StemActionException.
when (val outcome = runtime.validate(bytes)) {
is StemValidationOutcome.Success -> render = outcome.render
is StemValidationOutcome.Failure -> Log.e("Stem", outcome.report.render())
}StemActionException:
public class StemActionException(public val code: String, message: String) : RuntimeException(message)Throw it from any StemRepository / StemService / StemListenable implementation to route the action chain to its output.failure handler. The action binds @{actionId.code} and @{actionId.message} from the exception. Code values follow the catalogue in StemJSON spec §15 — e.g. notAuthenticated, notFound, noConnection, keychainAccess. Any other thrown exception is treated as an unknown failure with code = "unknown" and the exception's message.
StemIssueCode (returned in StemIssue entries inside the report) is the validator-side catalogue: V### for structural issues, E### for general, N### for network, S### for storage, K### for security. The code string is stable across releases.
public object Diagnostics {
public data class Configuration(
val enabled: Boolean = true,
val minLevel: StemSeverity = StemSeverity.NOTE,
)
}// Default — enabled, minLevel = NOTE
val runtime = StemRuntime(context)
// Quieter — drop notes and below
val runtime = StemRuntime(
context,
Diagnostics.Configuration(minLevel = StemSeverity.WARNING),
)
// Silence
val runtime = StemRuntime(
context,
Diagnostics.Configuration(enabled = false),
)Severity ladder (StemSeverity): BINGO, INFO, NOTE, WARNING, ERROR, CRITICAL. ERROR and CRITICAL block rendering.
Messages are emitted through android.util.Log under the tag com.stem.runtime.sdk. Filter with:
adb logcat -s com.stem.runtime.sdkThe runtime also routes every validation finding through the logger, so a host that never inspects the StemValidationReport still sees the validator's work in logcat.
StemJSON modules are a declarative tree: every component has a type, optional context, optional state, and optional children. Values anywhere in the tree may be static, state-bound (${field}), context-bound (@{key}), or expression-evaluated ({{ expr }}).
{ "id": "email_field", "type": "textfield",
"context": { "_label": "Email", "_text": "${email}" } }For the full component catalogue, value syntax, style options, and action types see the StemJSON v1.0 Specification.
The runtime's internal scope uses Dispatchers.Main.immediate for its supervisor job. State emissions, the custom-event bus, and action dispatch hop through that scope.
suspend entry points:
runtime.validate(bytes, ignore)runtime.kill(render)StemRepository.read/.create/.update/.deleteStemService.execute(input)StemListenable.listen(params)returns aFlow<Any?>
Repository and service implementations are invoked from the main dispatcher — long-running work should be moved off-main internally with withContext(Dispatchers.IO) { … } so the UI stays responsive.
runtime.stream(key, render) returns a cold Flow<Any?> whose values are JSON-shaped Kotlin primitives, List<Any?>, or Map<String, Any?>. Cast to your expected type inside the collector. The flow is distinctUntilChanged, so identical consecutive values do not re-emit.
StemRuntimeSDK runs entirely on-device. It contains no telemetry, no analytics, and no phone-home behaviour. The SDK transmits no data to Licensor.
The SDK's AndroidManifest.xml declares only two permissions, both for documented runtime features:
| Permission | Why |
|---|---|
android.permission.VIBRATE |
audio service haptic playback |
android.permission.POST_NOTIFICATIONS |
push service local notifications (API 33+) |
For your Application, remember to:
- Add any extra permissions your StemJSON modules cause — for example
ACCESS_FINE_LOCATION/ACCESS_COARSE_LOCATIONif you use the built-inlocationservice. - Drive the runtime-permission flow for sensitive permissions (
POST_NOTIFICATIONS, location, photos). The SDK readsContextCompat.checkSelfPermissionat action-fire time, so any grant takes effect on the next action invocation. - Disclose data flows your StemJSON modules cause (Keychain-equivalent secured storage, network requests, etc.) in your Play Store data-safety form.
To report a security vulnerability, see SECURITY.md.
This repository ships StemRuntimeSDK as a pre-compiled AAR. SDK source code is proprietary and is not published here. Bug reports, documentation fixes, and security disclosures are welcome — see CONTRIBUTING.md and SECURITY.md.
The StemJSON data format was originated and authored by Vasyl Krychun and is published separately under the Open Web Foundation Agreement 1.0 at github.com/vkrychun/StemJSON.
Distributed under a Proprietary Freeware License. Unlicensed builds display a small "Powered by StemJSON" badge on physical devices.
See LICENSE for the EULA and THIRD_PARTY_LICENSES.md for the attribution of the incorporated open-source components. The StemJSON format itself — originated and authored by Vasyl Krychun — is governed by the OWFa 1.0; see the StemJSON spec repo.
Pricing: stemjson.com/stemruntime#pricing. Commercial enquiries: vkrychun@stemjson.com.