A2UI 9 min read

A2UI for Jetpack Compose: Let AI Agents Render Native UI From Your Component Catalog

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.

0.9.1
A2UI spec version the renderer supports
alpha02
Latest release, October 21, 2026
No code
Agent picks from your catalog; nothing is executed

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

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:

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.

Comments 0

No comments yet. Be the first to leave one!

Leave a comment