https://github.com/vkrychun/stem-runtime-kotlin
Runtime engine for StemJSON - declarative language for AI
https://github.com/vkrychun/stem-runtime-kotlin
aar ai android backend-driven-ui declarative-ui jetpack-compose kotlin sdk server-driven-ui stemjson
Last synced: 5 days ago
JSON representation
Runtime engine for StemJSON - declarative language for AI
- Host: GitHub
- URL: https://github.com/vkrychun/stem-runtime-kotlin
- Owner: vkrychun
- License: other
- Created: 2026-05-14T09:16:36.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-06-05T12:17:22.000Z (12 days ago)
- Last Synced: 2026-06-05T14:10:53.640Z (12 days ago)
- Topics: aar, ai, android, backend-driven-ui, declarative-ui, jetpack-compose, kotlin, sdk, server-driven-ui, stemjson
- Homepage: https://stemjson.com
- Size: 2.81 MB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: .github/CONTRIBUTING.md
- License: LICENSE
- Security: .github/SECURITY.md
- Support: .github/SUPPORT.md
Awesome Lists containing this project
README
# StemRuntimeSDK
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.





---
## Table of Contents
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Zip-Packaged Modules](#zip-packaged-modules)
- [Core API](#core-api)
- [State Observation & Events](#state-observation--events)
- [Module Lifecycle](#module-lifecycle)
- [License & Watermark](#license--watermark)
- [Navigation Embedding](#navigation-embedding)
- [Custom Repositories](#custom-repositories)
- [Custom Services](#custom-services)
- [Error Handling](#error-handling)
- [Diagnostics & Logging](#diagnostics--logging)
- [Module JSON](#module-json)
- [Coroutines & Threading](#coroutines--threading)
- [Privacy & Security](#privacy--security)
- [Contributing](#contributing)
- [License](#license)
---
## Requirements
| 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.
---
## Installation
### Maven repository
Add the Maven mirror to `settings.gradle.kts`:
```kotlin
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://raw.githubusercontent.com/vkrychun/stem-runtime-kotlin/main") }
}
}
```
### App module
In your app module's `build.gradle.kts`:
```kotlin
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.2")
}
```
The AAR's POM and Module Metadata bring the transitive Compose, Coil, and Media3 graph — no extra `implementation` lines needed.
---
## Quick Start
```kotlin
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(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.
---
## Zip-Packaged Modules
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://` and take precedence over host-app resources with the same path.
- A zip without `main.json` at the root fails validation.
- `.strings` files under `localization/` back `l10n://` sources and the `localize(key, fallback)` expression function. The runtime falls back to the host app's resources if a key is missing.
See [StemJSON Specification §14](https://github.com/vkrychun/StemJSON/blob/main/spec/v1.0.md#14-package--distribution) for the full package format.
---
## Core API
### `StemRuntime`
The entry point. Create one per app or feature scope.
```kotlin
public class StemRuntime(
context: Context,
public val configuration: Diagnostics.Configuration = Diagnostics.Configuration(),
)
```
```kotlin
// 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:
```kotlin
val runtime = StemRuntime(context)
.navigationEmbedded()
.license("…")
.watermarkPosition(StemWatermarkPosition.TopEnd)
```
For repository / service / picker registration, see the [Custom Repositories](#custom-repositories) and [Custom Services](#custom-services) sections.
### Validation
```kotlin
public suspend fun validate(
bytes: ByteArray,
ignore: Set = emptySet(),
): StemValidationOutcome
```
`ignore` 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:
```kotlin
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:
```kotlin
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] : `. 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.
### `StemRender`
The renderable handle returned by `validate`. Embed it via its `@Composable Render()` function or read named context values through the indexed-access operator:
```kotlin
// 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.
---
## State Observation & Events
### Subscribe to a state key (callback form)
```kotlin
public fun subscribe(
key: String,
render: StemRender,
handler: (Any?) -> Unit,
): Closeable
```
```kotlin
val handle: Closeable = runtime.subscribe("cartCount", render) { value ->
updateBadge(value as? Long ?: 0L)
}
// later
handle.close()
```
### Stream state changes (Flow form)
```kotlin
public fun stream(key: String, render: StemRender): Flow
```
```kotlin
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`, or `Map`. 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.
### Trigger events from native code
```kotlin
public fun trigger(event: String, data: Any)
```
```kotlin
runtime.trigger("themeChanged", mapOf("mode" to "dark"))
```
`data` accepts any JSON-shaped value — a `Map` 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 `@{.}`. 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).
---
## Module Lifecycle
A module cannot terminate itself — it only mutates its own state. The host observes a sentinel state key and calls `kill`:
```kotlin
public suspend fun kill(render: StemRender)
```
```kotlin
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()`:
```kotlin
public fun release()
```
After `release()` the runtime instance is unusable; create a fresh `StemRuntime(context)` for a new feature scope.
---
## License & Watermark
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:
```kotlin
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.
---
## Navigation Embedding
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:
```kotlin
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.destination` must have `"type": "module"`.** A `scroll` / `vstack` placed there renders but its `events` (notably `onAppear`) 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.
---
## Custom Repositories
Register custom factories via:
```kotlin
public fun register(kind: StemDependencyKind, factory: StemDependencyFactory): StemRuntime
```
The factory receives a `StemDependencyContext` (`id`, `category`, `kind`, JSON-shaped `config: Map`) 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:
```kotlin
public interface StemRepository : StemDependable {
public suspend fun read(params: Map): Any?
public suspend fun create(params: Map): Any?
public suspend fun update(params: Map): Any?
public suspend fun delete(params: Map): Any?
}
```
`params` and return values are JSON-shaped (`String` / `Long` / `Double` / `Boolean` / `null` / `List` / `Map`). 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:
```kotlin
class ProductRepository(id: String, private val baseUrl: String) : StemRepositoryAdapter(id) {
override suspend fun read(params: Map): Any? = okHttp.get("$baseUrl/products")
}
```
Registration follows the same shape as services — either override a built-in kind, or define your own `StemDependencyKind`:
```kotlin
// 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`:
```json
{
"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` of JSON-shaped values:
```kotlin
class TickerRepository(
override val id: String,
private val socket: WebSocket,
) : StemRepositoryAdapter(id), StemListenable {
override fun listen(params: Map): Flow =
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.
### Custom photo picker
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:
```kotlin
class CuratedGalleryPicker(private val library: List) : StemPhotoPicker {
override suspend fun pick(
selectionLimit: Int,
mediaTypes: List,
): List = 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`](https://github.com/vkrychun/stem-examples-kotlin).
---
## Custom Services
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:
```kotlin
public interface StemService : StemDependable {
public suspend fun execute(input: Any?): Any?
}
```
`input` is the resolved JSON-shaped payload from the action — typically a `Map`, sometimes a primitive. Throw `StemActionException` to trigger `output.failure`.
```kotlin
class AnalyticsService(override val id: String) : StemService {
override suspend fun execute(input: Any?): Any? {
analytics.track(input as Map)
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:
```kotlin
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:
```json
{
"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`](https://github.com/vkrychun/stem-examples-kotlin).
---
## Error Handling
Validation surfaces a `StemValidationReport`; runtime failures inside repositories and services surface as `StemActionException`.
```kotlin
when (val outcome = runtime.validate(bytes)) {
is StemValidationOutcome.Success -> render = outcome.render
is StemValidationOutcome.Failure -> Log.e("Stem", outcome.report.render())
}
```
`StemActionException`:
```kotlin
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.
---
## Diagnostics & Logging
```kotlin
public object Diagnostics {
public data class Configuration(
val enabled: Boolean = true,
val minLevel: StemSeverity = StemSeverity.NOTE,
)
}
```
```kotlin
// 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:
```bash
adb logcat -s com.stem.runtime.sdk
```
The 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.
---
## Module JSON
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 }}`).
```json
{ "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**](https://github.com/vkrychun/StemJSON/blob/main/spec/v1.0.md).
---
## Coroutines & Threading
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` / `.delete`
- `StemService.execute(input)`
- `StemListenable.listen(params)` returns a `Flow`
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` whose values are JSON-shaped Kotlin primitives, `List`, or `Map`. Cast to your expected type inside the collector. The flow is `distinctUntilChanged`, so identical consecutive values do not re-emit.
---
## Privacy & Security
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:
1. Add any extra permissions your StemJSON modules cause — for example `ACCESS_FINE_LOCATION` / `ACCESS_COARSE_LOCATION` if you use the built-in `location` service.
2. Drive the runtime-permission flow for sensitive permissions (`POST_NOTIFICATIONS`, location, photos). The SDK reads `ContextCompat.checkSelfPermission` at action-fire time, so any grant takes effect on the next action invocation.
3. 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`](.github/SECURITY.md).
---
## Contributing
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`](.github/CONTRIBUTING.md) and [`SECURITY.md`](.github/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`](https://github.com/vkrychun/StemJSON).
---
## License
Distributed under a Proprietary Freeware License. Unlicensed builds display a small "Powered by StemJSON" badge on physical devices.
See [`LICENSE`](LICENSE) for the EULA and [`THIRD_PARTY_LICENSES.md`](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](https://github.com/vkrychun/StemJSON).
Pricing: [stemjson.com/stemruntime#pricing](https://stemjson.com/stemruntime#pricing). Commercial enquiries: [vkrychun@stemjson.com](mailto:vkrychun@stemjson.com).