Most "AI renders your UI" demos are a chat bubble full of markdown. A2UI is a different bet: the agent sends a JSON description of an interface, and your app turns it into real Compose components, without the agent ever shipping code. AndroidX now has an official renderer for it. 1.0.0-alpha01 landed on September 23, 2026 and 1.0.0-alpha02 on October 21. It is alpha, but the architecture is settled enough to learn now.
1. The Core Idea: The Catalog Is the Contract
Every A2UI surface is driven by a component catalog. The catalog declares which components exist, their property schemas and the local functions available. The agent can only build UI from what the catalog declares, so it cannot invent a widget or inject logic. The renderer maps the A2UI JSON protocol onto Compose primitives and uses the Compose Snapshot state system, so when the agent streams an update, only the components that read the changed data recompose.
The core engine is design-system agnostic. You either adopt the provided Basic Catalog built on Material 3, build a custom catalog for your own design system, or mix the two.
2. The Artifacts
androidx.a2ui.compose:compose-runtime: reactivity via Snapshot state.androidx.a2ui.compose:compose-ui: the catalog pattern that maps JSON schemas to Compose UI.androidx.a2ui.compose:compose-ui-testing: testing APIs.androidx.a2ui:a2ui-modelandandroidx.a2ui:a2ui-engine: the data layer, handling schema validation, protocol parsing and message processing.androidx.compose.material3:material3-a2ui: the ready-made Basic Catalog on Material 3.
dependencies {
implementation("androidx.a2ui.compose:compose-runtime:1.0.0-alpha02")
implementation("androidx.a2ui.compose:compose-ui:1.0.0-alpha02")
androidTestImplementation("androidx.a2ui.compose:compose-ui-testing:1.0.0-alpha02")
}
The release notes list those three Compose artifacts; add material3-a2ui if you want the Basic Catalog. Check the release pages for its current version.
3. Wiring It Up in a ViewModel
The data layer lives in your ViewModel. You create a parser and a message processor, feed incoming JSON from your network layer, and expose the active surfaces as a flow. This is the sample from the official docs:
class AgenticUiViewModel : ViewModel() {
private val parser = A2uiMessageParser()
private val processor = A2uiMessageProcessor(
catalogs = listOf(CustomDesignSystemCatalog)
)
val a2uiSurfaces: StateFlow<List<A2uiSurfaceModel>> =
processor.activeSurfaces
init {
viewModelScope.launch(Dispatchers.Default) {
processor.collectMessages()
}
// Two-way communication with the agent.
viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) {
processor.outboundEvents.collect(::handleOutboundA2uiEvent)
}
}
fun onNetworkMessage(json: String) {
processor.processInput(parser, json)
}
}
On the UI side, A2uiSurface is the entry point. It resolves the root component and animates between loading, error and success states, all of which you can override:
@Composable
fun AgenticUiScreen(viewModel: AgenticUiViewModel) {
val surfaces by viewModel.a2uiSurfaces.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize()) {
surfaces.forEach { surface ->
key(surface.id) {
A2uiSurface(
surfaceModel = surface,
loadingContent = { CircularProgressIndicator() },
errorContent = { exception ->
Text("Failed to load: ${exception.message}")
},
)
}
}
}
}
4. Choosing a Catalog
With the Basic Catalog you must supply the parts the library deliberately does not bundle: media renderers (image, video, audio), a URL opener and localized message formatting. That keeps Coil, Glide or Media3 out of the dependency graph unless you already use them:
val basicCatalog = materialA2uiBasicCatalogV1(
image = MaterialA2uiBasicCatalogV1Defaults.image {
url, description, scale, modifier, onError ->
AsyncImage(
model = url,
contentDescription = description,
contentScale = scale,
modifier = modifier,
onError = { state -> onError(state.result.throwable) },
)
},
video = MaterialA2uiBasicCatalogV1Defaults.video { url, modifier, onError -> /* Media3 */ },
audioPlayer = MaterialA2uiBasicCatalogV1Defaults.audioPlayer {
url, description, modifier, onError -> /* audio */ },
urlOpener = { url -> appNavigator.openUrl(url) },
messageFormatter = { pattern, locale, args -> /* your formatter */ pattern },
localeProvider = A2uiLocaleProvider.Default,
)
That is the alpha01 shape. Alpha02 changed it: the function list on A2uiBasicCatalogV1 was replaced by an A2uiBasicCatalogV1.Functions interface, and materialA2uiBasicCatalogV1 now takes a functions parameter instead of urlOpener, messageFormatter and localeProvider. Implementations only need to implement openUrl; A2uiBasicCatalogV1.Functions(context) gives defaults for everything else. Expect to touch this call site when you move to alpha02.
Alpha reality: the official guide pages still show the older parameters in places, while the alpha02 release notes document the change. Trust the release notes and your compiler over the prose, and pin the exact version.
For your own design system, build an A2uiCatalog from custom components. You can also mix them with Basic Catalog pieces:
val hybridCatalog = A2uiCatalog(
catalogId = "https://example.com/catalogs/my-app/v1/catalog.json",
components = listOf(
MaterialA2uiBasicCatalogV1Defaults.text,
MaterialA2uiBasicCatalogV1Defaults.card,
CustomChartComponent,
CustomProductCardComponent,
),
functions = createBasicCatalogFunctions(...),
)
5. Writing a Custom Component
A component implements A2uiComponent: a name, a description, a list of typed A2uiProperty declarations (used both to generate the JSON schema and to read values at runtime), an optional isReady, and a Content composable.
object CustomTextComponent : A2uiComponent {
private val textProp = A2uiProperty.dynamicString("text", required = true)
private val variantProp = A2uiProperty.stringEnum(
"variant",
enumValues = listOf("body", "title"),
)
override val name = "Text"
override val description = "Displays dynamic text."
override val properties = listOf(textProp, variantProp)
@Composable
override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean =
properties.bind(textProp) != null
@Composable
override fun A2uiComponentScope.Content(
properties: A2uiComponentProperties,
modifier: Modifier,
) {
val text = properties.bind(textProp) ?: ""
val variant = properties[variantProp] ?: "body"
Text(
text = text,
style = if (variant == "title") MaterialTheme.typography.titleLarge
else MaterialTheme.typography.bodyLarge,
modifier = modifier,
)
}
}
Two details matter. bind resolves a dynamic data-model value and subscribes to updates, which is what makes streaming cheap. Static config such as the variant is read with properties[prop]. For input components, bindUpdater returns a writable updater, or null when the agent gave a literal instead of a data path, which is your signal to render the field read-only:
val checked = properties.bind(valueProp) ?: false
val onCheckedChange = properties.bindUpdater(valueProp)
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = onCheckedChange != null,
)
User interactions go back to the agent through dispatchAction on A2uiComponentScope. Children are resolved with observeA2uiComponentState(id), which yields Loading, Error or Success, so a parent can render its shell while children arrive independently.
6. Defensive by Design
LLM output is untrusted input, and the docs treat it that way. Per the official guide:
- Errors are dispatched back to the agent as outbound client messages so it can self-correct on the next turn.
- Unknown component types are intercepted before reaching the UI tree, marked as errors and reported.
- Payloads are validated against component schemas (
A2uiSchema), so malformed properties never reach a layout. - Very large list indexes switch the data model from a dense list to an adaptive sparse map, preventing out-of-memory errors.
7. Versioning and Testing
Catalogs are versioned by their JSON schema contract. On a breaking schema change you publish a new catalog ID (for example .../v2/catalog.json) and register both versions with the processor, so the agent picks the right one per surface during catalog negotiation. Non-breaking additions use default interface implementations; breaking ones introduce successors such as A2uiComponentV2. Once the libraries reach 1.0.0, normal AndroidX binary compatibility rules apply.
For tests, alpha01 added the compose-ui-testing artifact with an A2uiTestSurface composable that mounts surfaces and components in isolation.
Should You Adopt It Now?
Not in production. It is alpha, the spec support is 0.9.1, and alpha02 already changed the Basic Catalog factory. But if you are building any agent-driven screen, such as a support assistant, a shopping helper or an in-app copilot, this is worth a spike. The pattern it enforces is the right one: the model proposes, a typed catalog disposes. Start with the Basic Catalog, wire your existing image loader, and write one custom component to see how bind and dispatchAction feel. Also note that alpha01 raised the Compose libraries' compileSdk to 37.1, which transitively affects apps using them.
Sources: the "Agent-to-UI renderer for Jetpack Compose" guide pages (overview, render surfaces, implement components, manage catalogs) and the A2UI and A2UI Compose release notes on developer.android.com.
No comments yet. Be the first to leave one!