iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
💻

Understanding A2UI Without LLMs: Testing with the Official Client and a Custom Agent

に公開

In this article, the goal is to understand A2UI (Agent-to-User Interface) by
actually running it using the official UI Client and a custom-made A2A Agent.

While I have organized the philosophy and design intentions in a separate article,
in this post, I will intentionally avoid using an LLM and instead use a configuration where:

  • The UI Client uses the official sample
  • The Agent is custom-made with FastAPI
  • It minimally follows the A2UI v0.8 specification

With this setup, we will see "what is happening where in A2UI" through the code and behavior.

If you would like to read about the A2UI philosophy first, click here:
👉What is A2UI? The design philosophy where the Agent doesn't "draw" the UI and differences from MCP Apps


1. Trying Out A2UI

From here, we will use the samples from the A2UI public repository to actually run A2UI locally and check its behavior.

While running the official sample as-is is fine, I want to understand just the behavior of A2UI first. Therefore, I will use the official sample for the UI Client, but implement the Agent myself to run it without an LLM.

1.1 Preparing the UI Client

First, clone the official sample locally.

git clone https://github.com/google/a2ui.git
cd a2ui

The official sample is implemented to work assuming Gemini.
Therefore, we will only start the UI Client part.

cd renderers/web_core
npm install
npm run build

# Lit renderer
cd ../lit
npm install
npm run build

# shell client
cd ../../samples/client/lit/shell
npm install
npm run dev

At this point, the "frontend side (shell)" should be running (however, since there is no agent to connect to, nothing will be displayed or an error will occur).

A2UI Sample UI Client


1.2 Organizing the Agent Interface from the UI Client's Perspective

Before implementing the Agent part, let's confirm under what assumptions the official sample UI Client calls the Agent.

In the official sample, the communication process between the Client and Agent is implemented in samples/client/lit/shell/client.ts. By reading this file, we can understand what the Client side expects from the Agent.

What the Client Side is Doing

In the official sample's Client UI, the following points were confirmed:

  • It communicates with the Agent using the A2AClient from @a2a-js/sdk.
  • JSON-RPC 2.0 is used for communication with the Agent.
  • It calls the message/send method.
  • The response is handled as a Task.

Minimum Requirements for the Agent Side

Based on the above, when implementing a custom Agent to use the official sample UI Client as-is, the Agent side is required to have at least the following interface:

Item Content
Agent Card Returns GET /.well-known/agent-card.json
Communication Method JSON-RPC 2.0
Endpoint POST <agentCard.url>
RPC Method message/send
Response Returns Task as result
UI Definition Includes DataPart (A2UI message) in task.status.message.parts

In the Agent2Agent (A2A) Protocol Official Specification, message/send is defined as one of the JSON-RPC based methods. Additionally, the structure of the Task object and the message parts (e.g., TextPart, DataPart, etc.) are included in the specification as part of the A2A data model.


1.3 Implementing and Running the Agent

To understand how A2UI works, the fastest way is to actually run it. Based on the implementation of the official sample UI Client analyzed in the previous section, I have implemented an A2A Agent using Python + FastAPI.

You can obtain and try out the code I implemented as a sample from the following link:

git clone https://github.com/naokky-tech/sample-a2ui-agent.git
cd sample-a2ui-agent
pip install fastapi uvicorn
uvicorn server:app --reload --port 10002
Operation Screen Capture Explanation
A2UI Test 1 If you reload the official client (refresh the browser), it should return to the state shown on the left. I will enable the Web Inspector to make the behavior easier to understand.
A2UI Test 2 It doesn't matter what you enter in the input form, but when you run it using the ▶️ button, the entered text and two buttons (OK and Cancel) will be rendered.

Inside the implemented Agent, in accordance with the A2UI specifications:

  • The structure of the UI
  • Which values are expected to be returned

are declared and sent back to the client.
To keep the process simple this time, I have not integrated it with an LLM, and the structure of the UI returned is always constant.

If you look at the second image in the operation capture, you can see that it simply receives JSON as a response to the JSON-RPC request. In other words, it is clear that the rendering of actual components like buttons is performed by the client.

If you are interested in the details of the implementation, please check the Appendix where I explain the key points.

1.4 Confirming A2UI's Dependency on the Client for Rendering

Handling input values and execution processes is entirely the responsibility of the client.
To make this a bit clearer, let's confirm this dependency by having the Agent return a component that the official sample client does not support.

Reference: Components provided in A2UI v0.8
https://deepwiki.com/google/A2UI/4.3-standard-component-catalog-v0.8

Testing with a component not provided in A2UI

In the components of a2ui_messages_v0_8(), let's insert a Table, for example, as a likely candidate for a component (the content can be anything; the important part is specifying a component not provided in A2UI).

def a2ui_messages_v0_8(title: str) -> List[Dict[str, Any]]:
    ...
    # --- Components (Adjacency list + v0.8 "component" wrapper) ---
    surface_update = {
        "surfaceUpdate": {
            "surfaceId": surface_id,
            "components": [
                {
                    "id": "root",
                    "component": {
                        "Column": {
                            "children": {"explicitList": ["title_text", "row_buttons"]}
                        }
                    },
                    ...
                    # << Add the component you want the Client to display here >>
                    ...
                },

Component to add (Table)

{
  "id": "table1",
  "component": {
    "Table": {   # ← This should be unsupported as it's not in the standard catalog
      "rows": {"explicitList": ["row1", "row2"]},
        "columns": {"explicitList": ["col1", "col2"]},
    }
  },
},

Mix the ID of the added component, "table1", into the children of root

- "children": {"explicitList": ["title_text", "row_buttons"]}
+ "children": {"explicitList": ["title_text", "row_buttons", "table1"]}

A2UI Test 3

Although the Agent returned the intention to have the Table component output, you can see that it is not displayed on the Client side.

Testing with a component provided in A2UI

Next, let's try another component that is provided in A2UI.
Component to add (a TextField prompting for an email address)

{
  "id": "email-input",
  "component": {
    "TextField": {
      "label": {"literalString": "Email Address"},
      "text": {"path": "/user/email"},
      "textFieldType": "shortText"
    }
  }
},

Mix the ID of the added component, "email-input", into the children of root

- "children": {"explicitList": ["title_text", "row_buttons", "table1"]}
+ "children": {"explicitList": ["title_text", "row_buttons", "table1", "email-input"]}

A2UI Test 4

This time, it was displayed on the Client side.
This is what it means for A2UI to depend on the client for UI rendering.


Appendix: Explanation of the Implemented A2A Agent

Implementation Point 1: Implementing Specifications Required for an A2A Agent

1. The Agent Card contains the "full URL to send JSON-RPC requests to"
PORT = int(os.getenv("PORT", "10002"))
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", f"http://localhost:{PORT}")

JSONRPC_PATH = "/a2a/jsonrpc"

@app.get("/.well-known/agent-card.json")
def agent_card() -> Dict[str, Any]:
    return {
        ...
        "url": f"{PUBLIC_BASE_URL}{JSONRPC_PATH}",
        ...
    }
2. The server actually accepts JSON-RPC at that URL
JSONRPC_PATH = "/a2a/jsonrpc"

@app.post(JSONRPC_PATH)
async def jsonrpc_handler(request: Request) -> JSONResponse:
    body = await request.json()
    ...
    if body.get("jsonrpc") != "2.0":
        return jsonrpc_error(req_id, -32600, "Invalid Request: jsonrpc must be '2.0'", http_status=400)
3. RPC methods defined in A2A, such as `message/send`, can be processed
method = body.get("method")
params = body.get("params") or {}

if method != "message/send":
    return jsonrpc_error(req_id, -32601, f"Method not found: {method}", http_status=404)

params_message = params.get("message") or {}
parsed = extract_user_text_or_a2ui_event(params_message)

...
return JSONResponse(
    status_code=200,
    content={"jsonrpc": "2.0", "id": req_id, "result": task},
)

Ensure that the PUBLIC_BASE_URL in the Agent Card matches the request destination of the Client calling the Agent. Note that the official sample Client UI is implemented to send requests to localhost:10002.

  • The Agent Card's URL (= PUBLIC_BASE_URL + JSONRPC_PATH)
  • The server endpoint @app.post(JSONRPC_PATH)

Also, make sure these two match exactly. If they don't, the client will POST to a non-existent endpoint, and the UI or calls will not function.


Implementation Point 2: Setting up CORS for Browser Execution & Cross-Origin Access

Implementing CORS Middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],          # For development. In production, restrict to the frontend origin.
    allow_credentials=False,
    allow_methods=["*"],          # OPTIONS preflight OK
    allow_headers=["*"],          # X-A2A-Extensions etc. OK
)
  • The ability to respond to OPTIONS (preflight) requests (FastAPI's CORS middleware handles this).
  • Allowing Content-Type: application/json and custom headers (e.g., X-A2A-Extensions).

In this sample, since the UI Client in the browser calls the Agent at a different origin (localhost:10002), it will not work without CORS settings.


Implementation Point 3: Creating A2UI Messages

In this Agent implementation, essentially all we are doing, aside from compliance with the A2A specification, is the following:
(≒ Implementation required for A2UI)

  • Create three A2UI v0.8 ServerToClientMessages:
    1. surfaceUpdate: UI structure (what to display)
    2. dataModelUpdate: State (data used for display)
    3. beginRendering: Start rendering (from which root to draw)
def a2ui_messages_v0_8(title: str) -> List[Dict[str, Any]]:
    ...
    return [surface_update, data_model_update, begin_rendering]

1. surfaceUpdate: "Declaring" the UI

A2UI's surfaceUpdate declares a "component graph" instead of HTML or JSX
surface_update = {
    "surfaceUpdate": {
        "surfaceId": surface_id,
        "components": [
            {
                "id": "root",
                "component": {
                    "Column": {
                        "children": {"explicitList": ["title_text", "row_buttons"]}
                    }
                },
            },
            ...
        ],
    }
}

In v0.8, it must always take the following form:

  • id: Identifier for reference
  • component: The component name is the key, e.g., { "Text": {...} }
Button: Declaring a clickable UI
{
  "id": "btn_ok",
  "component": {
    "Button": {
      "child": "btn_ok_text",
      "action": {"name": "clicked_ok"},
    }
  },
}
  • child is the component ID to be displayed inside the button.
  • action.name is the "meaning" triggered when clicked.

A2UI does not say "execute this JS when clicked"; instead, it declares the meaning that "the clicked_ok action has occurred."

2. dataModelUpdate: Updating State "as a Map"

A2UI v0.8's DataModel is not an intuitive “JSON”
data_model_update = {
    "dataModelUpdate": {
        "surfaceId": surface_id,
        "path": "/",
        "contents": [
            {"key": "now", "valueString": now_iso()},
        ],
    }
}

Key Points: root is a Map, and contents is an “array of Map entries”

  • path: "/" indicates a root update
  • contents is an array of {"key": ..., "valueString": ...}
  • Types like valueString / valueNumber must be explicitly specified
To add more states, simply increase the elements in contents
 "contents": [
   {"key": "now", "valueString": now_iso()},
+  {"key": "user", "valueString": "guest"},
 ],

3. beginRendering: Finally, Declare the “Start of Rendering”

Start rendering with the root specified in beginRendering
begin_rendering = {
    "beginRendering": {
        "surfaceId": surface_id,
        "root": "root",
        "catalogId": V0_8_STANDARD_CATALOG_ID,
    }
}

Point: root is the starting point for UI rendering

  • root must exist in components[].id
  • The UI is rendered starting from here

Discussion