A2UIをLLMなしで動かして理解する:公式Client+自作Agentで検証してみた
本記事では、A2UI(Agent-to-User Interface)を
公式のUI Clientと、自作したA2A Agentを使って実際に動かしながら理解する
ことを目的とします。
思想や設計意図については別記事で整理していますが、
本記事ではあえて LLM を使わず、
- UI Client は公式サンプルを利用
- Agent は FastAPI で自作
- A2UI v0.8 の仕様に最低限従う
という構成で、「A2UIでは何がどこで起きているのか」を
コードと挙動から確認していきます。
A2UIの思想編を先に読みたい方はこちら:
👉A2UIとは何か?AgentがUIを「描かない」設計思想とMCP Appsとの違い
1. A2UI を実際に動かしてみる
ここからは、A2UIの公開リポジトリのサンプルを使って、
実際に手元で A2UI を動かして挙動を確認していきます。
公式サンプルをそのまま動かすのも良いですが、まずはA2UIの挙動だけ理解したいので、UI Clientだけ公式サンプルを利用し、Agentは自前で実装してLLMを使わずに動かすことにします。
1.1 UI Client部の準備
まずは公式サンプルをローカルに clone します。
git clone https://github.com/google/a2ui.git
cd a2ui
公式サンプルではGemini前提で動くように実装されています。
なので、UI Client部だけ起動します。
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
ここまでで「フロント側(シェル)」は動くはずです(ただし接続先のagentがいないので何も表示されない/エラーは出ます)。

1.2 UI Client から見た Agent のインターフェースを整理する
Agent 部を実装する前に、公式サンプルの UI Client が どのような前提で Agent を呼び出しているか を確認しておきます。
公式サンプルでは、samples/client/lit/shell/client.ts に Client ⇄ Agent 間の通信処理が実装されており、このファイルを読むことで、「Client 側が Agent に何を期待しているか」が分かります。
Client 側で行っていること
公式サンプルの Client UI では、以下の点が確認できました。
-
@a2a-js/sdkのA2AClientを利用して Agent と通信している - Agent との通信には JSON-RPC 2.0 が使われている
-
message/sendメソッドを呼び出している - レスポンスは
Taskとして扱われている
Agent 側に求められる最小要件
上記を踏まえると、
公式サンプルの UI Client をそのまま利用して自前 Agent を実装する場合、
Agent側には、最低限、次のインタフェースが求められます。
| 項目 | 内容 |
|---|---|
| Agent Card |
GET /.well-known/agent-card.json を返す |
| 通信方式 | JSON-RPC 2.0 |
| エンドポイント | POST <agentCard.url> |
| RPC メソッド | message/send |
| レスポンス |
result として Task を返す |
| UI 定義 |
task.status.message.parts に DataPart(A2UI メッセージ)を含める |
Agent2Agent (A2A) Protocol Official Specification では、JSON-RPC ベースのメソッドのひとつとして message/send が定義されています。また、A2A のデータモデルとして Task オブジェクトや、Message の parts(たとえば TextPart, DataPart など)の構造も仕様に含まれています。
1.3 Agent部を実装して動かす
A2UIの挙動を把握するには、実際に動かすのが一番早いので、前節までに解析した公式サンプルのUI Clientの実装をもとに、python + FastAPIを使ってA2A Agentを実装してみました。
私がサンプルで実装したコードは以下から入手して試すことができます。
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
| 動作画面キャプチャ | 解説 |
|---|---|
![]() |
公式のクライアントをリロード(ブラウザを更新)すると左図の状態に戻っているかと思います。動きがわかりやすいようにWeb Inspectorを有効にしておきます。 |
![]() |
入力フォームは何を入れていただいても問題ありませんが、▶️ボタンから実行すると、入力したテキストとOK、キャンセルの2つのボタンが描画されます。 |
実装したAgentの中では、A2UIの仕様に則り
- UI の構造
- どの値を返してほしいか
だけを宣言して、クライアントに返却するようにしています。
今回は処理を簡単にするため、LLMとは連携せず、かつ、返すUIの構造も常に一定にしています。
実装画面キャプチャの2枚目を見ていただくと、jsonrpcのリクエストのレスポンスとしてjsonを受け取っているだけになります。つまり、実際のボタンなどのコンポーネントのレンダリングはクライアントが実施しているということがわかります。
実装の詳細が気になる方は、Appendixにポイントを解説していますのでそちらもご確認ください。
1.4 A2UIがレンダリングをクライアントに依存していることの確認
入力値の扱いや実行処理は、すべてクライアント側の責務です。
これをもう少しわかりやすくするために、あえて、公式サンプルのクライアントがサポートしていないコンポーネントをAgentが返すことで依存性を確認してみます。
参考:A2UI v0.8で用意されているコンポーネントは以下
A2UIで用意されていないコンポーネントで試してみる
a2ui_messages_v0_8() の components に、例えばこういう コンポーネントとしてありそうな Table を差し込みます(※中身は何でもOK。重要なのは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"]}
}
},
...
# <<ここにClientに表示させたいコンポーネントを追加>>
...
},
追加するコンポーネント(Table)
{
"id": "table1",
"component": {
"Table": { # ←標準カタログに無いので未サポートになるはず
"rows": {"explicitList": ["row1", "row2"]},
"columns": {"explicitList": ["col1", "col2"]},
}
},
},
root の children に 追加したコンポーネントのID「table1」を混ぜる
- "children": {"explicitList": ["title_text", "row_buttons"]}
+ "children": {"explicitList": ["title_text", "row_buttons", "table1"]}

Tableのコンポーネントを出力してほしい意図をAgentが返却しましたが、Client側では表示されていないことがわかります。
A2UIで用意されているコンポーネントで試してみる
今度はA2UIで用意されている別のコンポーネントを試してみます。
追加するコンポーネント(TextFieldでメールアドレスの入力を促す)
{
"id": "email-input",
"component": {
"TextField": {
"label": {"literalString": "Email Address"},
"text": {"path": "/user/email"},
"textFieldType": "shortText"
}
}
},
root の children に 追加したコンポーネントのID「email-input」を混ぜる
- "children": {"explicitList": ["title_text", "row_buttons", "table1"]}
+ "children": {"explicitList": ["title_text", "row_buttons", "table1", "email-input"]}

今度はClient側で表示されました。
これがA2UIの概念であるUIのレンダリングをクライアントに依存しているということです。
Appendix: 実装したA2A Agentの解説
実装上のポイント①:A2A Agentとして必要な仕様の実装
1. Agent Card に「JSON-RPCを投げる完全なURL」が書かれている
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. そのURLに対してサーバーが実際にJSON-RPCを受け付ける
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. message/send などのA2Aで定義されたRPCメソッドが処理できる
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},
)
Agent Card の PUBLIC_BASE_URL は Agentを呼び出すClientのリクエスト先と一致させてください。公式サンプルのClient UIでは localhost:10002 に対してリクエストを投げる実装となっているため注意してください。
- Agent Card の url(= PUBLIC_BASE_URL + JSONRPC_PATH)
- サーバーの受け口 @app.post(JSONRPC_PATH)
また、上記2つは 必ず一致させてください。
一致していないとクライアントは存在しないエンドポイントへPOSTし、UIや呼び出しが成立しません。
実装上のポイント②:ブラウザ実行&別オリジンなのでCORSを設定
CORSミドルウェアを実装する
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 開発用。本番はフロントの origin に絞る
allow_credentials=False,
allow_methods=["*"], # OPTIONS preflight OK
allow_headers=["*"], # X-A2A-Extensions 等 OK
)
- OPTIONS(preflight)に応答できること(FastAPIのCORSミドルウェアが面倒を見てくれる)
- Content-Type: application/json やカスタムヘッダ(例:X-A2A-Extensions)を許可すること
今回のサンプルではブラウザ上の UI Client から 異なるオリジンの localhost:10002 のAgentを叩くので、CORS 設定なしだと動きません。
実装上のポイント③:A2UIメッセージを作る
今回のAgent実装で、A2Aの仕様準拠を除いて本質的にやっていることは以下だけです。
(≒ A2UIとして必要な実装)
- A2UI v0.8 の ServerToClientMessage を3つ作る
- surfaceUpdate:UI構造(何を表示するか)
- dataModelUpdate:状態(表示に使うデータ)
- beginRendering:描画開始(どのrootから描くか)
def a2ui_messages_v0_8(title: str) -> List[Dict[str, Any]]:
...
return [surface_update, data_model_update, begin_rendering]
1. surfaceUpdate:UIを「宣言」する
A2UIの surfaceUpdate は、HTMLやJSXではなく「コンポーネントのグラフ」を宣言
surface_update = {
"surfaceUpdate": {
"surfaceId": surface_id,
"components": [
{
"id": "root",
"component": {
"Column": {
"children": {"explicitList": ["title_text", "row_buttons"]}
}
},
},
...
],
}
}
v0.8では必ず
- id: 参照用の識別子
- component: { "Text": {...} } のように コンポーネント名がキー
という形を取ります。
Button:クリックできるUIの宣言
{
"id": "btn_ok",
"component": {
"Button": {
"child": "btn_ok_text",
"action": {"name": "clicked_ok"},
}
},
}
- child は ボタン内表示のコンポーネントID
- action.name は クリック時に発火する“意味”
A2UIは「クリックしたらこのJSを実行」ではなく、
「clicked_ok という action が起きた」 という意味だけを宣言します。
2. dataModelUpdate:状態は「Mapとして」更新する
A2UI v0.8 の DataModel は直感的な “JSON” ではない
data_model_update = {
"dataModelUpdate": {
"surfaceId": surface_id,
"path": "/",
"contents": [
{"key": "now", "valueString": now_iso()},
],
}
}
ポイント:root は Map、contents は「Mapのエントリ配列」
- path: "/" は ルート更新
- contents は {"key": ..., "valueString": ...} の配列
- valueString / valueNumber のように型を明示
状態を増やすときはcontents の要素を増やすだけ
"contents": [
{"key": "now", "valueString": now_iso()},
+ {"key": "user", "valueString": "guest"},
],
3. beginRendering:最後に「描画開始」を宣言する
beginRenderingのrootでレンダリングを開始
begin_rendering = {
"beginRendering": {
"surfaceId": surface_id,
"root": "root",
"catalogId": V0_8_STANDARD_CATALOG_ID,
}
}
ポイント:root が UI の描画起点
- root は components[].id に存在する必要がある
- ここを起点に UI がレンダリングされる


Discussion