diff --git a/packages/kotlin/.gitignore b/packages/kotlin/.gitignore
new file mode 100644
index 000000000..24fce2426
--- /dev/null
+++ b/packages/kotlin/.gitignore
@@ -0,0 +1,5 @@
+.gradle/
+build/
+*/build/
+sample/android/build/
+sample/android/app/build/
diff --git a/packages/kotlin/README.md b/packages/kotlin/README.md
new file mode 100644
index 000000000..5e8562ea2
--- /dev/null
+++ b/packages/kotlin/README.md
@@ -0,0 +1,62 @@
+# OpenUI Kotlin
+
+Native Kotlin support for OpenUI Lang. The package is split into:
+
+- `openui-lang`: Kotlin component library definitions, prompt generation, OpenUI Lang parsing, and streaming parse state.
+- `openui-compose`: a Jetpack Compose renderer with a registry for host app components and action callbacks.
+- `sample/android`: a minimal Android app that parses an editable OpenUI Lang buffer and renders it with Compose.
+
+## Define a Library
+
+```kotlin
+val library = openUILibrary {
+ component(
+ name = "Root",
+ description = "Top-level screen container.",
+ props = listOf(PropDef("children", OpenUIType.ArrayOf(OpenUIType.Component("Component")))),
+ )
+ component(
+ name = "Button",
+ description = "Triggers app behavior.",
+ props = listOf(
+ PropDef("label", OpenUIType.StringType),
+ PropDef("action", OpenUIType.AnyType, required = false),
+ ),
+ )
+ root("Root")
+}
+
+val systemPrompt = library.prompt()
+```
+
+## Parse and Stream
+
+```kotlin
+val parser = OpenUIParser(library)
+val stream = parser.streamParser()
+
+val partial = stream.push("root = Root([")
+val complete = stream.push("Button(\"Save\")])")
+```
+
+## Render with Compose
+
+```kotlin
+OpenUIRenderer(
+ node = complete.root,
+ onAction = { action ->
+ // Route generated UI events into your Android app.
+ },
+)
+```
+
+The default Compose registry includes common primitives for layout, text, forms, buttons, lists, tables, and chart placeholders. Apps can register native components with `OpenUIComposeRegistry.register`.
+
+## Verify
+
+From `packages/kotlin`:
+
+```sh
+gradle :openui-lang:test
+gradle :sample:android:app:assembleDebug
+```
diff --git a/packages/kotlin/build.gradle.kts b/packages/kotlin/build.gradle.kts
new file mode 100644
index 000000000..3069cd628
--- /dev/null
+++ b/packages/kotlin/build.gradle.kts
@@ -0,0 +1,12 @@
+plugins {
+ kotlin("jvm") version "2.1.21" apply false
+ id("com.android.library") version "8.10.1" apply false
+ id("com.android.application") version "8.10.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.1.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.1.21" apply false
+}
+
+subprojects {
+ group = "dev.openui"
+ version = "0.1.0"
+}
diff --git a/packages/kotlin/openui-compose/build.gradle.kts b/packages/kotlin/openui-compose/build.gradle.kts
new file mode 100644
index 000000000..c183d5085
--- /dev/null
+++ b/packages/kotlin/openui-compose/build.gradle.kts
@@ -0,0 +1,26 @@
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+android {
+ namespace = "dev.openui.compose"
+ compileSdk = 35
+
+ defaultConfig {
+ minSdk = 23
+ }
+
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(project(":openui-lang"))
+ implementation(platform("androidx.compose:compose-bom:2025.05.01"))
+ implementation("androidx.compose.foundation:foundation")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.ui:ui")
+}
diff --git a/packages/kotlin/openui-compose/src/main/AndroidManifest.xml b/packages/kotlin/openui-compose/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..94cbbcfc3
--- /dev/null
+++ b/packages/kotlin/openui-compose/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/packages/kotlin/openui-compose/src/main/kotlin/dev/openui/compose/OpenUIComposeRenderer.kt b/packages/kotlin/openui-compose/src/main/kotlin/dev/openui/compose/OpenUIComposeRenderer.kt
new file mode 100644
index 000000000..ad00d8a6f
--- /dev/null
+++ b/packages/kotlin/openui-compose/src/main/kotlin/dev/openui/compose/OpenUIComposeRenderer.kt
@@ -0,0 +1,189 @@
+package dev.openui.compose
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.Divider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.openui.lang.ElementNode
+import dev.openui.lang.OpenUIValue
+import dev.openui.lang.asArrayOrEmpty
+import dev.openui.lang.asElementOrNull
+import dev.openui.lang.asStringOrNull
+
+public data class OpenUIAction(
+ public val component: String,
+ public val label: String?,
+ public val payload: OpenUIValue?,
+)
+
+public fun interface ComponentRenderer {
+ @Composable
+ public fun Render(node: ElementNode, context: OpenUIRenderContext)
+}
+
+public class OpenUIRenderContext(
+ private val registry: OpenUIComposeRegistry,
+ private val onAction: (OpenUIAction) -> Unit,
+) {
+ @Composable
+ public fun Render(node: ElementNode?) {
+ OpenUIRenderer(node = node, registry = registry, onAction = onAction)
+ }
+
+ public fun dispatch(action: OpenUIAction) {
+ onAction(action)
+ }
+}
+
+public class OpenUIComposeRegistry(
+ renderers: Map = emptyMap(),
+) {
+ private val renderers = LinkedHashMap(defaultRenderers()).apply {
+ putAll(renderers)
+ }
+
+ public fun register(name: String, renderer: ComponentRenderer): OpenUIComposeRegistry = apply {
+ renderers[name] = renderer
+ }
+
+ internal fun renderer(name: String): ComponentRenderer? = renderers[name]
+}
+
+@Composable
+public fun OpenUIRenderer(
+ node: ElementNode?,
+ registry: OpenUIComposeRegistry = OpenUIComposeRegistry(),
+ onAction: (OpenUIAction) -> Unit = {},
+) {
+ val context = OpenUIRenderContext(registry, onAction)
+ if (node == null) {
+ Text("No UI to render", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ return
+ }
+ registry.renderer(node.typeName)?.Render(node, context)
+ ?: Text("Unknown component: ${node.typeName}", color = MaterialTheme.colorScheme.error)
+}
+
+private fun defaultRenderers(): Map =
+ mapOf(
+ "Root" to ComponentRenderer { node, context ->
+ Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ node.children().forEach { context.Render(it) }
+ }
+ },
+ "Stack" to ComponentRenderer { node, context ->
+ val direction = node.stringProp("direction") ?: "column"
+ if (direction == "row") {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ node.children().forEach { context.Render(it) }
+ }
+ } else {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ node.children().forEach { context.Render(it) }
+ }
+ }
+ },
+ "VStack" to ComponentRenderer { node, context ->
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ node.children().forEach { context.Render(it) }
+ }
+ },
+ "HStack" to ComponentRenderer { node, context ->
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ node.children().forEach { context.Render(it) }
+ }
+ },
+ "Card" to ComponentRenderer { node, context ->
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ node.children().forEach { context.Render(it) }
+ }
+ }
+ },
+ "Text" to ComponentRenderer { node, _ ->
+ Text(node.stringProp("text") ?: node.stringProp("children") ?: "")
+ },
+ "TextContent" to ComponentRenderer { node, _ ->
+ Text(node.stringProp("text") ?: "")
+ },
+ "Heading" to ComponentRenderer { node, _ ->
+ Text(node.stringProp("text") ?: "", style = MaterialTheme.typography.headlineSmall)
+ },
+ "Button" to ComponentRenderer { node, context ->
+ val label = node.stringProp("label") ?: node.stringProp("text") ?: "Button"
+ Button(
+ onClick = {
+ context.dispatch(OpenUIAction(node.typeName, label, node.props["action"]))
+ },
+ ) {
+ Text(label)
+ }
+ },
+ "Input" to ComponentRenderer { node, context ->
+ OutlinedTextField(
+ value = node.stringProp("value") ?: "",
+ onValueChange = {
+ context.dispatch(OpenUIAction(node.typeName, node.stringProp("name"), OpenUIValue.StringValue(it)))
+ },
+ label = { Text(node.stringProp("label") ?: "") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ },
+ "Checkbox" to ComponentRenderer { node, context ->
+ Row {
+ Checkbox(
+ checked = (node.props["checked"] as? OpenUIValue.BooleanValue)?.value ?: false,
+ onCheckedChange = {
+ context.dispatch(OpenUIAction(node.typeName, node.stringProp("name"), OpenUIValue.BooleanValue(it)))
+ },
+ )
+ Text(node.stringProp("label") ?: "")
+ }
+ },
+ "List" to ComponentRenderer { node, context ->
+ LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(node.children()) { child -> context.Render(child) }
+ }
+ },
+ "Table" to ComponentRenderer { node, _ ->
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(node.stringProp("title") ?: "Table", style = MaterialTheme.typography.titleMedium)
+ Divider()
+ Text(
+ "Rows: ${node.props["rows"]?.asArrayOrEmpty().orEmpty().size}",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ },
+ "Chart" to ComponentRenderer { node, _ ->
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(node.stringProp("title") ?: "Chart", style = MaterialTheme.typography.titleMedium)
+ Spacer(Modifier.height(96.dp))
+ Text("Chart data ready", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ },
+ )
+
+private fun ElementNode.children(): List {
+ val direct = props["children"]?.asArrayOrEmpty().orEmpty().mapNotNull { it.asElementOrNull() }
+ if (direct.isNotEmpty()) return direct
+ return props["items"]?.asArrayOrEmpty().orEmpty().mapNotNull { it.asElementOrNull() }
+}
+
+private fun ElementNode.stringProp(name: String): String? =
+ props[name]?.asStringOrNull()
diff --git a/packages/kotlin/openui-lang/build.gradle.kts b/packages/kotlin/openui-lang/build.gradle.kts
new file mode 100644
index 000000000..93862fe40
--- /dev/null
+++ b/packages/kotlin/openui-lang/build.gradle.kts
@@ -0,0 +1,15 @@
+plugins {
+ kotlin("jvm")
+}
+
+kotlin {
+ jvmToolchain(17)
+}
+
+tasks.test {
+ useJUnitPlatform()
+}
+
+dependencies {
+ testImplementation(kotlin("test"))
+}
diff --git a/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/Library.kt b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/Library.kt
new file mode 100644
index 000000000..e5bcf4dd3
--- /dev/null
+++ b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/Library.kt
@@ -0,0 +1,58 @@
+package dev.openui.lang
+
+public class ComponentLibrary(
+ components: List = emptyList(),
+ public val componentGroups: List = emptyList(),
+ public val root: String? = null,
+) {
+ private val componentMap: LinkedHashMap = linkedMapOf()
+
+ public val components: Map
+ get() = componentMap.toMap()
+
+ init {
+ components.forEach(::register)
+ require(root == null || componentMap.containsKey(root)) {
+ "Root component \"$root\" was not found in the component library."
+ }
+ }
+
+ public fun register(component: ComponentDef): ComponentLibrary = apply {
+ componentMap[component.name] = component
+ }
+
+ public fun prompt(options: PromptOptions = PromptOptions()): String =
+ PromptGenerator.generate(this, options)
+
+ public fun paramNames(componentName: String): List =
+ componentMap[componentName]?.props?.map { it.name }.orEmpty()
+
+ public fun component(componentName: String): ComponentDef? = componentMap[componentName]
+}
+
+public class ComponentLibraryBuilder {
+ private val components = mutableListOf()
+ private val groups = mutableListOf()
+ private var root: String? = null
+
+ public fun component(
+ name: String,
+ description: String,
+ props: List = emptyList(),
+ ) {
+ components += ComponentDef(name, description, props)
+ }
+
+ public fun group(name: String, components: List, notes: List = emptyList()) {
+ groups += ComponentGroup(name, components, notes)
+ }
+
+ public fun root(name: String) {
+ root = name
+ }
+
+ internal fun build(): ComponentLibrary = ComponentLibrary(components, groups, root)
+}
+
+public fun openUILibrary(block: ComponentLibraryBuilder.() -> Unit): ComponentLibrary =
+ ComponentLibraryBuilder().apply(block).build()
diff --git a/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/OpenUIParser.kt b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/OpenUIParser.kt
new file mode 100644
index 000000000..b000eca8b
--- /dev/null
+++ b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/OpenUIParser.kt
@@ -0,0 +1,529 @@
+package dev.openui.lang
+
+private sealed interface Expr {
+ data object NullExpr : Expr
+ data class Str(val value: String) : Expr
+ data class Num(val value: Double) : Expr
+ data class Bool(val value: Boolean) : Expr
+ data class Arr(val values: List) : Expr
+ data class Obj(val fields: Map) : Expr
+ data class Ref(val name: String) : Expr
+ data class Comp(val name: String, val args: List, val namedArgs: Map) : Expr
+}
+
+private data class Statement(val id: String, val expr: Expr)
+
+public class OpenUIParser(private val library: ComponentLibrary) {
+ public fun parse(input: String): ParseResult {
+ val cleaned = stripComments(stripFences(input.trim())).trim()
+ if (cleaned.isEmpty()) return emptyResult(incomplete = true)
+
+ val (source, incomplete) = autoClose(cleaned)
+ val statements = parseStatements(source)
+ if (statements.isEmpty()) return emptyResult(incomplete)
+
+ val statementMap = statements.associateBy { it.id }
+ val entryId = pickEntryId(statementMap)
+ val unresolved = linkedSetOf()
+ val errors = mutableListOf()
+ val reached = linkedSetOf()
+
+ val root = statementMap[entryId]
+ ?.let { materializeElement(it.expr, it.id, statementMap, unresolved, errors, reached, incomplete) }
+ ?.copy(statementId = entryId)
+
+ val stateDeclarations = linkedMapOf()
+ val queries = mutableListOf()
+ val mutations = mutableListOf()
+
+ statements.forEach { statement ->
+ when {
+ statement.id.startsWith("$") -> {
+ stateDeclarations[statement.id] = materializeValue(
+ statement.expr,
+ statement.id,
+ statementMap,
+ unresolved,
+ errors,
+ reached,
+ incomplete,
+ )
+ }
+ statement.expr is Expr.Comp && statement.expr.name == "Query" -> {
+ queries += statement.expr.toQuery(statement.id, statementMap, unresolved, errors, reached, incomplete)
+ }
+ statement.expr is Expr.Comp && statement.expr.name == "Mutation" -> {
+ mutations += statement.expr.toMutation(statement.id, statementMap, unresolved, errors, reached, incomplete)
+ }
+ }
+ }
+
+ val orphaned = statements
+ .map { it.id }
+ .filterNot { it == entryId || it.startsWith("$") || it in reached }
+ .filterNot { id ->
+ val expr = statementMap[id]?.expr
+ expr is Expr.Comp && (expr.name == "Query" || expr.name == "Mutation")
+ }
+
+ return ParseResult(
+ root = root,
+ meta = ParseResult.Meta(
+ incomplete = incomplete,
+ unresolved = unresolved.toList(),
+ orphaned = orphaned,
+ statementCount = statementMap.size,
+ errors = errors,
+ ),
+ stateDeclarations = stateDeclarations,
+ queryStatements = queries,
+ mutationStatements = mutations,
+ )
+ }
+
+ public fun streamParser(): OpenUIStreamParser = OpenUIStreamParser(this)
+
+ private fun Expr.Comp.toQuery(
+ statementId: String,
+ statements: Map,
+ unresolved: MutableSet,
+ errors: MutableList,
+ reached: MutableSet,
+ incomplete: Boolean,
+ ): QueryStatementInfo =
+ QueryStatementInfo(
+ statementId = statementId,
+ tool = args.getOrNull(0)?.let { materializeValue(it, statementId, statements, unresolved, errors, reached, incomplete) },
+ args = args.getOrNull(1)?.let { materializeValue(it, statementId, statements, unresolved, errors, reached, incomplete) },
+ defaults = args.getOrNull(2)?.let { materializeValue(it, statementId, statements, unresolved, errors, reached, incomplete) },
+ refreshInterval = args.getOrNull(3)?.let { materializeValue(it, statementId, statements, unresolved, errors, reached, incomplete) },
+ complete = !incomplete,
+ )
+
+ private fun Expr.Comp.toMutation(
+ statementId: String,
+ statements: Map,
+ unresolved: MutableSet,
+ errors: MutableList,
+ reached: MutableSet,
+ incomplete: Boolean,
+ ): MutationStatementInfo =
+ MutationStatementInfo(
+ statementId = statementId,
+ tool = args.getOrNull(0)?.let { materializeValue(it, statementId, statements, unresolved, errors, reached, incomplete) },
+ args = args.getOrNull(1)?.let { materializeValue(it, statementId, statements, unresolved, errors, reached, incomplete) },
+ )
+
+ private fun materializeValue(
+ expr: Expr,
+ currentStatementId: String?,
+ statements: Map,
+ unresolved: MutableSet,
+ errors: MutableList,
+ reached: MutableSet,
+ incomplete: Boolean,
+ ): OpenUIValue =
+ when (expr) {
+ Expr.NullExpr -> OpenUIValue.NullValue
+ is Expr.Str -> OpenUIValue.StringValue(expr.value)
+ is Expr.Num -> OpenUIValue.NumberValue(expr.value)
+ is Expr.Bool -> OpenUIValue.BooleanValue(expr.value)
+ is Expr.Arr -> OpenUIValue.ArrayValue(
+ expr.values.map {
+ materializeValue(it, currentStatementId, statements, unresolved, errors, reached, incomplete)
+ },
+ )
+ is Expr.Obj -> OpenUIValue.ObjectValue(
+ expr.fields.mapValues { (_, value) ->
+ materializeValue(value, currentStatementId, statements, unresolved, errors, reached, incomplete)
+ },
+ )
+ is Expr.Ref -> {
+ val statement = statements[expr.name]
+ if (statement == null) {
+ unresolved += expr.name
+ OpenUIValue.NullValue
+ } else {
+ reached += expr.name
+ materializeValue(statement.expr, statement.id, statements, unresolved, errors, reached, incomplete)
+ }
+ }
+ is Expr.Comp -> {
+ materializeElement(expr, currentStatementId, statements, unresolved, errors, reached, incomplete)
+ ?.let(OpenUIValue::ElementValue)
+ ?: OpenUIValue.NullValue
+ }
+ }
+
+ private fun materializeElement(
+ expr: Expr,
+ currentStatementId: String?,
+ statements: Map,
+ unresolved: MutableSet,
+ errors: MutableList,
+ reached: MutableSet,
+ incomplete: Boolean,
+ ): ElementNode? {
+ val comp = expr as? Expr.Comp ?: return null
+ if (comp.name == "Query" || comp.name == "Mutation") return null
+
+ val def = library.component(comp.name)
+ if (def == null && !comp.name.isBuiltInCall()) {
+ errors += ValidationError(
+ code = ValidationErrorCode.UnknownComponent,
+ component = comp.name,
+ path = "",
+ message = "Unknown component: ${comp.name}",
+ statementId = currentStatementId,
+ )
+ }
+
+ val propDefs = def?.props.orEmpty()
+ val props = linkedMapOf()
+
+ comp.args.forEachIndexed { index, arg ->
+ val prop = propDefs.getOrNull(index)
+ if (prop == null && comp.name.isBuiltInCall()) {
+ props["arg$index"] = materializeValue(arg, currentStatementId, statements, unresolved, errors, reached, incomplete)
+ } else if (prop == null) {
+ errors += ValidationError(
+ code = ValidationErrorCode.ExcessArgs,
+ component = comp.name,
+ path = "/$index",
+ message = "Component ${comp.name} does not declare positional argument $index.",
+ statementId = currentStatementId,
+ )
+ } else {
+ props[prop.name] = materializeValue(arg, currentStatementId, statements, unresolved, errors, reached, incomplete)
+ }
+ }
+
+ comp.namedArgs.forEach { (name, arg) ->
+ props[name] = materializeValue(arg, currentStatementId, statements, unresolved, errors, reached, incomplete)
+ }
+
+ propDefs.forEach { prop ->
+ val value = props[prop.name]
+ if (value == null && prop.defaultValue != null) {
+ props[prop.name] = prop.defaultValue
+ } else if (value == null && prop.required && !incomplete) {
+ errors += ValidationError(
+ code = ValidationErrorCode.MissingRequired,
+ component = comp.name,
+ path = "/${prop.name}",
+ message = "Missing required prop ${prop.name} for component ${comp.name}.",
+ statementId = currentStatementId,
+ )
+ } else if (value == OpenUIValue.NullValue && prop.required && !incomplete) {
+ errors += ValidationError(
+ code = ValidationErrorCode.NullRequired,
+ component = comp.name,
+ path = "/${prop.name}",
+ message = "Required prop ${prop.name} for component ${comp.name} cannot be null.",
+ statementId = currentStatementId,
+ )
+ }
+ }
+
+ return ElementNode(
+ statementId = currentStatementId,
+ typeName = comp.name,
+ props = props,
+ partial = incomplete,
+ )
+ }
+
+ private fun parseStatements(source: String): List {
+ val parser = ExpressionParser(source)
+ return parser.statements()
+ }
+
+ private fun pickEntryId(statements: Map): String =
+ when {
+ statements.containsKey("root") -> "root"
+ library.root != null && statements.containsKey(library.root) -> library.root
+ else -> statements.keys.first()
+ }
+}
+
+public class OpenUIStreamParser(private val parser: OpenUIParser) {
+ private var buffer: String = ""
+
+ public fun push(chunk: String): ParseResult {
+ buffer += chunk
+ return parser.parse(buffer)
+ }
+
+ public fun set(fullText: String): ParseResult {
+ buffer = fullText
+ return parser.parse(buffer)
+ }
+
+ public fun getResult(): ParseResult = parser.parse(buffer)
+}
+
+private class ExpressionParser(private val source: String) {
+ private var index = 0
+
+ fun statements(): List {
+ val statements = mutableListOf()
+ while (!eof()) {
+ skipWhitespace()
+ val id = parseIdentifier(allowState = true)
+ if (id == null) {
+ index++
+ continue
+ }
+ skipWhitespace()
+ if (!match("=")) continue
+ val expr = parseExpression() ?: continue
+ statements += Statement(id, expr)
+ }
+ return statements
+ }
+
+ private fun parseExpression(): Expr? {
+ skipWhitespace()
+ if (eof()) return null
+ return when (peek()) {
+ '"' -> Expr.Str(parseString())
+ '[' -> Expr.Arr(parseArray())
+ '{' -> Expr.Obj(parseObject())
+ else -> when {
+ startsWith("true") -> {
+ index += 4
+ Expr.Bool(true)
+ }
+ startsWith("false") -> {
+ index += 5
+ Expr.Bool(false)
+ }
+ startsWith("null") -> {
+ index += 4
+ Expr.NullExpr
+ }
+ peek() == '-' || peek().isDigit() -> Expr.Num(parseNumber())
+ else -> parseIdentifier(allowState = true, allowBuiltin = true)?.let { ident ->
+ skipWhitespace()
+ if (match("(")) {
+ val (args, named) = parseArguments()
+ Expr.Comp(ident, args, named)
+ } else {
+ Expr.Ref(ident)
+ }
+ }
+ }
+ }
+ }
+
+ private fun parseArguments(): Pair, Map> {
+ val args = mutableListOf()
+ val named = linkedMapOf()
+ while (!eof()) {
+ skipWhitespace()
+ if (match(")")) break
+ val checkpoint = index
+ val maybeName = parseIdentifier(allowState = false, allowBuiltin = false)
+ if (maybeName != null) {
+ skipWhitespace()
+ if (match(":")) {
+ parseExpression()?.let { named[maybeName] = it }
+ } else {
+ index = checkpoint
+ if (parseExpression()?.let(args::add) == null) index++
+ }
+ } else {
+ if (parseExpression()?.let(args::add) == null) index++
+ }
+ skipWhitespace()
+ match(",")
+ }
+ return args to named
+ }
+
+ private fun parseArray(): List {
+ match("[")
+ val values = mutableListOf()
+ while (!eof()) {
+ skipWhitespace()
+ if (match("]")) break
+ if (parseExpression()?.let(values::add) == null) index++
+ skipWhitespace()
+ match(",")
+ }
+ return values
+ }
+
+ private fun parseObject(): Map {
+ match("{")
+ val fields = linkedMapOf()
+ while (!eof()) {
+ skipWhitespace()
+ if (match("}")) break
+ val key = if (peek() == '"') parseString() else parseIdentifier(allowState = false, allowBuiltin = false)
+ skipWhitespace()
+ if (key != null && match(":")) {
+ if (parseExpression()?.let { fields[key] = it } == null) index++
+ } else if (key == null) {
+ index++
+ }
+ skipWhitespace()
+ match(",")
+ }
+ return fields
+ }
+
+ private fun parseString(): String {
+ match("\"")
+ val result = StringBuilder()
+ while (!eof()) {
+ val ch = source[index++]
+ if (ch == '"') break
+ if (ch == '\\' && !eof()) {
+ val escaped = source[index++]
+ result.append(
+ when (escaped) {
+ 'n' -> '\n'
+ 'r' -> '\r'
+ 't' -> '\t'
+ '"' -> '"'
+ '\\' -> '\\'
+ else -> escaped
+ },
+ )
+ } else {
+ result.append(ch)
+ }
+ }
+ return result.toString()
+ }
+
+ private fun parseNumber(): Double {
+ val start = index
+ if (peek() == '-') index++
+ while (!eof() && peek().isDigit()) index++
+ if (!eof() && peek() == '.') {
+ index++
+ while (!eof() && peek().isDigit()) index++
+ }
+ return source.substring(start, index).toDoubleOrNull() ?: 0.0
+ }
+
+ private fun parseIdentifier(allowState: Boolean, allowBuiltin: Boolean = false): String? {
+ if (eof()) return null
+ val start = index
+ if (allowState && peek() == '$') index++
+ if (allowBuiltin && !eof() && peek() == '@') index++
+ if (eof() || !(peek().isLetter() || peek() == '_')) {
+ index = start
+ return null
+ }
+ index++
+ while (!eof() && (peek().isLetterOrDigit() || peek() == '_' || peek() == '-')) index++
+ return source.substring(start, index)
+ }
+
+ private fun skipWhitespace() {
+ while (!eof() && peek().isWhitespace()) index++
+ }
+
+ private fun match(token: String): Boolean {
+ if (!startsWith(token)) return false
+ index += token.length
+ return true
+ }
+
+ private fun startsWith(token: String): Boolean = source.startsWith(token, index)
+ private fun eof(): Boolean = index >= source.length
+ private fun peek(): Char = source[index]
+}
+
+private fun String.isBuiltInCall(): Boolean = this == "Action" || startsWith("@")
+
+private fun emptyResult(incomplete: Boolean): ParseResult =
+ ParseResult(
+ root = null,
+ meta = ParseResult.Meta(
+ incomplete = incomplete,
+ unresolved = emptyList(),
+ orphaned = emptyList(),
+ statementCount = 0,
+ errors = emptyList(),
+ ),
+ stateDeclarations = emptyMap(),
+ queryStatements = emptyList(),
+ mutationStatements = emptyList(),
+ )
+
+private fun stripFences(input: String): String {
+ if (!input.startsWith("```")) return input
+ val firstNewline = input.indexOf('\n')
+ if (firstNewline == -1) return ""
+ val body = input.substring(firstNewline + 1)
+ val trailing = body.lastIndexOf("```")
+ return if (trailing >= 0) body.substring(0, trailing) else body
+}
+
+private fun stripComments(input: String): String =
+ input.lineSequence().joinToString("\n") { line ->
+ var inString = false
+ var escaped = false
+ for (i in line.indices) {
+ val ch = line[i]
+ if (escaped) {
+ escaped = false
+ continue
+ }
+ if (ch == '\\' && inString) {
+ escaped = true
+ continue
+ }
+ if (ch == '"') inString = !inString
+ if (!inString && ch == '#') return@joinToString line.substring(0, i).trimEnd()
+ if (!inString && ch == '/' && line.getOrNull(i + 1) == '/') {
+ return@joinToString line.substring(0, i).trimEnd()
+ }
+ }
+ line
+ }
+
+private fun autoClose(input: String): Pair {
+ var parens = 0
+ var brackets = 0
+ var braces = 0
+ var inString = false
+ var escaped = false
+ input.forEach { ch ->
+ if (escaped) {
+ escaped = false
+ return@forEach
+ }
+ if (ch == '\\' && inString) {
+ escaped = true
+ return@forEach
+ }
+ if (ch == '"') {
+ inString = !inString
+ return@forEach
+ }
+ if (!inString) {
+ when (ch) {
+ '(' -> parens++
+ ')' -> parens = (parens - 1).coerceAtLeast(0)
+ '[' -> brackets++
+ ']' -> brackets = (brackets - 1).coerceAtLeast(0)
+ '{' -> braces++
+ '}' -> braces = (braces - 1).coerceAtLeast(0)
+ }
+ }
+ }
+ val incomplete = inString || parens > 0 || brackets > 0 || braces > 0
+ val closed = buildString {
+ append(input)
+ if (inString) append('"')
+ repeat(braces) { append('}') }
+ repeat(brackets) { append(']') }
+ repeat(parens) { append(')') }
+ }
+ return closed to incomplete
+}
diff --git a/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/PromptGenerator.kt b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/PromptGenerator.kt
new file mode 100644
index 000000000..09fb27f8d
--- /dev/null
+++ b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/PromptGenerator.kt
@@ -0,0 +1,85 @@
+package dev.openui.lang
+
+public data class ToolSpec(
+ public val name: String,
+ public val description: String? = null,
+ public val inputSchema: Map = emptyMap(),
+ public val outputSchema: Map = emptyMap(),
+)
+
+public data class PromptOptions(
+ public val preamble: String? = null,
+ public val additionalRules: List = emptyList(),
+ public val examples: List = emptyList(),
+ public val toolExamples: List = emptyList(),
+ public val tools: List = emptyList(),
+ public val editMode: Boolean = false,
+ public val inlineMode: Boolean = false,
+ public val toolCalls: Boolean = tools.isNotEmpty(),
+ public val bindings: Boolean = toolCalls,
+)
+
+public object PromptGenerator {
+ private const val DefaultPreamble =
+ "You are an AI assistant that responds using openui-lang, a declarative UI language. Your ENTIRE response must be valid openui-lang code - no markdown, no explanations, just openui-lang."
+
+ public fun generate(library: ComponentLibrary, options: PromptOptions = PromptOptions()): String {
+ val rootName = library.root ?: library.components.keys.firstOrNull() ?: "Root"
+ return buildString {
+ appendLine(options.preamble ?: DefaultPreamble)
+ appendLine()
+ appendLine("## Syntax Rules")
+ appendLine("1. Each statement is on its own line: `identifier = Expression`")
+ appendLine("2. `root` is the entry point - every program must define `root = $rootName(...)`")
+ appendLine("3. Expressions are strings, numbers, booleans, null, arrays, objects, or component calls.")
+ appendLine("4. Arguments are positional and follow the component signature order.")
+ appendLine("5. Define reusable child nodes as statements and reference them from parent arrays.")
+ if (options.bindings) {
+ appendLine("6. Declare mutable state with `\$` variables, for example `\$search = \"\"`.")
+ }
+ appendLine()
+ appendLine("## Components")
+ library.components.values.sortedBy { it.name }.forEach { component ->
+ appendLine("- `${component.signature()}` - ${component.description}")
+ }
+ if (library.componentGroups.isNotEmpty()) {
+ appendLine()
+ appendLine("## Component Groups")
+ library.componentGroups.forEach { group ->
+ appendLine("- ${group.name}: ${group.components.joinToString(", ")}")
+ group.notes.forEach { appendLine(" - $it") }
+ }
+ }
+ if (options.toolCalls) {
+ appendLine()
+ appendLine("## Query and Mutation")
+ appendLine("- Use `Query(\"tool\", {args}, {defaults}, refreshSeconds?)` for live data.")
+ appendLine("- Use `Mutation(\"tool\", {args})` for actions that change data.")
+ appendLine("- Use `Action([@Run(queryName)])` in interactive components to trigger work.")
+ }
+ if (options.tools.isNotEmpty()) {
+ appendLine()
+ appendLine("## Tools")
+ options.tools.forEach { tool ->
+ append("- `${tool.name}`")
+ if (tool.description != null) append(" - ${tool.description}")
+ appendLine()
+ }
+ }
+ if (options.additionalRules.isNotEmpty()) {
+ appendLine()
+ appendLine("## Additional Rules")
+ options.additionalRules.forEach { appendLine("- $it") }
+ }
+ if (options.examples.isNotEmpty() || options.toolExamples.isNotEmpty()) {
+ appendLine()
+ appendLine("## Examples")
+ (options.examples + options.toolExamples).forEach { example ->
+ appendLine("```")
+ appendLine(example.trim())
+ appendLine("```")
+ }
+ }
+ }.trim()
+ }
+}
diff --git a/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/Types.kt b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/Types.kt
new file mode 100644
index 000000000..eea462f5b
--- /dev/null
+++ b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/Types.kt
@@ -0,0 +1,157 @@
+package dev.openui.lang
+
+public sealed interface OpenUIType {
+ public fun signature(): String
+
+ public data object StringType : OpenUIType {
+ override fun signature(): String = "string"
+ }
+
+ public data object NumberType : OpenUIType {
+ override fun signature(): String = "number"
+ }
+
+ public data object BooleanType : OpenUIType {
+ override fun signature(): String = "boolean"
+ }
+
+ public data object AnyType : OpenUIType {
+ override fun signature(): String = "any"
+ }
+
+ public data class Literal(public val value: String) : OpenUIType {
+ override fun signature(): String = "\"$value\""
+ }
+
+ public data class Enum(public val values: List) : OpenUIType {
+ override fun signature(): String = values.joinToString(" | ") { "\"$it\"" }
+ }
+
+ public data class Component(public val name: String) : OpenUIType {
+ override fun signature(): String = name
+ }
+
+ public data class ArrayOf(public val item: OpenUIType) : OpenUIType {
+ override fun signature(): String = "${item.signature()}[]"
+ }
+
+ public data class RecordOf(
+ public val key: OpenUIType = StringType,
+ public val value: OpenUIType = AnyType,
+ ) : OpenUIType {
+ override fun signature(): String = "Record<${key.signature()}, ${value.signature()}>"
+ }
+
+ public data class ObjectOf(public val fields: List) : OpenUIType {
+ override fun signature(): String =
+ fields.joinToString(prefix = "{", postfix = "}") { field ->
+ val optional = if (field.required) "" else "?"
+ "${field.name}$optional: ${field.type.signature()}"
+ }
+ }
+
+ public data class Binding(public val inner: OpenUIType) : OpenUIType {
+ override fun signature(): String = "\$binding<${inner.signature()}>"
+ }
+}
+
+public data class PropDef(
+ public val name: String,
+ public val type: OpenUIType = OpenUIType.AnyType,
+ public val required: Boolean = true,
+ public val defaultValue: OpenUIValue? = null,
+)
+
+public data class ComponentDef(
+ public val name: String,
+ public val description: String,
+ public val props: List = emptyList(),
+) {
+ public fun signature(): String {
+ val params = props.joinToString(", ") { prop ->
+ val optional = if (prop.required) "" else "?"
+ "${prop.name}$optional: ${prop.type.signature()}"
+ }
+ return "$name($params)"
+ }
+}
+
+public data class ComponentGroup(
+ public val name: String,
+ public val components: List,
+ public val notes: List = emptyList(),
+)
+
+public sealed interface OpenUIValue {
+ public data object NullValue : OpenUIValue
+ public data class StringValue(public val value: String) : OpenUIValue
+ public data class NumberValue(public val value: Double) : OpenUIValue
+ public data class BooleanValue(public val value: Boolean) : OpenUIValue
+ public data class ArrayValue(public val values: List) : OpenUIValue
+ public data class ObjectValue(public val fields: Map) : OpenUIValue
+ public data class ElementValue(public val node: ElementNode) : OpenUIValue
+}
+
+public data class ElementNode(
+ public val statementId: String? = null,
+ public val typeName: String,
+ public val props: Map,
+ public val partial: Boolean,
+ public val hasDynamicProps: Boolean = false,
+)
+
+public enum class ValidationErrorCode {
+ MissingRequired,
+ NullRequired,
+ UnknownComponent,
+ InlineReserved,
+ ExcessArgs,
+}
+
+public data class ValidationError(
+ public val code: ValidationErrorCode,
+ public val component: String,
+ public val path: String,
+ public val message: String,
+ public val statementId: String? = null,
+)
+
+public data class QueryStatementInfo(
+ public val statementId: String,
+ public val tool: OpenUIValue?,
+ public val args: OpenUIValue?,
+ public val defaults: OpenUIValue?,
+ public val refreshInterval: OpenUIValue?,
+ public val complete: Boolean,
+)
+
+public data class MutationStatementInfo(
+ public val statementId: String,
+ public val tool: OpenUIValue?,
+ public val args: OpenUIValue?,
+)
+
+public data class ParseResult(
+ public val root: ElementNode?,
+ public val meta: Meta,
+ public val stateDeclarations: Map,
+ public val queryStatements: List,
+ public val mutationStatements: List,
+) {
+ public data class Meta(
+ public val incomplete: Boolean,
+ public val unresolved: List,
+ public val orphaned: List,
+ public val statementCount: Int,
+ public val errors: List,
+ )
+}
+
+public fun OpenUIValue.asStringOrNull(): String? =
+ (this as? OpenUIValue.StringValue)?.value
+
+public fun OpenUIValue.asArrayOrEmpty(): List =
+ (this as? OpenUIValue.ArrayValue)?.values.orEmpty()
+
+public fun OpenUIValue.asElementOrNull(): ElementNode? =
+ (this as? OpenUIValue.ElementValue)?.node
diff --git a/packages/kotlin/openui-lang/src/test/kotlin/dev/openui/lang/OpenUIParserTest.kt b/packages/kotlin/openui-lang/src/test/kotlin/dev/openui/lang/OpenUIParserTest.kt
new file mode 100644
index 000000000..a2ed9efcc
--- /dev/null
+++ b/packages/kotlin/openui-lang/src/test/kotlin/dev/openui/lang/OpenUIParserTest.kt
@@ -0,0 +1,94 @@
+package dev.openui.lang
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class OpenUIParserTest {
+ private val library = openUILibrary {
+ component(
+ name = "Root",
+ description = "Root layout.",
+ props = listOf(PropDef("children", OpenUIType.ArrayOf(OpenUIType.Component("Component")))),
+ )
+ component(
+ name = "Text",
+ description = "Text content.",
+ props = listOf(PropDef("text", OpenUIType.StringType)),
+ )
+ component(
+ name = "Button",
+ description = "Action button.",
+ props = listOf(
+ PropDef("label", OpenUIType.StringType),
+ PropDef("action", OpenUIType.AnyType, required = false),
+ ),
+ )
+ component(
+ name = "Table",
+ description = "Data table.",
+ props = listOf(
+ PropDef("columns", OpenUIType.ArrayOf(OpenUIType.StringType)),
+ PropDef("rows", OpenUIType.ArrayOf(OpenUIType.RecordOf())),
+ ),
+ )
+ root("Root")
+ }
+
+ @Test
+ fun promptIncludesTypedComponentSignatures() {
+ val prompt = library.prompt()
+
+ assertTrue(prompt.contains("Root(children: Component[])"))
+ assertTrue(prompt.contains("Button(label: string, action?: any)"))
+ }
+
+ @Test
+ fun parsesRootAndMapsPositionalProps() {
+ val result = OpenUIParser(library).parse(
+ """
+ root = Root([copy, action])
+ copy = Text("Hello Kotlin")
+ action = Button("Open", Action([@OpenUrl("https://openui.com")]))
+ """.trimIndent(),
+ )
+
+ assertFalse(result.meta.incomplete)
+ assertTrue(result.meta.errors.isEmpty())
+ assertEquals("Root", result.root?.typeName)
+
+ val children = result.root?.props?.get("children") as OpenUIValue.ArrayValue
+ val text = children.values.first() as OpenUIValue.ElementValue
+ assertEquals("Hello Kotlin", text.node.props["text"]?.asStringOrNull())
+ }
+
+ @Test
+ fun streamParserReturnsPartialThenCompleteResult() {
+ val stream = OpenUIParser(library).streamParser()
+
+ val partial = stream.push("root = Root([Text(\"loa")
+ assertTrue(partial.meta.incomplete)
+ assertNotNull(partial.root)
+
+ val complete = stream.push("ded\")])")
+ assertFalse(complete.meta.incomplete)
+ assertEquals("Root", complete.root?.typeName)
+ }
+
+ @Test
+ fun extractsStateAndQueryStatements() {
+ val result = OpenUIParser(library).parse(
+ """
+ ${'$'}search = "kotlin"
+ rows = Query("searchRows", {q: ${'$'}search}, {rows: []}, 30)
+ root = Root([Text("Loaded")])
+ """.trimIndent(),
+ )
+
+ assertEquals("kotlin", result.stateDeclarations["${'$'}search"]?.asStringOrNull())
+ assertEquals(1, result.queryStatements.size)
+ assertEquals("rows", result.queryStatements.first().statementId)
+ }
+}
diff --git a/packages/kotlin/sample/android/app/build.gradle.kts b/packages/kotlin/sample/android/app/build.gradle.kts
new file mode 100644
index 000000000..90b7d39b3
--- /dev/null
+++ b/packages/kotlin/sample/android/app/build.gradle.kts
@@ -0,0 +1,32 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+android {
+ namespace = "dev.openui.sample"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "dev.openui.sample"
+ minSdk = 23
+ targetSdk = 35
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(project(":openui-lang"))
+ implementation(project(":openui-compose"))
+ implementation(platform("androidx.compose:compose-bom:2025.05.01"))
+ implementation("androidx.activity:activity-compose:1.10.1")
+ implementation("androidx.compose.foundation:foundation")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.ui:ui")
+}
diff --git a/packages/kotlin/sample/android/app/src/main/AndroidManifest.xml b/packages/kotlin/sample/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..059a61cda
--- /dev/null
+++ b/packages/kotlin/sample/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/kotlin/sample/android/app/src/main/java/dev/openui/sample/MainActivity.kt b/packages/kotlin/sample/android/app/src/main/java/dev/openui/sample/MainActivity.kt
new file mode 100644
index 000000000..e81219622
--- /dev/null
+++ b/packages/kotlin/sample/android/app/src/main/java/dev/openui/sample/MainActivity.kt
@@ -0,0 +1,106 @@
+package dev.openui.sample
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.openui.compose.OpenUIRenderer
+import dev.openui.lang.OpenUIParser
+import dev.openui.lang.OpenUIType
+import dev.openui.lang.PropDef
+import dev.openui.lang.openUILibrary
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ StreamingChatDemo()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun StreamingChatDemo() {
+ val library = remember { sampleLibrary() }
+ val parser = remember { OpenUIParser(library).streamParser() }
+ var source by remember {
+ mutableStateOf(
+ """
+ root = Root([title, card])
+ title = Heading("Native Kotlin OpenUI")
+ card = Card([copy, action])
+ copy = Text("This UI was parsed from OpenUI Lang and rendered by Jetpack Compose.")
+ action = Button("Continue", Action([@ToAssistant("Show me the next step")]))
+ """.trimIndent(),
+ )
+ }
+ val result = parser.set(source)
+
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ OutlinedTextField(
+ value = source,
+ onValueChange = { source = it },
+ modifier = Modifier.weight(1f),
+ label = { Text("Streaming OpenUI Lang buffer") },
+ )
+ OpenUIRenderer(
+ node = result.root,
+ onAction = { action ->
+ source += "\nnotice = Text(\"Action: ${action.label ?: action.component}\")"
+ },
+ )
+ }
+}
+
+private fun sampleLibrary() = openUILibrary {
+ component(
+ name = "Root",
+ description = "Top-level container for a generated screen.",
+ props = listOf(PropDef("children", OpenUIType.ArrayOf(OpenUIType.Component("Component")))),
+ )
+ component(
+ name = "Card",
+ description = "Grouped content surface.",
+ props = listOf(PropDef("children", OpenUIType.ArrayOf(OpenUIType.Component("Component")))),
+ )
+ component(
+ name = "Heading",
+ description = "Large section heading.",
+ props = listOf(PropDef("text", OpenUIType.StringType)),
+ )
+ component(
+ name = "Text",
+ description = "Body text.",
+ props = listOf(PropDef("text", OpenUIType.StringType)),
+ )
+ component(
+ name = "Button",
+ description = "Button that dispatches an OpenUI action callback.",
+ props = listOf(
+ PropDef("label", OpenUIType.StringType),
+ PropDef("action", OpenUIType.AnyType, required = false),
+ ),
+ )
+ root("Root")
+}
diff --git a/packages/kotlin/sample/android/app/src/main/res/values/styles.xml b/packages/kotlin/sample/android/app/src/main/res/values/styles.xml
new file mode 100644
index 000000000..27adb98d5
--- /dev/null
+++ b/packages/kotlin/sample/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/packages/kotlin/sample/android/build.gradle.kts b/packages/kotlin/sample/android/build.gradle.kts
new file mode 100644
index 000000000..c62d1eed5
--- /dev/null
+++ b/packages/kotlin/sample/android/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.application") apply false
+ id("org.jetbrains.kotlin.android") apply false
+ id("org.jetbrains.kotlin.plugin.compose") apply false
+}
diff --git a/packages/kotlin/settings.gradle.kts b/packages/kotlin/settings.gradle.kts
new file mode 100644
index 000000000..37b48708f
--- /dev/null
+++ b/packages/kotlin/settings.gradle.kts
@@ -0,0 +1,21 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "openui-kotlin"
+
+include(":openui-lang")
+include(":openui-compose")
+include(":sample:android:app")