원클릭으로
a2ui-local-tester
Specialized role for setting up and performing local testing of A2UI agents using the A2A / JSON-RPC protocol.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Specialized role for setting up and performing local testing of A2UI agents using the A2A / JSON-RPC protocol.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
| name | A2UI Local Tester |
| description | Specialized role for setting up and performing local testing of A2UI agents using the A2A / JSON-RPC protocol. |
| mode | manual |
You are the A2UI Local Tester. Your primary responsibility is to set up a local testing harness for A2UI agents, allowing developers to verify component rendering and interaction flow without deploying to Vertex AI Agent Engine or Gemini Enterprise.
[agent_name]_a2ui).If the agent is built as an A2A / JSON-RPC service (e.g., using AdkAgentToA2AExecutor), follow these steps to set up local testing:
Create a folder named local_tester (or similar) directly under the agent's root directory.
Ensure this folder is added to the project's .gitignore to prevent it from being included in deployment packages.
Create a Python script (e.g., server.py) inside the local_tester folder. This script should use FastAPI to expose the endpoints and serve the mock client.
Here is the complete template for server.py:
import os
import sys
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
import json
import logging
from dotenv import load_dotenv
# Add parent directory to path to import agent and tools
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(parent_dir)
# Load environment variables from .env file in parent directory
load_dotenv(os.path.join(parent_dir, '.env'))
import agent
from google.adk import runners
from google.adk.sessions import in_memory_session_service
from google.adk.artifacts import in_memory_artifact_service
from google.adk.memory import in_memory_memory_service
from google.genai import types as genai_types
app = FastAPI()
# Enable CORS for local testing
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
# Initialize ADK Runner
adk_agent = agent.root_agent
runner = runners.Runner(
app_name=adk_agent.name,
agent=adk_agent,
session_service=in_memory_session_service.InMemorySessionService(),
artifact_service=in_memory_artifact_service.InMemoryArtifactService(),
memory_service=in_memory_memory_service.InMemoryMemoryService(),
)
@app.get("/.well-known/agent-card.json")
async def get_agent_card():
return {
"capabilities": {
"streaming": False,
"extensions": [{"uri": "https://a2ui.org/a2a-extension/a2ui/v0.8", "required": False}]
},
"name": adk_agent.name,
"url": "/jsonrpc",
"version": "1.0.0"
}
@app.get("/")
async def get_index():
return FileResponse(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"))
@app.post("/jsonrpc")
async def handle_jsonrpc(request: Request):
body = await request.json()
logger.info(f"Received JSON-RPC request: {body}")
if body.get("jsonrpc") != "2.0":
return {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": body.get("id")}
method = body.get("method")
params = body.get("params", {})
request_id = body.get("id")
if method == "message/send":
message = params.get("message", {})
query = message.get("text", "")
parts = message.get("parts", [])
session_id = params.get("session_id", "local_session")
# Extract userAction from DataPart
user_action = None
for part in parts:
if part.get("metadata", {}).get("mimeType") == "application/json+a2ui":
data = part.get("data")
if isinstance(data, str):
try:
data = json.loads(data)
except:
pass
if isinstance(data, dict) and 'userAction' in data:
user_action = data['userAction']
break
# Get session
session = await runner.session_service.get_session(
app_name=adk_agent.name,
user_id="local_user",
session_id=session_id,
)
if not session:
session = await runner.session_service.create_session(
app_name=adk_agent.name,
user_id="local_user",
state={},
session_id=session_id,
)
state = session.state if session.state else {}
# Inject userAction context into session state
if user_action:
action_context = user_action.get('context', {})
for key, value in action_context.items():
state[key] = value
if key == 'message':
query = value # Override query with message from context
session.state = state # Update in-memory object
# Append state to query to maintain context
state_str = " ".join([f"[State: {k}={v}]" for k, v in state.items() if k not in ['message']])
if state_str:
query = f"{query} {state_str}"
logger.info(f"Injected state into query: {query}")
content = genai_types.Content(role="user", parts=[{"text": query}])
final_response_content = None
async for event in runner.run_async(
user_id="local_user", session_id=session.id, new_message=content
):
if event.is_final_response():
if event.content and event.content.parts and event.content.parts[0].text:
final_response_content = "\n".join([p.text for p in event.content.parts if p.text])
if not final_response_content:
return {"jsonrpc": "2.0", "result": {"message": {"text": "No response from agent"}}, "id": request_id}
# Split A2UI JSON
text_part = final_response_content
json_string_cleaned = "[]"
if "---a2ui_JSON---" in final_response_content:
text_part, json_string = final_response_content.split("---a2ui_JSON---", 1)
json_string_cleaned = json_string.strip().lstrip("```json").rstrip("```").strip()
if not json_string_cleaned:
json_string_cleaned = "[]"
try:
ui_data = json.loads(json_string_cleaned)
except Exception as e:
logger.error(f"Failed to parse UI JSON: {e}")
ui_data = []
parts = []
if text_part.strip():
parts.append({"text": text_part.strip()})
# Load schema
schema_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "server_to_client_with_standard_catalog.json")
schema = None
if os.path.exists(schema_path):
try:
with open(schema_path, "r") as f:
schema = json.load(f)
except Exception as e:
logger.error(f"Failed to load schema from {schema_path}: {e}")
# Wrap A2UI messages in DataPart
if isinstance(ui_data, list):
messages = ui_data
elif isinstance(ui_data, dict) and "a2ui_messages" in ui_data:
messages = ui_data["a2ui_messages"]
else:
messages = [ui_data] if ui_data else []
for msg in messages:
if schema:
try:
import jsonschema
jsonschema.validate(instance=msg, schema=schema)
logger.info("A2UI payload message successfully validated against schema.")
except jsonschema.ValidationError as e:
logger.critical(f"A2UI STRICT SCHEMA VALIDATION FAILED: {e.message}")
logger.critical(f"Invalid Message: {json.dumps(msg, indent=2)}")
raise ValueError(f"A2UI Schema Validation Error: {e.message}") from e
parts.append({
"data": msg,
"metadata": {"mimeType": "application/json+a2ui"}
})
return {
"jsonrpc": "2.0",
"result": {
"message": {
"parts": parts
}
},
"id": request_id
}
return {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": request_id}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
To test the agent without cloning the full A2UI repository, you can generate an interactive HTML client inside the local_tester folder. This client uses fetch to communicate with your local server and renders a basic visual representation of the A2UI components (Cards, Buttons, Dropdowns) rather than just dumping JSON.
Key Features of this Mock Client:
Card, Column, Row, Text, Button, MultipleChoice, DateTimeInput, CheckBox, Slider, and TextField, and falls back gracefully for others.dataModel object and handles dataModelUpdate from the server.userAction payload with context, resolving paths against the local dataModel.Create index.html in the local_tester folder:
<!DOCTYPE html>
<html>
<head>
<title>A2UI Local Tester</title>
<style>
body { font-family: sans-serif; margin: 20px; background: #f0f2f5; }
.container { max-width: 800px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.chat-box { border: 1px solid #ccc; padding: 10px; height: 400px; overflow-y: scroll; margin-bottom: 10px; background: #fafafa; }
.card { border: 1px solid #ddd; padding: 15px; margin: 10px 0; border-radius: 8px; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.button { background: #007bff; color: white; padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; margin-right: 5px; }
.button:hover { background: #0056b3; }
.input-group { display: flex; gap: 10px; }
input[type="text"] { flex-grow: 1; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
select { padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
pre { background: #f8f9fa; padding: 10px; border-radius: 4px; border: 1px solid #e9ecef; overflow-x: auto; }
details { margin-top: 10px; }
summary { cursor: pointer; color: #007bff; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.session-info { font-size: 0.9em; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>A2UI Local Tester</h1>
<div class="session-info">
<span id="session-id-display"></span>
<button onclick="startNewSession()" class="button" style="background: #dc3545; margin-left: 10px;">New Session</button>
</div>
</div>
<div class="chat-box" id="chat"></div>
<div class="input-group">
<input type="text" id="input" placeholder="Type a message...">
<button onclick="sendMessage()" class="button">Send</button>
</div>
<details>
<summary>Show Raw UI JSON</summary>
<pre id="json-dump">No UI data yet.</pre>
</details>
</div>
<script>
let dataModel = {};
let sessionId = generateSessionId();
function generateSessionId() {
return 'session_' + Math.random().toString(36).substr(2, 9);
}
function updateSessionDisplay() {
document.getElementById('session-id-display').innerText = `Session: ${sessionId}`;
}
function startNewSession() {
sessionId = generateSessionId();
updateSessionDisplay();
dataModel = {};
document.getElementById('chat').innerHTML = '';
appendMessage('System', 'Started a new session.');
document.getElementById('json-dump').innerText = 'No UI data yet.';
}
// Call on load
updateSessionDisplay();
// Enter key listener
document.getElementById('input').addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
sendMessage();
}
});
async function sendMessage() {
const input = document.getElementById('input');
const text = input.value;
if (!text.trim()) return;
input.value = '';
appendMessage('User', text);
await sendPayload({ message: { text: text } });
}
async function sendUserAction(userAction, messageText) {
appendMessage('User', messageText);
const payload = {
message: {
text: messageText,
parts: [
{
data: { userAction: userAction },
metadata: { mimeType: 'application/json+a2ui' }
}
]
}
};
await sendPayload(payload);
}
async function sendPayload(params) {
const loadingDiv = appendMessage('System', 'Agent is thinking...');
try {
const response = await fetch('/jsonrpc', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'message/send',
params: { ...params, session_id: sessionId },
id: Date.now()
})
});
const data = await response.json();
loadingDiv.remove();
handleResponse(data);
} catch (error) {
loadingDiv.remove();
appendMessage('Error', `Failed to connect to server: ${error.message}`);
}
}
function appendMessage(sender, text) {
const chat = document.getElementById('chat');
const div = document.createElement('div');
div.style.marginBottom = '10px';
div.innerHTML = `<strong>${sender}:</strong> ${text}`;
chat.appendChild(div);
chat.scrollTop = chat.scrollHeight;
return div;
}
let currentRootId = null;
function handleResponse(data) {
if (data.error) {
appendMessage('Error', data.error.message);
return;
}
const parts = data.result.message.parts;
// First pass to find beginRendering and extract root ID
for (const part of parts) {
if (part.data && part.metadata && part.metadata.mimeType === 'application/json+a2ui') {
const uiData = part.data;
if (uiData.beginRendering) {
currentRootId = uiData.beginRendering.root;
}
}
}
for (const part of parts) {
if (part.text) {
appendMessage('Agent', part.text);
}
if (part.data && part.metadata && part.metadata.mimeType === 'application/json+a2ui') {
const uiData = part.data;
console.log("Received UI Data:", uiData);
// Handle Data Model Updates
if (uiData.dataModelUpdate) {
const contents = uiData.dataModelUpdate.contents || [];
for (const item of contents) {
dataModel[item.key] = item.valueString || item.valueNumber || item.valueBoolean;
}
}
// Handle Surface Updates (Rendering)
if (uiData.surfaceUpdate) {
document.getElementById('json-dump').innerText = JSON.stringify(uiData, null, 2);
const rootToUse = uiData.surfaceUpdate.root || currentRootId;
renderUI(uiData.surfaceUpdate, rootToUse);
}
}
}
}
function renderUI(surfaceUpdate, rootId) {
const chat = document.getElementById('chat');
const div = document.createElement('div');
div.className = 'card';
div.innerHTML = '<h4>Rich UI View</h4>';
const components = surfaceUpdate.components;
if (rootId && components) {
const rendered = renderComponent(rootId, components);
if (rendered) {
div.appendChild(rendered);
}
}
chat.appendChild(div);
chat.scrollTop = chat.scrollHeight;
}
function renderComponent(componentId, components) {
console.log("Rendering component:", componentId);
const compWrapper = components.find(c => c.id === componentId);
if (!compWrapper) {
console.warn("Component not found:", componentId);
return document.createTextNode(`[Missing: ${componentId}]`);
}
const comp = compWrapper.component;
console.log("Component data:", comp);
if (comp.Text) {
const div = document.createElement('div');
div.innerText = comp.Text.text.literalString || "";
if (comp.Text.usageHint === 'h1') div.style.fontSize = '1.8em';
if (comp.Text.usageHint === 'h2') div.style.fontSize = '1.4em';
if (comp.Text.usageHint === 'h3') div.style.fontSize = '1.2em';
div.style.marginBottom = '5px';
return div;
}
if (comp.Card) {
const div = document.createElement('div');
div.style.border = '1px solid #ccc';
div.style.padding = '10px';
div.style.borderRadius = '5px';
div.style.background = '#fff';
div.style.marginBottom = '10px';
if (comp.Card.child) {
div.appendChild(renderComponent(comp.Card.child, components));
}
return div;
}
if (comp.Column) {
const div = document.createElement('div');
div.style.display = 'flex';
div.style.flexDirection = 'column';
div.style.gap = '10px';
const children = comp.Column.children.explicitList || [];
for (const childId of children) {
div.appendChild(renderComponent(childId, components));
}
return div;
}
if (comp.Row) {
const div = document.createElement('div');
div.style.display = 'flex';
div.style.flexDirection = 'row';
div.style.gap = '10px';
div.style.justifyContent = comp.Row.distribution === 'spaceAround' ? 'space-around' : 'flex-start';
const children = comp.Row.children.explicitList || [];
for (const childId of children) {
div.appendChild(renderComponent(childId, components));
}
return div;
}
if (comp.Button) {
const btn = document.createElement('button');
btn.className = 'button';
if (comp.Button.child) {
const childComp = components.find(c => c.id === comp.Button.child);
if (childComp && childComp.component.Text) {
btn.innerText = childComp.component.Text.text.literalString;
} else {
btn.innerText = comp.Button.child;
}
}
if (comp.Button.action) {
btn.onclick = () => {
const context = comp.Button.action.context || [];
const msgItem = context.find(i => i.key === 'message');
let msgText = "Action triggered";
if (msgItem) {
msgText = msgItem.value.literalString || "Action triggered";
}
// Construct userAction payload
const userAction = {
name: comp.Button.action.name,
context: {}
};
if (comp.Button.action.context && comp.Button.action.context.length > 0) {
for (const ctxItem of comp.Button.action.context) {
const key = ctxItem.key;
let val = undefined;
if (ctxItem.value) {
if (ctxItem.value.literalString !== undefined) {
val = ctxItem.value.literalString;
} else if (ctxItem.value.path !== undefined) {
val = dataModel[ctxItem.value.path];
}
}
userAction.context[key] = val;
}
} else {
// Grab values from inputs to context
const inputFields = document.querySelectorAll('input, select');
inputFields.forEach(field => {
if (field.id) {
const path = "/" + field.id.replace("_input", "").replace("_select", "").replace("_checkbox", "").replace("_slider", "");
const cleanKey = field.id.replace("_input", "").replace("_select", "").replace("_checkbox", "").replace("_slider", "");
userAction.context[cleanKey] = dataModel[path] || field.value;
}
});
}
sendUserAction(userAction, msgText);
};
}
return btn;
}
if (comp.MultipleChoice) {
const mc = comp.MultipleChoice;
const select = document.createElement('select');
select.style.padding = '5px';
if (mc.selections && mc.selections.path) {
select.id = mc.selections.path + "_select";
}
// Add empty option
const emptyOpt = document.createElement('option');
emptyOpt.value = "";
emptyOpt.innerText = "Select...";
select.appendChild(emptyOpt);
for (const opt of mc.options) {
const option = document.createElement('option');
option.value = opt.value;
option.innerText = opt.label.literalString || opt.value;
select.appendChild(option);
}
// Handle selections path
if (mc.selections && mc.selections.path) {
select.onchange = (e) => {
dataModel[mc.selections.path] = e.target.value;
console.log(`Updated data model: ${mc.selections.path} = ${e.target.value}`);
};
}
return select;
}
if (comp.DateTimeInput) {
const input = document.createElement('input');
const dt = comp.DateTimeInput;
if (dt.enableDate === false && dt.enableTime === true) {
input.type = 'time';
} else if (dt.enableDate === true && dt.enableTime === false) {
input.type = 'date';
} else {
input.type = 'datetime-local';
}
input.style.padding = '5px';
if (comp.DateTimeInput.value && comp.DateTimeInput.value.path) {
const path = comp.DateTimeInput.value.path;
input.id = path + "_input";
input.value = dataModel[path] || "";
input.oninput = (e) => {
dataModel[path] = e.target.value;
console.log(`Updated data model: ${path} = ${e.target.value}`);
};
}
return input;
}
if (comp.CheckBox) {
const div = document.createElement('div');
div.style.display = 'flex';
div.style.alignItems = 'center';
div.style.gap = '5px';
const input = document.createElement('input');
input.type = 'checkbox';
const label = document.createElement('label');
label.innerText = comp.CheckBox.label.literalString || "";
div.appendChild(input);
div.appendChild(label);
if (comp.CheckBox.value && comp.CheckBox.value.path) {
const path = comp.CheckBox.value.path;
input.id = path + "_checkbox";
input.checked = dataModel[path] || false;
input.onchange = (e) => {
dataModel[path] = e.target.checked;
console.log(`Updated data model: ${path} = ${e.target.checked}`);
};
}
return div;
}
if (comp.Slider) {
const div = document.createElement('div');
div.style.display = 'flex';
div.style.flexDirection = 'column';
const label = document.createElement('label');
label.innerText = `Value: `;
const span = document.createElement('span');
span.innerText = dataModel[comp.Slider.value.path] || comp.Slider.minValue || 0;
label.appendChild(span);
const input = document.createElement('input');
input.type = 'range';
input.min = comp.Slider.minValue || 0;
input.max = comp.Slider.maxValue || 100;
input.value = dataModel[comp.Slider.value.path] || 0;
div.appendChild(label);
div.appendChild(input);
if (comp.Slider.value && comp.Slider.value.path) {
const path = comp.Slider.value.path;
input.id = path + "_slider";
input.oninput = (e) => {
span.innerText = e.target.value;
dataModel[path] = parseInt(e.target.value);
};
}
return div;
}
if (comp.TextField) {
const div = document.createElement('div');
div.style.display = 'flex';
div.style.flexDirection = 'column';
const label = document.createElement('label');
label.innerText = comp.TextField.label.literalString || "";
const input = document.createElement('input');
input.type = 'text';
div.appendChild(label);
div.appendChild(input);
if (comp.TextField.text && comp.TextField.text.path) {
const path = comp.TextField.text.path;
input.id = path + "_input";
input.value = dataModel[path] || "";
input.oninput = (e) => {
dataModel[path] = e.target.value;
console.log(`Updated data model: ${path} = ${e.target.value}`);
};
}
return div;
}
if (type === 'Image') {
const img = document.createElement('img');
img.src = comp.Image.url.literalString || comp.Image.url.path || "";
img.style.maxWidth = '100px';
img.style.maxHeight = '100px';
img.style.objectFit = comp.Image.fit || 'contain';
img.style.display = 'block';
img.style.marginBottom = '5px';
return img;
}
if (comp.WebFrameUrl) {
const iframe = document.createElement('iframe');
const urlObj = comp.WebFrameUrl.url;
let srcUrl = "";
if (urlObj.literalString) {
srcUrl = urlObj.literalString;
} else if (urlObj.path) {
srcUrl = dataModel[urlObj.path] || "";
}
iframe.src = srcUrl;
iframe.width = "100%";
iframe.height = (comp.WebFrameUrl.height || 450) + "px";
iframe.style.border = "none";
iframe.style.borderRadius = "8px";
iframe.style.boxShadow = "0 1px 3px rgba(0,0,0,0.1)";
iframe.style.marginTop = "10px";
return iframe;
}
if (comp.WebFrameSrcdoc) {
const iframe = document.createElement('iframe');
const srcdocObj = comp.WebFrameSrcdoc.srcdoc;
let srcdocHtml = "";
if (srcdocObj && srcdocObj.literalString) {
srcdocHtml = srcdocObj.literalString;
} else if (srcdocObj && srcdocObj.path) {
srcdocHtml = dataModel[srcdocObj.path] || "";
}
iframe.srcdoc = srcdocHtml;
iframe.width = "100%";
iframe.height = (comp.WebFrameSrcdoc.height || 400) + "px";
iframe.style.border = "none";
iframe.style.borderRadius = "8px";
iframe.style.boxShadow = "0 1px 3px rgba(0,0,0,0.1)";
iframe.style.marginTop = "10px";
return iframe;
}
if (comp.Divider) {
const hr = document.createElement('hr');
hr.style.border = "none";
if (comp.Divider.axis === 'vertical') {
hr.style.borderLeft = "1px solid #eef2f6";
hr.style.height = "100%";
hr.style.margin = "0 10px";
hr.style.display = "inline-block";
} else {
hr.style.borderTop = "1px solid #eef2f6";
hr.style.width = "100%";
hr.style.margin = "8px 0";
}
return hr;
}
const fallbackDiv = document.createElement('div');
fallbackDiv.style.border = '1px dashed #ffc107';
fallbackDiv.style.padding = '10px';
fallbackDiv.style.margin = '5px 0';
fallbackDiv.style.borderRadius = '4px';
fallbackDiv.style.background = '#fff8e1';
fallbackDiv.style.color = '#856404';
const type = Object.keys(comp)[0] || "Unknown";
fallbackDiv.innerHTML = `<strong>[${type}]</strong> ${componentId}`;
return fallbackDiv;
}
</script>
</body>
</html>
To avoid CORS and file origin restrictions in the browser, it is recommended to serve the generated index.html over HTTP. You can use Python's built-in HTTP server:
cd local_tester
python3 -m http.server 8080
Then open http://localhost:8080 in your browser to test the agent.
If the agent is already deployed to Vertex AI Agent Engine, you can use a remote tester to verify it. This involves running a local proxy server that forwards A2A requests to the deployed agent endpoint, while serving the interactive mock client locally.
Create a folder named remote_tester directly under the agent's root directory. Copy the index.html file from the local_tester folder into this new folder, and update the heading in it to <h1>A2UI Remote Tester</h1> and the title to <title>A2UI Remote Tester</title> to distinguish it from the local tester.
Create a Python script (e.g., proxy_server.py) inside the remote_tester folder.
Here is the complete template for proxy_server.py:
import os
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
import json
import logging
import requests
import time
from google.auth import default
from google.auth.transport.requests import Request as AuthRequest
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
# Configure these for your deployed agent
PROJECT_ID = "your-project-id"
LOCATION = "us-central1"
ENGINE_ID = "your-engine-id"
def get_bearer_token():
credentials, _ = default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
credentials.refresh(AuthRequest())
return credentials.token
@app.get("/.well-known/agent-card.json")
async def get_agent_card():
token = get_bearer_token()
url = f"https://{LOCATION}-aiplatform.googleapis.com/v1beta1/projects/{PROJECT_ID}/locations/{LOCATION}/reasoningEngines/{ENGINE_ID}/a2a/v1/card"
headers = {"Authorization": f"Bearer {token}"}
res = requests.get(url, headers=headers)
return res.json()
@app.get("/")
async def get_index():
return FileResponse(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"))
@app.post("/jsonrpc")
async def handle_jsonrpc(request: Request):
body = await request.json()
logger.info(f"Received JSON-RPC request: {body}")
if body.get("jsonrpc") != "2.0":
return {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": body.get("id")}
method = body.get("method")
params = body.get("params", {})
request_id = body.get("id")
if method == "message/send":
message = params.get("message", {})
token = get_bearer_token()
url = f"https://{LOCATION}-aiplatform.googleapis.com/v1beta1/projects/{PROJECT_ID}/locations/{LOCATION}/reasoningEngines/{ENGINE_ID}/a2a/v1/message:send"
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
payload = {"message": message}
logger.info(f"Forwarding to remote agent: {url}")
res = requests.post(url, headers=headers, json=payload)
if res.status_code != 200:
return {"jsonrpc": "2.0", "error": {"code": res.status_code, "message": res.text}, "id": request_id}
remote_response = res.json()
task_id = remote_response.get("task", {}).get("id")
if task_id:
logger.info(f"Polling Task {task_id}...")
task_url = f"https://{LOCATION}-aiplatform.googleapis.com/v1beta1/projects/{PROJECT_ID}/locations/{LOCATION}/reasoningEngines/{ENGINE_ID}/a2a/v1/tasks/{task_id}"
for _ in range(90):
time.sleep(2)
task_res = requests.get(task_url, headers=headers)
if task_res.status_code == 404:
logger.warning("Task status returned 404 (not found). Syncing replica, retrying...")
continue
if task_res.status_code == 200:
task_data = task_res.json()
state = task_data.get("status", {}).get("state")
if state == "TASK_STATE_SUCCEEDED" or state == "TASK_STATE_COMPLETED":
artifacts = task_data.get("artifacts", [])
# Load schema
schema_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "server_to_client_with_standard_catalog.json")
schema = None
if os.path.exists(schema_path):
try:
with open(schema_path, "r") as f:
schema = json.load(f)
except Exception as e:
logger.error(f"Failed to load schema from {schema_path}: {e}")
parts = []
if artifacts:
parts = artifacts[0].get("parts", [])
# Clean nested data fields in A2UI parts if needed
for part in parts:
if "data" in part and part.get("metadata", {}).get("mimeType") == "application/json+a2ui":
data_field = part["data"]
if isinstance(data_field, dict) and "data" in data_field:
part["data"] = data_field["data"]
# Validate data_field against schema
if schema:
try:
import jsonschema
jsonschema.validate(instance=part["data"], schema=schema)
logger.info("A2UI payload part successfully validated against schema.")
except jsonschema.ValidationError as e:
logger.critical(f"A2UI STRICT SCHEMA VALIDATION FAILED: {e.message}")
logger.critical(f"Invalid Message: {json.dumps(part['data'], indent=2)}")
raise ValueError(f"A2UI Schema Validation Error: {e.message}") from e
return {
"jsonrpc": "2.0",
"result": {
"message": {
"parts": parts
}
},
"id": request_id
}
elif state == "TASK_STATE_FAILED":
return {"jsonrpc": "2.0", "error": {"code": 500, "message": "Task failed on remote agent"}, "id": request_id}
return {"jsonrpc": "2.0", "error": {"code": 504, "message": "Task timeout on remote agent"}, "id": request_id}
else:
return {"jsonrpc": "2.0", "error": {"code": 500, "message": "No task ID returned from remote agent"}, "id": request_id}
return {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": request_id}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
cd remote_tester
python3 proxy_server.py
Open the local URL (e.g., http://localhost:8000) in your browser.
/ endpoint. This puts both on the same origin (e.g., http://vmps.c.googlers.com:8000/) and avoids CORS issues entirely.<select> elements in the mock client may not reliably trigger the onchange event, resulting in empty values being sent in the action context.execute_browser_javascript to set the value of the dropdown directly and manually dispatch a change event:const select = document.getElementById('your_component_id_select');
select.value = 'desired_value';
select.dispatchEvent(new Event('change'));
500 Internal Server Error during a request.429 RESOURCE_EXHAUSTED due to quota limits./start_address). When the client sends these in the action context, it might strip the slash or use it as the key. If the server looks strictly for one format, it will fail to find the data.start_addr = state.get("start_address") or state.get("/start_address")
Deploys an A2UI ADK agent cleanly to Google Cloud Run in namespaced A2A-compatibility mode.
Expert guide and patterns for building Agent-Driven User Interfaces (A2UI) using the ADK.
Deploys ADK agents to Vertex AI Agent Engine using the Python SDK and registers them with Gemini Enterprise.
Comprehensive guide to building, orchestrating, and deploying agents with the Google Agent Development Kit (ADK).
Provisions a PostgreSQL Cloud SQL instance, database, database user, and registers connection details in Secret Manager using Terraform.
Provisions a scheduled Cloud Scheduler job with secure OIDC token authentication to invoke HTTP targets (such as Reasoning Engine endpoints) using Terraform.