
Renderer for A2UI protocol, rendering agent-sent JSON into native widgets from a single common codebase; enforces UAX #31 naming, pluggable locale formatting, modular Material‑3 catalog.
A renderer for the A2UI protocol built on Compose Multiplatform — Android, iOS, desktop (JVM), macOS, and web (JS + wasmJs) from a single commonMain.
Status:
0.1.0is on Maven Central, and the API is not stable. A0.xline: expect breaking changes between minor versions. See Installation and Roadmap.
A2UI lets an agent describe a user interface as a stream of JSON, which the client renders with its own native widgets. The agent never ships code — the catalog of renderable components is the trust boundary, and it lives in your binary.
dependencies {
implementation("dev.ynagai.a2ui:a2ui-material3:0.1.0")
}That is the usual entry point: it brings a2ui-compose, which brings a2ui-core. Take the lower
two directly when you do not want the Material 3 renderers -- a2ui-compose to draw the catalog
with your own design system, a2ui-core for the protocol alone, which carries no Compose dependency
at all:
implementation("dev.ynagai.a2ui:a2ui-compose:0.1.0")
implementation("dev.ynagai.a2ui:a2ui-core:0.1.0")Android consumers must compile against compileSdk 37 or later; see Targets for why
that floor is not a preference.
This library targets A2UI v1.0 and carries no v0.8/v0.9 compatibility layer.
The spec site currently labels v0.9.1 "Current" and v1.0 a "release candidate", so that choice
deserves an explanation. The official renderers already ship v1.0: @a2ui/react 0.10.2,
@a2ui/angular 0.10.5 and @a2ui/lit 0.10.3 are all on the 0.10.x line, which is v1.0 under its
former draft name. The "RC" label lags the reference implementations.
Every other Kotlin renderer is on v0.9. Rather than add a fifth v0.9 implementation and inherit a compatibility layer on day one, this one starts where the official renderers already are.
If v1.0 takes a breaking change before GA, the fallback is a stable-v0.9.1 / experimental-v1.0 split rather than a rewrite.
a2ui_protocol.md's "Catalog Entity Naming Rules" make component, function and argument names
MUST-conform to UAX #31, and this library enforces that where a catalog is built:
CatalogDefinition answers ^[\p{XID_Start}_][\p{XID_Continue}]*$ from the Unicode 17.0.0
derived tables, in its constructor, so a catalog assembled in Kotlin is held to it as well as one
decoded off the wire.
formatString's expression parser answers from the same tables. The specification states no
production for a name inside a ${…} expression, so there is no naming rule there to conform to;
what decides it is that a name a catalog accepts should be a name a format string can call.
0.1.0 judged those names by a general-category approximation of its own (isLetter(),
isLetterOrDigit()), which refused _helper — a valid entity name — and accepted ͺ (U+037A,
ID_Start but not XID_Start), which a catalog refuses. The two were unified in
#45; @index remains the one name with a @ a
template may call.
| Target | |
|---|---|
| Android |
minSdk 24, compileSdk 37 |
| JVM (desktop) | |
| iOS |
iosArm64, iosSimulatorArm64
|
| macOS | macosArm64 |
| Web |
js(browser), wasmJs(browser)
|
Three targets are deliberately absent. iosX64 (the Intel iOS simulator) is dropped because Compose
Multiplatform 1.12.0 does not publish variants for it. macosX64 is dropped because Kotlin has
demoted it out of the supported tiers. linux/mingw are not Compose targets.
The compileSdk floor of 37 is not a preference — it is forced by androidx.compose:1.12.0, which
Compose Multiplatform pulls in on Android. It becomes minCompileSdk in the published AAR metadata,
so every Android consumer must compile against 37 or later.
commonTest runs on all six targets, Android included — 492 of the suite's assertions run against
the Android variant on the JVM, which is what withHostTest {} buys. Before that they ran on five
targets and the sixth was silent: not one test had ever executed against the variant whose .aar
is published.
The Compose UI tests are the exception, and Android is the target that misses them. They need
a composition to draw into; Android's host tests have none without Robolectric or an on-device
instrumentation run, and this repository has neither. So the component renderers in
a2ui-material3 — what a CheckBox does with a tap, what a Modal intercepts — ship in the
.aar with nothing on Android having drawn them. They are covered on JVM, macOS, iOS and
wasmJs from the same commonMain, which is most of the argument but not all of it. Kotlin/JS is
in the same position for a different reason (its test harness cannot boot Skiko) and is covered by
the Gallery instead; Android has no equivalent, because the Gallery does not build for it.
| Artifact | Contents |
|---|---|
dev.ynagai.a2ui:a2ui-core |
Protocol types, v1.0 message parsing and serialization, data model and JSON Pointer binding, function evaluation, validation. No Compose dependency. |
dev.ynagai.a2ui:a2ui-compose |
A2uiSurface, A2uiRenderer, the component registry, and the bounds that keep an agent's payload from outgrowing a composition. Depends on compose.runtime and compose.ui — no design system.
|
dev.ynagai.a2ui:a2ui-material3 |
All eighteen of the catalog's components drawn with Material 3 -- every component the specification's forty-three examples name. Video and AudioPlayer draw a media component's frame and play nothing: there is no player in Compose Multiplatform, and a host with a media stack registers its own renderer for the two. Tabs and Modal hold state the agent cannot see or set, which is what the guide asks for; a Modal intercepts its trigger's taps, so a trigger carrying an action does not dispatch it -- and only its taps: a keyboard or a screen reader activating the trigger still reaches the button underneath, so it dispatches the action and does not open the dialog, which is the component's one known gap. checks, the catalog's renderer-side validation, is honoured: a Button whose check fails is disabled and a failing input is captioned with the message. Almost every string on a surface is the agent's own; the five that cannot be (a picker dialog's confirm and cancel, a filter field's label, a modal's close button, a video's frame) come from LocalA2uiStrings, English until a host provides otherwise. Image draws through a host-provided A2uiImageLoader, and a described placeholder without one -- this library fetches nothing itself, and a loader that does is handed the agent's URL unvetted. Every leaf and framed component carries a uniform 8dp margin (the guide's Leaf-Margin Strategy), which Text, Button and TextField did not have before. |
formatNumber, formatCurrency, formatDate and pluralize run through a LocaleFormatter, and
the one they get unless a host chooses otherwise is FallbackLocaleFormatter — which opens its own
documentation by saying it is not a locale implementation. It renders USD 1,234.50 rather than a
symbol, applies the English n == 1 plural rule whatever the language, and uses English month
names.
That is the intended default, not an oversight. A renderer that read the device by default would make one payload render differently in CI than on a desk. But it is chosen silently, so choose:
val renderer = remember {
A2uiRenderer(A2uiRendererConfig.Default.withLocale(systemLocaleFormatter()))
}localeFormatter(tag) takes a fixed locale instead. The corpus reaches those four functions in
roughly thirty places, so a host that ships without choosing is shipping the placeholder.
Every other renderer setting is on the same object. A2uiRendererConfig exists so that the
renderer's constructor is not its compatibility surface: seven defaulted constructor parameters
read well and cannot be extended, because a caller naming one binds to the synthetic defaults
constructor — parameter list plus bitmask — and an eighth setting would break every consumer
compiled against the old one. A new setting adds a withX to the config and breaks nothing.
The split follows the Core SDK / Framework Adapter separation in the A2UI project's own guidance for
new client SDKs. Material 3 is a third artifact rather than part of the adapter because a design
system is a host's choice: a host with its own components takes a2ui-compose alone and writes its
own ComponentRenderers, and pays nothing for a Material 3 it does not use.
Transport is deliberately absent: the library stays transport-free, so you can drive it from SSE, AG-UI, a WebSocket, or a local agent loop without the library taking an opinion.
a2ui-gallery is the reference environment the A2UI framework adapter blueprint asks every renderer
to ship: three columns — the specification's forty-three examples on the left, the live surface with
its JSON message stream and a step-one-message-at-a-time control in the middle, and the data model
and action log on the right. It is a development tool, and it is not published.
It is also where the renderer is exercised on Kotlin/JS. Compose's UI test harness cannot boot Skiko
there, so JS has no rendering test — the Gallery is the thing that runs. It is likewise the only
thing that runs the platform locale tables: it passes systemLocaleFormatter(), which the library
itself leaves opt-in. openUrl is deliberately not wired, so such an action does nothing here.
One limitation worth knowing before reading a layout off it: the preview scrolls, so a surface is
measured with an unbounded height and vertical layout that needs a bounded one does not take
effect — a Column whose justify spreads its children wraps instead. That is the same thing a
host embedding A2uiSurface in its own scroll container sees; check a vertical-arrangement question
somewhere bounded.
./gradlew :a2ui-gallery:run # desktop
./gradlew :a2ui-gallery:wasmJsBrowserDevelopmentRun # browser, Kotlin/Wasm
./gradlew :a2ui-gallery:jsBrowserDevelopmentRun # browser, Kotlin/JS
./gradlew :a2ui-gallery:runDebugExecutableMacosArm64 # native macOSOn iOS the entry point is MainViewController(), written for an Xcode project to set as its root
view controller. It is not reachable yet: the Gallery declares no binaries.framework, so there is
no framework to link and no Xcode project is checked in — the iOS targets are compiled and tested,
not packaged. Android is the one target the Gallery does not build for — the three library
modules do, and only the Gallery does not.
| Gate | Contents | Target | Closed |
|---|---|---|---|
| G0 | Skeleton — build, CI, all targets compiling | 2026-08-31 | ✅ 2026-08-25 |
| G1 |
a2ui-core: full v1.0 message handling, JSON Pointer binding, function evaluation |
2026-10-31 | ✅ 2026-08-27 |
| G2 |
a2ui-material3: the v1.0 standard widget catalog on Material 3 |
2026-12-31 | ✅ 2026-09-01 |
| G3 | Publish 0.1.0 to Maven Central |
2026-12-31 | ✅ 2026-09-08 |
All four are closed, each ahead of its date. That is a different claim from "finished": 0.1.0 is a
0.x line whose API is not stable, the release notes list what a consumer should know before
adopting it, and the ✅ on G3 is a publication rather than a claim of full conformance — see
UAX #31 is enforced on catalogs and in
formatString for the one rule 0.1.0
applied unevenly, unified since.
Four Kotlin/Compose A2UI renderers exist. All are worth reading, and none is currently a production-ready Compose Multiplatform option:
Contextable/a2ui-4k — Apache-2.0, the only one published
to Maven Central. Already Compose Multiplatform despite the "for KMP" description. v0.9, no wasmJs.mikepenz/A2CUI — the best module decomposition of the four,
with AG-UI transport and codegen. No LICENSE file, unpublished.coder-brzhang/a2ui-compose — Android only.NikhilBhutani/compose-genui../gradlew buildApache-2.0, matching the upstream A2UI project.
A renderer for the A2UI protocol built on Compose Multiplatform — Android, iOS, desktop (JVM), macOS, and web (JS + wasmJs) from a single commonMain.
Status:
0.1.0is on Maven Central, and the API is not stable. A0.xline: expect breaking changes between minor versions. See Installation and Roadmap.
A2UI lets an agent describe a user interface as a stream of JSON, which the client renders with its own native widgets. The agent never ships code — the catalog of renderable components is the trust boundary, and it lives in your binary.
dependencies {
implementation("dev.ynagai.a2ui:a2ui-material3:0.1.0")
}That is the usual entry point: it brings a2ui-compose, which brings a2ui-core. Take the lower
two directly when you do not want the Material 3 renderers -- a2ui-compose to draw the catalog
with your own design system, a2ui-core for the protocol alone, which carries no Compose dependency
at all:
implementation("dev.ynagai.a2ui:a2ui-compose:0.1.0")
implementation("dev.ynagai.a2ui:a2ui-core:0.1.0")Android consumers must compile against compileSdk 37 or later; see Targets for why
that floor is not a preference.
This library targets A2UI v1.0 and carries no v0.8/v0.9 compatibility layer.
The spec site currently labels v0.9.1 "Current" and v1.0 a "release candidate", so that choice
deserves an explanation. The official renderers already ship v1.0: @a2ui/react 0.10.2,
@a2ui/angular 0.10.5 and @a2ui/lit 0.10.3 are all on the 0.10.x line, which is v1.0 under its
former draft name. The "RC" label lags the reference implementations.
Every other Kotlin renderer is on v0.9. Rather than add a fifth v0.9 implementation and inherit a compatibility layer on day one, this one starts where the official renderers already are.
If v1.0 takes a breaking change before GA, the fallback is a stable-v0.9.1 / experimental-v1.0 split rather than a rewrite.
a2ui_protocol.md's "Catalog Entity Naming Rules" make component, function and argument names
MUST-conform to UAX #31, and this library enforces that where a catalog is built:
CatalogDefinition answers ^[\p{XID_Start}_][\p{XID_Continue}]*$ from the Unicode 17.0.0
derived tables, in its constructor, so a catalog assembled in Kotlin is held to it as well as one
decoded off the wire.
formatString's expression parser answers from the same tables. The specification states no
production for a name inside a ${…} expression, so there is no naming rule there to conform to;
what decides it is that a name a catalog accepts should be a name a format string can call.
0.1.0 judged those names by a general-category approximation of its own (isLetter(),
isLetterOrDigit()), which refused _helper — a valid entity name — and accepted ͺ (U+037A,
ID_Start but not XID_Start), which a catalog refuses. The two were unified in
#45; @index remains the one name with a @ a
template may call.
| Target | |
|---|---|
| Android |
minSdk 24, compileSdk 37 |
| JVM (desktop) | |
| iOS |
iosArm64, iosSimulatorArm64
|
| macOS | macosArm64 |
| Web |
js(browser), wasmJs(browser)
|
Three targets are deliberately absent. iosX64 (the Intel iOS simulator) is dropped because Compose
Multiplatform 1.12.0 does not publish variants for it. macosX64 is dropped because Kotlin has
demoted it out of the supported tiers. linux/mingw are not Compose targets.
The compileSdk floor of 37 is not a preference — it is forced by androidx.compose:1.12.0, which
Compose Multiplatform pulls in on Android. It becomes minCompileSdk in the published AAR metadata,
so every Android consumer must compile against 37 or later.
commonTest runs on all six targets, Android included — 492 of the suite's assertions run against
the Android variant on the JVM, which is what withHostTest {} buys. Before that they ran on five
targets and the sixth was silent: not one test had ever executed against the variant whose .aar
is published.
The Compose UI tests are the exception, and Android is the target that misses them. They need
a composition to draw into; Android's host tests have none without Robolectric or an on-device
instrumentation run, and this repository has neither. So the component renderers in
a2ui-material3 — what a CheckBox does with a tap, what a Modal intercepts — ship in the
.aar with nothing on Android having drawn them. They are covered on JVM, macOS, iOS and
wasmJs from the same commonMain, which is most of the argument but not all of it. Kotlin/JS is
in the same position for a different reason (its test harness cannot boot Skiko) and is covered by
the Gallery instead; Android has no equivalent, because the Gallery does not build for it.
| Artifact | Contents |
|---|---|
dev.ynagai.a2ui:a2ui-core |
Protocol types, v1.0 message parsing and serialization, data model and JSON Pointer binding, function evaluation, validation. No Compose dependency. |
dev.ynagai.a2ui:a2ui-compose |
A2uiSurface, A2uiRenderer, the component registry, and the bounds that keep an agent's payload from outgrowing a composition. Depends on compose.runtime and compose.ui — no design system.
|
dev.ynagai.a2ui:a2ui-material3 |
All eighteen of the catalog's components drawn with Material 3 -- every component the specification's forty-three examples name. Video and AudioPlayer draw a media component's frame and play nothing: there is no player in Compose Multiplatform, and a host with a media stack registers its own renderer for the two. Tabs and Modal hold state the agent cannot see or set, which is what the guide asks for; a Modal intercepts its trigger's taps, so a trigger carrying an action does not dispatch it -- and only its taps: a keyboard or a screen reader activating the trigger still reaches the button underneath, so it dispatches the action and does not open the dialog, which is the component's one known gap. checks, the catalog's renderer-side validation, is honoured: a Button whose check fails is disabled and a failing input is captioned with the message. Almost every string on a surface is the agent's own; the five that cannot be (a picker dialog's confirm and cancel, a filter field's label, a modal's close button, a video's frame) come from LocalA2uiStrings, English until a host provides otherwise. Image draws through a host-provided A2uiImageLoader, and a described placeholder without one -- this library fetches nothing itself, and a loader that does is handed the agent's URL unvetted. Every leaf and framed component carries a uniform 8dp margin (the guide's Leaf-Margin Strategy), which Text, Button and TextField did not have before. |
formatNumber, formatCurrency, formatDate and pluralize run through a LocaleFormatter, and
the one they get unless a host chooses otherwise is FallbackLocaleFormatter — which opens its own
documentation by saying it is not a locale implementation. It renders USD 1,234.50 rather than a
symbol, applies the English n == 1 plural rule whatever the language, and uses English month
names.
That is the intended default, not an oversight. A renderer that read the device by default would make one payload render differently in CI than on a desk. But it is chosen silently, so choose:
val renderer = remember {
A2uiRenderer(A2uiRendererConfig.Default.withLocale(systemLocaleFormatter()))
}localeFormatter(tag) takes a fixed locale instead. The corpus reaches those four functions in
roughly thirty places, so a host that ships without choosing is shipping the placeholder.
Every other renderer setting is on the same object. A2uiRendererConfig exists so that the
renderer's constructor is not its compatibility surface: seven defaulted constructor parameters
read well and cannot be extended, because a caller naming one binds to the synthetic defaults
constructor — parameter list plus bitmask — and an eighth setting would break every consumer
compiled against the old one. A new setting adds a withX to the config and breaks nothing.
The split follows the Core SDK / Framework Adapter separation in the A2UI project's own guidance for
new client SDKs. Material 3 is a third artifact rather than part of the adapter because a design
system is a host's choice: a host with its own components takes a2ui-compose alone and writes its
own ComponentRenderers, and pays nothing for a Material 3 it does not use.
Transport is deliberately absent: the library stays transport-free, so you can drive it from SSE, AG-UI, a WebSocket, or a local agent loop without the library taking an opinion.
a2ui-gallery is the reference environment the A2UI framework adapter blueprint asks every renderer
to ship: three columns — the specification's forty-three examples on the left, the live surface with
its JSON message stream and a step-one-message-at-a-time control in the middle, and the data model
and action log on the right. It is a development tool, and it is not published.
It is also where the renderer is exercised on Kotlin/JS. Compose's UI test harness cannot boot Skiko
there, so JS has no rendering test — the Gallery is the thing that runs. It is likewise the only
thing that runs the platform locale tables: it passes systemLocaleFormatter(), which the library
itself leaves opt-in. openUrl is deliberately not wired, so such an action does nothing here.
One limitation worth knowing before reading a layout off it: the preview scrolls, so a surface is
measured with an unbounded height and vertical layout that needs a bounded one does not take
effect — a Column whose justify spreads its children wraps instead. That is the same thing a
host embedding A2uiSurface in its own scroll container sees; check a vertical-arrangement question
somewhere bounded.
./gradlew :a2ui-gallery:run # desktop
./gradlew :a2ui-gallery:wasmJsBrowserDevelopmentRun # browser, Kotlin/Wasm
./gradlew :a2ui-gallery:jsBrowserDevelopmentRun # browser, Kotlin/JS
./gradlew :a2ui-gallery:runDebugExecutableMacosArm64 # native macOSOn iOS the entry point is MainViewController(), written for an Xcode project to set as its root
view controller. It is not reachable yet: the Gallery declares no binaries.framework, so there is
no framework to link and no Xcode project is checked in — the iOS targets are compiled and tested,
not packaged. Android is the one target the Gallery does not build for — the three library
modules do, and only the Gallery does not.
| Gate | Contents | Target | Closed |
|---|---|---|---|
| G0 | Skeleton — build, CI, all targets compiling | 2026-08-31 | ✅ 2026-08-25 |
| G1 |
a2ui-core: full v1.0 message handling, JSON Pointer binding, function evaluation |
2026-10-31 | ✅ 2026-08-27 |
| G2 |
a2ui-material3: the v1.0 standard widget catalog on Material 3 |
2026-12-31 | ✅ 2026-09-01 |
| G3 | Publish 0.1.0 to Maven Central |
2026-12-31 | ✅ 2026-09-08 |
All four are closed, each ahead of its date. That is a different claim from "finished": 0.1.0 is a
0.x line whose API is not stable, the release notes list what a consumer should know before
adopting it, and the ✅ on G3 is a publication rather than a claim of full conformance — see
UAX #31 is enforced on catalogs and in
formatString for the one rule 0.1.0
applied unevenly, unified since.
Four Kotlin/Compose A2UI renderers exist. All are worth reading, and none is currently a production-ready Compose Multiplatform option:
Contextable/a2ui-4k — Apache-2.0, the only one published
to Maven Central. Already Compose Multiplatform despite the "for KMP" description. v0.9, no wasmJs.mikepenz/A2CUI — the best module decomposition of the four,
with AG-UI transport and codegen. No LICENSE file, unpublished.coder-brzhang/a2ui-compose — Android only.NikhilBhutani/compose-genui../gradlew buildApache-2.0, matching the upstream A2UI project.