Implement custom A2UI components

In the A2UI architecture, every surface is driven by a component catalog. Rather than having an AI agent invent its own UI primitives or generate arbitrary code, your catalog declares the components, property schemas, and capabilities available to the agent. The agent then uses these components to construct a user interface.

When you build a custom catalog for your app's design system, you implement components that map those catalog definitions into concrete Jetpack Compose UI elements. Each A2UI component (A2uiComponent) defines its property schema contract, evaluates readiness as dynamic data arrives, binds reactive properties from the data model, emits Compose UI, and dispatches user interaction actions back to the agent.

The Compose UI renderer (androidx.a2ui.compose:compose-ui) provides the interfaces and receiver scopes needed to implement custom components, which follow your app's design system.

Declare statically typed component properties

Before rendering, declare the properties that a component expects from the agent. The runtime layer provides statically typed A2uiProperty APIs used for both JSON schema generation and extracting values at runtime:

// Define static properties, dynamic bindings, and component references
val textProp = A2uiProperty.dynamicString("text", required = true)
val variantProp = A2uiProperty.stringEnum("variant", enumValues = listOf("body", "title"))
val childProp = A2uiProperty.componentId("child", required = true)
val actionProp = A2uiProperty.action("action", required = true)

Implement the A2uiComponent interface

Implement the A2uiComponent interface to define a component's schema and map properties received from the agent onto Compose UI:

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 {
        // The component does not become ready until dynamic text data arrives
        return properties.bind(textProp) != null
    }

    @Composable
    override fun A2uiComponentScope.Content(
        properties: A2uiComponentProperties,
        modifier: Modifier,
    ) {
        // Reactively resolve dynamic data binding and subscribe to updates
        val text = properties.bind(textProp) ?: ""

        // Read the static configuration property
        val variant = properties[variantProp] ?: "body"
        val textStyle = if (variant == "title") {
            MaterialTheme.typography.titleLarge
        } else {
            MaterialTheme.typography.bodyLarge
        }

        Text(
            text = text,
            style = textStyle,
            modifier = modifier,
        )
    }
}

Resolve regular and two-way data model bindings

Component implementations use A2uiComponentScope to resolve dynamically bound properties. For regular dynamic properties, bind returns the current value and automatically subscribes to data model updates.

For interactive input components, bindUpdater returns a stable updater lambda. If the agent provided a literal string instead of a writable data path, the updater lambda is null, signaling that the field is read-only:

val labelProp = A2uiProperty.dynamicString("label", required = true)
val valueProp = A2uiProperty.dynamicBoolean("value")

@Composable
fun A2uiComponentScope.CustomCheckbox(properties: A2uiComponentProperties) {
    // Read a dynamic property from the data model subscribing to updates
    val label = properties.bind(labelProp) ?: ""

    // Bind a property value and its updater to handle two-way data binding
    val checked = properties.bind(valueProp) ?: false
    val onCheckedChange = properties.bindUpdater(valueProp)

    Row(verticalAlignment = Alignment.CenterVertically) {
        Checkbox(
            checked = checked,
            onCheckedChange = onCheckedChange,
            enabled = (onCheckedChange != null), // Read-only if no writable path was bound
        )
        Text(text = label)
    }
}

Dispatch user actions to the agent

Interactive components use A2uiComponentScope.dispatchAction to send user events back to the agent:

object CustomButtonComponent : A2uiComponent {
    private val childProp = A2uiProperty.componentId("child", required = true)
    private val actionProp = A2uiProperty.action("action", required = true)

    override val name = "Button"
    override val description = "A clickable button."
    override val properties = listOf(childProp, actionProp)

    @Composable
    override fun A2uiComponentScope.Content(
        properties: A2uiComponentProperties,
        modifier: Modifier,
    ) {
        val actionDefinition = properties[actionProp]
        val childId = properties[childProp] ?: return
        val currentAction by rememberUpdatedState(actionDefinition)
        val onClick: () -> Unit = remember {
            { currentAction?.let { dispatchAction(it) } }
        }

        Button(onClick = onClick, modifier = modifier) {
            val childState = observeA2uiComponentState(id = childId)
            when (childState) {
                is A2uiComponentState.Loading -> CircularProgressIndicator()
                is A2uiComponentState.Error -> Text("Error")
                is A2uiComponentState.Success -> A2uiComponent(childState.component)
            }
        }
    }
}

Handle child components and progressive rendering

Components supporting nested children use observeA2uiComponentState(id) to observe child states. This enables progressive rendering where a parent container renders its shell while child components load independently:

val headerChildProp = A2uiProperty.componentId("headerId", required = true)

@Composable
fun A2uiComponentScope.CustomCompositeContent(
    properties: A2uiComponentProperties,
) {
    val headerId = properties[headerChildProp] ?: return

    val headerState = observeA2uiComponentState(id = headerId)
    when (headerState) {
        is A2uiComponentState.Loading -> {
            // Render a localized loading placeholder
            LinearProgressIndicator()
        }
        is A2uiComponentState.Error -> {
            // Render a localized error fallback
            Text("Failed to load header")
        }
        is A2uiComponentState.Success -> {
            // Forward the resolved child component to the visual UI router
            A2uiComponent(headerState.component)
        }
    }
}

To handle collections or lists of children (such as items in a column, row, or list), declare a property using A2uiProperty.childList and resolve the children with bindChildReferences:

val childrenProp = A2uiProperty.childList("children", required = true)

@Composable
fun A2uiComponentScope.CustomColumn(
    properties: A2uiComponentProperties,
    modifier: Modifier = Modifier,
) {
    // Resolve child references (supports both static ID arrays and dynamic data templates)
    val childReferences = properties.bindChildReferences(childrenProp) ?: return

    Column(modifier = modifier) {
        childReferences.forEach { reference ->
            key(reference.id, reference.baseDataPath) {
                val childState = observeA2uiComponentState(reference)
                when (childState) {
                    is A2uiComponentState.Loading -> CircularProgressIndicator()
                    is A2uiComponentState.Error -> Text("Failed to load child")
                    is A2uiComponentState.Success -> A2uiComponent(childState.component)
                }
            }
        }
    }
}

Integrate native media rendering in the Basic Catalog

When using the provided Basic Catalog implementation (androidx.compose.material3:material3-a2ui), you can plug your preferred media libraries (such as Coil for images or ExoPlayer for video) into the Basic Catalog's media components:

// Configure an Image component for the Basic Catalog using Coil
val coilImage = MaterialA2uiBasicCatalogV1Defaults.image { url, desc, scale, modifier, onError ->
    AsyncImage(
        model = url,
        contentDescription = desc,
        contentScale = scale,
        modifier = modifier,
        onError = { state -> onError(state.result.throwable) },
    )
}

Implementation details

The following sections explain recursive UI emission, dynamic property evaluation, and error reporting.

The component implementation user journeys introduce the following key APIs:

  • A2uiComponent: Interface defining component metadata, property schemas, readiness checks (isReady), and rendering emission (Content).
  • A2uiProperty: A statically typed property declaration used for JSON schema generation and runtime value resolution.
  • A2uiComponentScope: A receiver scope providing contextual capabilities (such as data binding, action dispatching, and child state observation) to component implementations.
  • A2uiComponentProperties: A container for component properties received from the agent that provides type-safe property access.
  • A2uiComponentState: Represents the reactive loading, success, or error resolution state of a component.

Recursive UI emission and dynamic routing

The root state hoisted by the caller (or child component state resolved within a parent) kicks off recursive component rendering through the A2uiComponent composable function. Rather than tightly coupling the resolved state to a specific UI implementation, this function acts as a dynamic router.