DEV Community

Cover image for Build with Google's new A2UI Spec: Agent User Interfaces with A2UI + AG-UI
Bonnie for CopilotKit

Posted on • Originally published at copilotkit.ai

Build with Google's new A2UI Spec: Agent User Interfaces with A2UI + AG-UI

TL;DR

In this guide, you will learn how to build full-stack agent-to-user interface (A2UI) agents using the agent-to-agent (A2A) protocol, the AG-UI protocol, and CopilotKit.

Before we jump in, I would also like to cover how A2UI & AG-UI fit together. You can read about it here

For this build, we'll cover:

  • What is A2UI?

  • Building A2UI + A2A agent backend

  • Building A2UI + A2A agent frontend using AG-UI and CopilotKit

Here is a preview of what we will be building:

What is A2UI?

A2UI is a new open-source UI Toolkit to facilitate LLM-generated UIs that allows AI agents to create and present dynamic, interactive user interfaces (UIs) on the fly, rather than relying on a fixed, pre-built interface.

A2UI is built with the A2A protocol and allows an A2A agent to send interactive components instead of just text, using a high-level framework-agnostic format that can be rendered natively on any surface

In simpler terms, instead of the AI agent only responding with text in a chat window, it can respond by generating a complete UI component like:

  • An interactive chart or graph.

  • A fully populated form or table.

  • Specific buttons (like "Approve" or "Reject") relevant only to the task at hand.

You can learn more about A2UI here.

Image from Notion

Now that you have learned what the A2UI protocol is, let us see how to use it together with A2A, AG-UI, and CopilotKit to build full-stack A2UI AI agents.

Prerequisites

To fully understand this tutorial, you need to have a basic understanding of React or Next.js

We'll also make use of the following:

  • Python - a popular programming language for building AI agents with AI agent frameworks; make sure it is installed on your computer.

  • AG-UI Protocol - The Agent User Interaction Protocol (AG-UI), developed by CopilotKit, is an open-source, lightweight, event-based protocol that facilitates rich, real-time interactions between the frontend and your AI agent backend.

  • Google ADK - an open-source framework designed by Google to simplify the process of building complex and production-ready AI agents.

  • Gemini API Key - an API key to enable you to perform various tasks using the Gemini models for ADK agents.

  • CopilotKit - an open-source copilot framework for building custom AI chatbots, in-app AI agents, and text areas.

Building A2UI + A2A agent backend

In this section, you will learn how to configure your agent to use A2UI using the A2A protocol in the backend.

Let’s get started.

To get started, clone the A2A starter template using the command below:

git clone https://github.com/copilotkit/with-a2a-a2ui.git
Enter fullscreen mode Exit fullscreen mode

After that, create an environment file with your API key:

echo "GEMINI_API_KEY=your_api_key_here" > .env
Enter fullscreen mode Exit fullscreen mode

Then run the command below to install the dependencies:

pnpm install
Enter fullscreen mode Exit fullscreen mode

Finally, run and connect your agent using the following command:

pnpm dev
Enter fullscreen mode Exit fullscreen mode

Let us now see how to configure your agent to use A2UI using the A2A protocol.

Step 1: Define your agent A2UI Response Components

To define your agent A2UI response components, first, define a schema that contains the complete JSON Schema that defines a valid A2UI message, as shown in the agent/restaurant_finder/prompt_builder.py file.

A2UI_SCHEMA = r'''
{
  "title": "A2UI Message Schema",
  "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to construct and update user interfaces dynamically. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.",
  "type": "object",
  "properties": {
    "beginRendering": {
      "type": "object",
      "description": "Signals the client to begin rendering a surface with a root component and specific styles.",
      "properties": {
        "surfaceId": {
          "type": "string",
          "description": "The unique identifier for the UI surface to be rendered."
        },
        "root": {
          "type": "string",
          "description": "The ID of the root component to render."
        },
        "styles": {
          "type": "object",
          "description": "Styling information for the UI.",
          "properties": {
            "font": {
              "type": "string",
              "description": "The primary font for the UI."
            },
            "primaryColor": {
              "type": "string",
              "description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').",
              "pattern": "^#[0-9a-fA-F]{6}$"
            }
          }
        }
      },
      "required": ["root", "surfaceId"]
    },

   // ...

  }
Enter fullscreen mode Exit fullscreen mode

Then define A2UI response components that your agent uses to format its responses, as shown in the agent/restaurant_finder/prompt_builder.py file.

RESTAURANT_UI_EXAMPLES = """
---BEGIN SINGLE_COLUMN_LIST_EXAMPLE---
[
  {{ "beginRendering": {{ "surfaceId": "default", "root": "root-column", "styles": {{ "primaryColor": "#FF0000", "font": "Roboto" }} }} }},
  {{ "surfaceUpdate": {{
    "surfaceId": "default",
    "components": [
      {{ "id": "root-column", "component": {{ "Column": {{ "children": {{ "explicitList": ["title-heading", "item-list"] }} }} }} }},
      {{ "id": "title-heading", "component": {{ "Text": {{ "usageHint": "h1", "text": {{ "literalString": "Top Restaurants" }} }} }} }},
      {{ "id": "item-list", "component": {{ "List": {{ "direction": "vertical", "children": {{ "template": {{ "componentId": "item-card-template", "dataBinding": "/items" }} }} }} }} }},
      {{ "id": "item-card-template", "component": {{ "Card": {{ "child": "card-layout" }} }} }},
      {{ "id": "card-layout", "component": {{ "Row": {{ "children": {{ "explicitList": ["template-image", "card-details"] }} }} }} }},
      {{ "id": "template-image", weight: 1, "component": {{ "Image": {{ "url": {{ "path": "imageUrl" }} }} }} }},
      {{ "id": "card-details", weight: 2, "component": {{ "Column": {{ "children": {{ "explicitList": ["template-name", "template-rating", "template-detail", "template-link", "template-book-button"] }} }} }} }},
      {{ "id": "template-name", "component": {{ "Text": {{ "usageHint": "h3", "text": {{ "path": "name" }} }} }} }},
      {{ "id": "template-rating", "component": {{ "Text": {{ "text": {{ "path": "rating" }} }} }} }},
      {{ "id": "template-detail", "component": {{ "Text": {{ "text": {{ "path": "detail" }} }} }} }},
      {{ "id": "template-link", "component": {{ "Text": {{ "text": {{ "path": "infoLink" }} }} }} }},
      {{ "id": "template-book-button", "component": {{ "Button": {{ "child": "book-now-text", "primary": true, "action": {{ "name": "book_restaurant", "context": [ {{ "key": "restaurantName", "value": {{ "path": "name" }} }}, {{ "key": "imageUrl", "value": {{ "path": "imageUrl" }} }}, {{ "key": "address", "value": {{ "path": "address" }} }} ] }} }} }} }},
      {{ "id": "book-now-text", "component": {{ "Text": {{ "text": {{ "literalString": "Book Now" }} }} }} }}
    ]
  }} }},
  {{ "dataModelUpdate": {{
    "surfaceId": "default",
    "path": "/",
    "contents": [
      {{ "key": "items", "valueMap": [
        {{ "key": "item1", "valueMap": [
          {{ "key": "name", "valueString": "The Fancy Place" }},
          {{ "key": "rating", "valueNumber": 4.8 }},
          {{ "key": "detail", "valueString": "Fine dining experience" }},
          {{ "key": "infoLink", "valueString": "https://example.com/fancy" }},
          {{ "key": "imageUrl", "valueString": "https://example.com/fancy.jpg" }},
          {{ "key": "address", "valueString": "123 Main St" }}
        ] }},
        {{ "key": "item2", "valueMap": [
          {{ "key": "name", "valueString": "Quick Bites" }},
          {{ "key": "rating", "valueNumber": 4.2 }},
          {{ "key": "detail", "valueString": "Casual and fast" }},
          {{ "key": "infoLink", "valueString": "https://example.com/quick" }},
          {{ "key": "imageUrl", "valueString": "https://example.com/quick.jpg" }},
          {{ "key": "address", "valueString": "456 Oak Ave" }}
        ] }}
      ] }} // Populate this with restaurant data
    ]
  }} }}
]
---END SINGLE_COLUMN_LIST_EXAMPLE---

// ...
Enter fullscreen mode Exit fullscreen mode

After that, define a function that constructs the full prompt with instructions, A2UI response components and A2UI message JSON Schema, as shown in the agent/restaurant_finder/prompt_builder.py file.

def get_ui_prompt(base_url: str, examples: str) -> str:
    """
    Constructs the full prompt with UI instructions, rules, examples, and schema.

    Args:
        base_url: The base URL for resolving static assets like logos.
        examples: A string containing the specific UI examples for the agent's task.

    Returns:
        A formatted string to be used as the system prompt for the LLM.
    """
    # The f-string substitution for base_url happens here, at runtime.
    formatted_examples = examples.format(base_url=base_url)

    return f"""
    You are a helpful restaurant finding assistant. Your final output MUST be a A2UI UI JSON response.

    To generate the response, you MUST follow these rules:
    1.  Your response MUST be in two parts, separated by the delimiter: `---a2ui_JSON---`.
    2.  The first part is your conversational text response.
    3.  The second part is a single, raw JSON object that is a list of A2UI messages.
    4.  The JSON part MUST validate against the A2UI JSON SCHEMA provided below.

    --- UI TEMPLATE RULES ---
    -   If the query is for a list of restaurants, use the restaurant data you have already received from the `get_restaurants` tool to populate the `dataModelUpdate.contents` array (e.g., as a `valueMap` for the "items" key).
    -   If the number of restaurants is 5 or fewer, you MUST use the `SINGLE_COLUMN_LIST_EXAMPLE` template.
    -   If the number of restaurants is more than 5, you MUST use the `TWO_COLUMN_LIST_EXAMPLE` template.
    -   If the query is to book a restaurant (e.g., "USER_WANTS_TO_BOOK..."), you MUST use the `BOOKING_FORM_EXAMPLE` template.
    -   If the query is a booking submission (e.g., "User submitted a booking..."), you MUST use the `CONFIRMATION_EXAMPLE` template.

    {formatted_examples}

    ---BEGIN A2UI JSON SCHEMA---
    {A2UI_SCHEMA}
    ---END A2UI JSON SCHEMA---
    """
Enter fullscreen mode Exit fullscreen mode

Step 2: Generating A2UI components with A2UI Composer

If you want an easy way to generate components, you can use the A2UI Composer, which enables you to generate A2UI components.

To create a component using the A2UI composer, go to https://a2ui-editor.ag-ui.com/ and describe your A2UI widget, as shown below.

Image from Notion

Then click the create button, and the composer will generate your A2UI widget, as shown below.

Image from Notion

After that, copy the generated JSON spec and paste it into your agent's prompt as we discussed in step one above.

Step 3: Configure your A2UI agent

Once you have set up your A2A components and assembled your agent prompt, configure your agent, as shown in the agent/restaurant_finder/agent.py file.

// ...

# Local imports from sibling modules
from prompt_builder import (
    A2UI_SCHEMA,  # JSON Schema for A2UI validation
    RESTAURANT_UI_EXAMPLES,  # Example A2UI responses for few-shot learning
    get_text_prompt,  # Prompt for text-only mode
    get_ui_prompt,  # Prompt for UI mode (with schema and examples)
)
from tools import get_restaurants  # Tool function for fetching restaurant data

logger = logging.getLogger(__name__)

AGENT_INSTRUCTION = """
    You are a helpful restaurant finding assistant. Your goal is....
"""

class RestaurantAgent:
    """
    An agent that finds restaurants based on user criteria.

    """

    # Supported content types for A2A protocol
    SUPPORTED_CONTENT_TYPES = ["text", "text/plain"]

    def __init__(self, base_url: str, use_ui: bool = False):
        """
        Initialize the RestaurantAgent.

        Args:
            base_url: The server's base URL (used for resolving image URLs)
            use_ui: If True, generate A2UI JSON responses; if False, generate text
        """
        self.base_url = base_url
        self.use_ui = use_ui

        # Build the underlying LlmAgent with appropriate instructions
        self._agent = self._build_agent(use_ui)

        # User ID for session management (constant for this simple example)
        self._user_id = "remote_agent"

        // ...

        # =====================================================================
        # LOAD AND WRAP A2UI SCHEMA
        # =====================================================================
        # We need to validate LLM responses against the A2UI schema.
        # The schema defines a SINGLE message, but the LLM returns a LIST of messages.
        # So we wrap the schema in an array validator.
        try:
            # Parse the schema for a single A2UI message
            single_message_schema = json.loads(A2UI_SCHEMA)

            # Wrap it: the LLM must return an ARRAY of valid messages
            self.a2ui_schema_object = {"type": "array", "items": single_message_schema}
            logger.info(
                "A2UI_SCHEMA successfully loaded and wrapped in an array validator."
            )
        except json.JSONDecodeError as e:
            logger.error(f"CRITICAL: Failed to parse A2UI_SCHEMA: {e}")
            self.a2ui_schema_object = None  # Validation will be skipped

   // ...

    # =========================================================================
    # STEP 3c: BUILD THE LLM AGENT
    # =========================================================================
    def _build_agent(self, use_ui: bool) -> LlmAgent:
        """
        Builds the LLM agent with appropriate instructions and tools.

        Args:
            use_ui: Whether to include UI instructions and schema in the prompt

        Returns:
            A configured LlmAgent instance
        """
        # Get the model name from the environment variable, with a sensible default
        LITELLM_MODEL = os.getenv("LITELLM_MODEL", "gemini/gemini-2.5-flash")

        if use_ui:
            # UI mode: Include instructions for generating A2UI JSON
            # This adds:
            # - Detailed formatting rules (delimiter, JSON structure)
            # - Template examples for different scenarios
            # - The complete A2UI JSON schema
            instruction = AGENT_INSTRUCTION + get_ui_prompt(
                self.base_url, RESTAURANT_UI_EXAMPLES
            )
        else:
            # Text mode: Simple instructions for text-only responses
            instruction = get_text_prompt()

        # Create the LlmAgent with:
        # - A LiteLLM wrapper (allows using different LLM providers)
        # - Name and description for identification
        # - The instruction (system prompt)
        # - Available tools (get_restaurants function)
        return LlmAgent(
            model=LiteLlm(model=LITELLM_MODEL),
            name="restaurant_agent",
            description="An agent that finds restaurants and helps book tables.",
            instruction=instruction,
            tools=[get_restaurants],  # The agent can call this function
        )

      // ...
Enter fullscreen mode Exit fullscreen mode

Step 4: Configure your A2UI agent executor using A2A protocol

After configuring your A2UI agent, configure the agent with an A2A agent executor that handles A2A requests and responses, as shown in the agent/restaurant_finder/agent_executer.py file.

// ...

# =============================================================================
# STEP 2: AGENT EXECUTOR CLASS
# =============================================================================
class RestaurantAgentExecutor(AgentExecutor):
    """
    Restaurant AgentExecutor - Bridges A2A Protocol and RestaurantAgent.


    """

    # =========================================================================
    # STEP 2a: INITIALIZATION
    # =========================================================================
    def __init__(self, base_url: str):
        """
        Initialize the executor with two agent instances.


        """
        # UI agent: Generates rich A2UI responses with components and data models
        self.ui_agent = RestaurantAgent(base_url=base_url, use_ui=True)

        # Text agent: Generates plain text responses (for clients without A2UI support)
        self.text_agent = RestaurantAgent(base_url=base_url, use_ui=False)

    # =========================================================================
    # STEP 2b: MAIN EXECUTION METHOD
    # =========================================================================
    async def execute(
        self,
        context: RequestContext,
        event_queue: EventQueue,
    ) -> None:
        """
        Main entry point for handling A2A requests.

        This method orchestrates the entire request-response cycle:
        1. Detects whether the client supports A2UI
        2. Parses incoming message parts (text or UI events)
        3. Transforms UI events into natural language queries
        4. Invokes the appropriate agent
        5. Formats and sends the response

        Args:
            context: Contains the incoming message, task state, and client info
            event_queue: Queue for sending events (status updates, responses) to client
        """
        # Initialize variables for tracking the query and UI events
        query = ""  # The final query string to send to the LLM
        ui_event_part = None  # Will hold A2UI event data if present
        action = None  # The action name from UI events (e.g., "book_restaurant")

        # =====================================================================
        # STEP 2b-i: DETECT A2UI SUPPORT
        # =====================================================================
        # Check which protocol extensions the client requested
        logger.info(
            f"--- Client requested extensions: {context.requested_extensions} ---"
        )

        # try_activate_a2ui_extension checks if A2UI is in the requested extensions
        # and marks it as active in the context if so
        use_ui = try_activate_a2ui_extension(context)

        # =====================================================================
        # STEP 2b-ii: SELECT APPROPRIATE AGENT
        # =====================================================================
        # Based on A2UI support, choose which agent to use
        if use_ui:
            agent = self.ui_agent
            logger.info(
                "--- AGENT_EXECUTOR: A2UI extension is active. Using UI agent. ---"
            )
        else:
            agent = self.text_agent
            logger.info(
                "--- AGENT_EXECUTOR: A2UI extension is not active. Using text agent. ---"
            )

        # =====================================================================
        # STEP 2b-iii: PARSE INCOMING MESSAGE PARTS
        # =====================================================================
        # A2A messages can contain multiple "parts" - these can be:
        # - TextPart: Plain text from the user
        # - DataPart: Structured data (like A2UI events from button clicks)
        if context.message and context.message.parts:
            logger.info(
                f"--- AGENT_EXECUTOR: Processing {len(context.message.parts)} message parts ---"
            )

            # Iterate through all parts to find relevant data
            for i, part in enumerate(context.message.parts):
                if isinstance(part.root, DataPart):
                    # DataPart contains structured JSON data
                    if "userAction" in part.root.data:
                        # This is an A2UI ClientEvent - a button was clicked!
                        logger.info(f"  Part {i}: Found a2ui UI ClientEvent payload.")
                        ui_event_part = part.root.data["userAction"]
                    else:
                        # Some other structured data
                        logger.info(f"  Part {i}: DataPart (data: {part.root.data})")
                elif isinstance(part.root, TextPart):
                    # Plain text from the user
                    logger.info(f"  Part {i}: TextPart (text: {part.root.text})")
                else:
                    logger.info(f"  Part {i}: Unknown part type ({type(part.root)})")

        # =====================================================================
        # STEP 2b-iv: TRANSFORM UI EVENTS INTO LLM QUERIES
        # =====================================================================
        # If we received a UI event (button click), we need to transform it
        # into a natural language query that the LLM can understand.
        # This is a key part of the A2UI pattern - UI events become LLM inputs.
        if ui_event_part:
            logger.info(f"Received a2ui ClientEvent: {ui_event_part}")

            # Extract the action name and context data from the event
            action = ui_event_part.get("actionName")
            ctx = ui_event_part.get("context", {})

            # Handle "Book Now" button click
            if action == "book_restaurant":
                # User clicked "Book Now" on a restaurant card
                # Extract restaurant details from the button's context
                restaurant_name = ctx.get("restaurantName", "Unknown Restaurant")
                address = ctx.get("address", "Address not provided")
                image_url = ctx.get("imageUrl", "")

                # Create a structured query that tells the LLM what the user wants
                # The LLM is trained to recognize this pattern and show a booking form
                query = f"USER_WANTS_TO_BOOK: {restaurant_name}, Address: {address}, ImageURL: {image_url}"

            # Handle "Submit Reservation" button click
            elif action == "submit_booking":
                # User filled out the booking form and submitted it
                # Extract all the form data from the button's context
                restaurant_name = ctx.get("restaurantName", "Unknown Restaurant")
                party_size = ctx.get("partySize", "Unknown Size")
                reservation_time = ctx.get("reservationTime", "Unknown Time")
                dietary_reqs = ctx.get("dietary", "None")
                image_url = ctx.get("imageUrl", "")

                # Create a query that tells the LLM to confirm the booking
                query = f"User submitted a booking for {restaurant_name} for {party_size} people at {reservation_time} with dietary requirements: {dietary_reqs}. The image URL is {image_url}"

            else:
                # Unknown action - pass it through as-is
                query = f"User submitted an event: {action} with data: {ctx}"
        else:
            # No UI event - this is a regular text message
            logger.info("No a2ui UI event part found. Falling back to text input.")
            query = context.get_user_input()  # Extract text from the message parts

        logger.info(f"--- AGENT_EXECUTOR: Final query for LLM: '{query}' ---")

        // ...

    # =========================================================================
    # STEP 2c: CANCEL METHOD (NOT IMPLEMENTED)
    # =========================================================================
    async def cancel(
        self, request: RequestContext, event_queue: EventQueue
    ) -> Task | None:
        """
        Handle task cancellation requests.

        This agent does not support cancellation, so we raise an error.
        A more sophisticated agent might gracefully stop ongoing work.

        Raises:
            ServerError: With UnsupportedOperationError
        """
        raise ServerError(error=UnsupportedOperationError())
Enter fullscreen mode Exit fullscreen mode

Step 5: Configure your A2UI agent server using A2A protocol

Once you have configured the agent executor, set up a main entry for your agent that sets up and starts an A2A-compliant server that can:

  • Respond to agent discovery requests (via AgentCard)

  • Handle user queries about restaurants

  • Serve static files (restaurant images)

  • Support both text-only and A2UI (rich UI) responses

You can configure the server, as shown in the agent/restaurant_finder/__main__.py file.

// ...

@click.command()  # Decorates this function as a CLI command
@click.option("--host", default="localhost")  # Optional --host argument
@click.option("--port", default=10002)  # Optional --port argument
def main(host, port):
    """
    Main entry point for the Restaurant Finder Agent server.


    """
    try:
        # =====================================================================
        # VALIDATE API CREDENTIALS
        # =====================================================================
        # The agent needs either:
        # - GEMINI_API_KEY for direct Gemini API access, OR
        # - GOOGLE_GENAI_USE_VERTEXAI=TRUE for Vertex AI authentication
        if not os.getenv("GOOGLE_GENAI_USE_VERTEXAI") == "TRUE":
            if not os.getenv("GEMINI_API_KEY"):
                raise MissingAPIKeyError(
                    "GEMINI_API_KEY environment variable not set and GOOGLE_GENAI_USE_VERTEXAI is not TRUE."
                )

        # =====================================================================
        # DEFINE AGENT CAPABILITIES
        # =====================================================================
        # AgentCapabilities tells clients what this agent supports:
        # - streaming=True: Agent can stream responses progressively
        # - extensions: List of protocol extensions (here, A2UI for rich UIs)
        capabilities = AgentCapabilities(
            streaming=True,
            extensions=[get_a2ui_agent_extension()],  # Enable A2UI rich UI support
        )

        # =====================================================================
        # DEFINE AGENT SKILLS
        # =====================================================================
        # AgentSkill describes what this agent can do. This metadata is used
        # for agent discovery and helps clients understand the agent's purpose.
        skill = AgentSkill(
            id="find_restaurants",  # Unique identifier for this skill
            name="Find Restaurants Tool",  # Human-readable name
            description="Helps find restaurants based on user criteria (e.g., cuisine, location).",
            tags=["restaurant", "finder"],  # Searchable tags
            examples=["Find me the top 10 chinese restaurants in the US"],  # Example queries
        )

        # =====================================================================
        # CONSTRUCT BASE URL
        # =====================================================================
        # The base URL is used for:
        # - Agent discovery (clients connect to this URL)
        # - Resolving static asset URLs (e.g., restaurant images)
        base_url = f"http://{host}:{port}"

        # =====================================================================
        # CREATE AGENT CARD
        # =====================================================================
        # The AgentCard is the "identity card" for this agent in the A2A protocol.
        # Clients use this to discover and understand the agent.
        agent_card = AgentCard(
            name="Restaurant Agent",  # Display name
            description="This agent helps find restaurants based on user criteria.",
            url=base_url,  # Where to reach this agent
            version="1.0.0",  # Semantic version
            default_input_modes=RestaurantAgent.SUPPORTED_CONTENT_TYPES,  # ["text", "text/plain"]
            default_output_modes=RestaurantAgent.SUPPORTED_CONTENT_TYPES,
            capabilities=capabilities,  # What the agent supports
            skills=[skill],  # What the agent can do
        )

        # =====================================================================
        # CREATE AGENT EXECUTOR
        # =====================================================================
        # The AgentExecutor is the bridge between the A2A protocol and our
        # RestaurantAgent. It handles:
        # - Parsing A2A requests
        # - Invoking the appropriate agent (UI or text)
        # - Formatting A2A responses
        agent_executor = RestaurantAgentExecutor(base_url=base_url)

        # =====================================================================
        # CREATE REQUEST HANDLER
        # =====================================================================
        # DefaultRequestHandler processes incoming A2A requests and delegates
        # to our agent_executor. The InMemoryTaskStore keeps track of ongoing
        # tasks (useful for multi-turn conversations).
        request_handler = DefaultRequestHandler(
            agent_executor=agent_executor,
            task_store=InMemoryTaskStore(),
        )

        # =====================================================================
        # BUILD THE A2A APPLICATION
        # =====================================================================
        # A2AStarletteApplication creates an ASGI web application that:
        # - Exposes the /.well-known/agent.json endpoint (agent discovery)
        # - Handles A2A protocol messages
        server = A2AStarletteApplication(
            agent_card=agent_card, http_handler=request_handler
        )
        import uvicorn

        # Build the Starlette app from our A2A configuration
        app = server.build()

          // ...

        # =====================================================================
        # MOUNT STATIC FILES
        # =====================================================================
        # Serve restaurant images from the ./images directory at /static
        # Example: http://localhost:10002/static/restaurant1.jpg
        app.mount("/static", StaticFiles(directory="images"), name="static")

        # =====================================================================
        # START THE SERVER
        # =====================================================================
        # uvicorn is an ASGI server that will run our application.
        # It handles HTTP connections and routes them to our app.
        uvicorn.run(app, host=host, port=port)

      // ...
Enter fullscreen mode Exit fullscreen mode

Congrats! You've successfully built your A2UI backend using the A2A protocol. Let’s see how to add a frontend to it using AG-UI and CopilotKit.

Building A2UI + A2A agent frontend using AG-UI and CopilotKit

In this section, you will learn how to configure your A2UI + A2A agent with the frontend using AG-UI protocol that handles communicating with your A2UI + A2A agent, and passes the A2UI messages back and forth as ActivityMessage objects.

Let’s get started.

Step 1: Configure the CopilotKit API Route with AG-UI and A2A Client

To connect your A2UI + A2A agent to the frontend, configure a CopilotKit API route handler that connects the frontend to your A2UI + A2A agent backend using AG-UI and A2A client, as shown below.

// CopilotKit Runtime - Core server-side components
import {
  CopilotRuntime,         // Main runtime that orchestrates agents and handles requests
  createCopilotEndpoint,  // Factory function to create an HTTP endpoint
  InMemoryAgentRunner,    // Runs agents with in-memory state (no persistence)
} from "@copilotkitnext/runtime";

// Hono adapter for Vercel/Next.js - Converts Hono app to Next.js route handlers
import { handle } from "hono/vercel";

// A2A (Agent-to-Agent) Integration
import { A2AAgent } from "@ag-ui/a2a";     // CopilotKit wrapper for A2A protocol
import { A2AClient } from "@a2a-js/sdk/client"; // Client to communicate with A2A servers

// =============================================================================
// STEP 2: CREATE A2A, CLIENT
// =============================================================================
/**
 * A2AClient connects to an A2A-compliant agent server.
 */
const a2aClient = new A2AClient("http://localhost:10002");

// =============================================================================
// STEP 3: CREATE A2A AGENT WRAPPER
// =============================================================================
/**
 * A2AAgent wraps the A2AClient to make it compatible with CopilotKit.
 */
const agent = new A2AAgent({ a2aClient, debug: true });

// =============================================================================
// STEP 4: CREATE COPILOTKIT RUNTIME
// =============================================================================
/**
 * CopilotRuntime is the central orchestrator that:
 * - Manages registered agents
 * - Routes requests to the appropriate agent
 * - Handles streaming responses
 * - Manages conversation state
 */
const runtime = new CopilotRuntime({
  agents: {
    default: agent, // Our A2A Restaurant Agent is the default agent
  },
  runner: new InMemoryAgentRunner(),
});

// =============================================================================
// STEP 5: CREATE THE HTTP ENDPOINT
// =============================================================================
/**
 * createCopilotEndpoint creates a Hono web application that handles
 * CopilotKit API requests.
 *
 * The endpoint handles:
 * - POST /api/copilotkit - Main chat endpoint for sending messages
 * - GET /api/copilotkit - Health check and capability discovery
 * - Various sub-routes for streaming, actions, etc.
 */
const app = createCopilotEndpoint({
  runtime,
  basePath: "/api/copilotkit",
});

export const GET = handle(app as any);
export const POST = handle(app as any);
Enter fullscreen mode Exit fullscreen mode

Step 2: Create A2UI Message Renderer

After configuring the API route, create an A2UI message renderer provided by CopilotKit to render A2UI messages and instantiate it with a theme, as shown below.

"use client";

// A2UI Renderer Factory - Creates a message renderer for A2UI protocol responses
import { createA2UIMessageRenderer } from "@copilotkitnext/a2ui-renderer";

// Custom theme configuration for styling A2UI components
import { theme } from "./theme";

// =============================================================================
// CREATE A2UI MESSAGE RENDERER
// =============================================================================
/**
 * A2UIMessageRenderer is a custom message renderer that knows how to
 * display A2UI protocol messages as rich, interactive UI components.
 *
 * When the A2A agent sends back A2UI JSON (with beginRendering, surfaceUpdate,
 * dataModelUpdate messages), this renderer:
 * 1. Parses the A2UI JSON
 * 2. Builds a component tree from the surfaceUpdate
 * 3. Binds data from dataModelUpdate to components
 * 4. Renders native React components (cards, buttons, forms, etc.)
 * 5. Handles user interactions and sends events back to the agent
 *
 * The `theme` option allows customizing colors, fonts, and spacing of
 * The rendered A2UI components to match your app's design.
 */
const A2UIMessageRenderer = createA2UIMessageRenderer({ theme });

// ....
Enter fullscreen mode Exit fullscreen mode

Step 3: Set up the CopilotKit provider and Chat Component

Once you have created the A2UI message renderer, set up the CopilotKit provider together with the chat component.

Then pass the A2UI message renderer to the CopilotKit provider, as shown below.

"use client";

import { CopilotChat, CopilotKitProvider } from "@copilotkitnext/react";

// ...

export default function Home() {
  return (
    // =========================================================================
    // CopilotKitProvider - The Context Provider
    // =========================================================================
    /**
     * CopilotKitProvider wraps the app and provides:
     * - Connection to the backend API
     * - Shared state for all CopilotKit components
     * - Message history management
     * - Action/tool registration
     *
     * Props:
     * - runtimeUrl: The backend API endpoint (our Next.js API route)
     * - showDevConsole: "auto" shows dev tools in development mode only
     * - renderActivityMessages: Custom renderers for agent messages
     *   - A2UIMessageRenderer handles A2UI protocol messages
     *   - Multiple renderers can be provided for different message types
     */
    <CopilotKitProvider
      runtimeUrl="/api/copilotkit"
      showDevConsole="auto"
      renderActivityMessages={[A2UIMessageRenderer]}
    >
      {/* Main container - full viewport height with flex layout */}
      <main
        className="flex min-h-screen flex-1 flex-col overflow-hidden"
        style={{ minHeight: "100dvh" }} // Use dynamic viewport height for mobile
      >
        <Chat />
      </main>
    </CopilotKitProvider>
  );
}

// =============================================================================
// CHAT COMPONENT
// =============================================================================
/**
 * Chat is a wrapper component that contains the CopilotChat interface.
 */
function Chat() {
  return (
    // Flex container that fills available space
    <div className="flex flex-1 flex-col overflow-hidden">
      <CopilotChat style={{ flex: 1, minHeight: "100%" }} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Running your A2UI + A2A + AG-UI agent

After setting up the CopilotKit provider alongside the chat component, run your agent backend server alongside the frontend server. Then navigate to the frontend localhost URL, and you should see the A2UI agent running with the chat component, as shown below.

Image from Notion

After that, ask the agent to find you restaurants in New York with Chinese cuisine. Then you should see the A2UI components and messages rendering with the top restaurants in New York that you can book and reserve, as shown.

Conclusion

In this guide, we have walked through the steps of building a full-stack A2UI agent using A2A + AG-UI protocols and CopilotKit.

While we’ve explored a couple of features, we have barely scratched the surface of the countless use cases for CopilotKit, ranging from building interactive AI chatbots to building agentic solutions—in essence, CopilotKit lets you add a ton of functional AI capabilities to your products in minutes.

Hopefully, this guide makes it easier for you to integrate AI-powered Copilots into your existing application.

Follow CopilotKit on Twitter and say hi. If you'd like to build something cool, join the Discord community.

Top comments (6)

Collapse
 
uliyahoo profile image
uliyahoo CopilotKit

Nice one Bonnie. A2UI's declarative generative UIs + CopilotKit is the future of agentic application development.

Collapse
 
the_greatbonnie profile image
Bonnie CopilotKit

Totally agree

Collapse
 
nathan_tarbert profile image
Nathan Tarbert CopilotKit

A2UI seems very practical in terms of standardizing UIs. I love how you show it working together with A2A and CopilotKit / AG-UI.
Nicely done!

Collapse
 
the_greatbonnie profile image
Bonnie CopilotKit

I am happy to hear that, Nathan.

Collapse
 
alexhales67 profile image
Alex Hales67

Super interesting, I was just telling my boss this morning that we needed to figure out how to make dynamic contextual interfaces for onboarding clients onto one of our project

Collapse
 
jal_c_68347 profile image
Jalissia_C

Wow, AI is really exploding. Google releases a new spec? I will be digging in for sure