From 40266701e02fc5cecfbfc4330680f31026c352dc Mon Sep 17 00:00:00 2001 From: Yuzuru Tanahashi Date: Mon, 27 Jul 2026 16:14:30 +0000 Subject: [PATCH 01/14] docs: add specification for A2UI web app iframe component (v0.9) --- .../src/v0_9/schema/client-to-server.test.ts | 8 +- .../agent/adk/mcp_app_proxy/agent.py | 12 +- .../catalogs/0.9/mcp_app_catalog.json | 47 ++ .../agent/adk/mcp_app_proxy/tools.py | 65 +++ samples/community/client/angular/package.json | 2 +- .../src/a2ui-catalog/catalog.ts | 10 + .../src/a2ui-catalog/web-app-frame-url.ts | 405 ++++++++++++++++ .../a2ui-catalog/web-frame-component_spec.md | 432 ++++++++++++++++++ .../projects/mcp_calculator/src/app/app.html | 4 + .../mcp_apps_inner_iframe/sandbox-url.html | 56 +++ .../shared/mcp_apps_inner_iframe/sandbox.ts | 48 +- samples/community/web/pong/README.md | 38 ++ samples/community/web/pong/__main__.py | 20 + samples/community/web/pong/pong_server.py | 81 ++++ .../web/pong/pong_web_frame_bridge.js | 154 +++++++ samples/community/web/pong/pyproject.toml | 22 + samples/community/web/pong/uv.lock | 8 + 17 files changed, 1394 insertions(+), 18 deletions(-) create mode 100644 samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts create mode 100644 samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-component_spec.md create mode 100644 samples/community/client/shared/mcp_apps_inner_iframe/sandbox-url.html create mode 100644 samples/community/web/pong/README.md create mode 100644 samples/community/web/pong/__main__.py create mode 100644 samples/community/web/pong/pong_server.py create mode 100644 samples/community/web/pong/pong_web_frame_bridge.js create mode 100644 samples/community/web/pong/pyproject.toml create mode 100644 samples/community/web/pong/uv.lock diff --git a/renderers/web_core/src/v0_9/schema/client-to-server.test.ts b/renderers/web_core/src/v0_9/schema/client-to-server.test.ts index 0b829d3a38..4a120ddd04 100644 --- a/renderers/web_core/src/v0_9/schema/client-to-server.test.ts +++ b/renderers/web_core/src/v0_9/schema/client-to-server.test.ts @@ -23,7 +23,7 @@ describe('Client-to-Server Schema Verification', () => { versions.forEach(version => { describe(`Protocol ${version}`, () => { - it(`validates a valid action message`, () => { + it('validates a valid action message', () => { const validAction = { version, action: { @@ -38,7 +38,7 @@ describe('Client-to-Server Schema Verification', () => { assert.ok(result.success, result.success ? '' : result.error.message); }); - it(`validates a valid error message (validation failed)`, () => { + it('validates a valid error message (validation failed)', () => { const validError = { version, error: { @@ -52,7 +52,7 @@ describe('Client-to-Server Schema Verification', () => { assert.ok(result.success, result.success ? '' : result.error.message); }); - it(`validates a valid error message (generic)`, () => { + it('validates a valid error message (generic)', () => { const validError = { version, error: { @@ -65,7 +65,7 @@ describe('Client-to-Server Schema Verification', () => { assert.ok(result.success, result.success ? '' : result.error.message); }); - it(`validates a valid data model message`, () => { + it('validates a valid data model message', () => { const validDataModel = { version, surfaces: { diff --git a/samples/community/agent/adk/mcp_app_proxy/agent.py b/samples/community/agent/adk/mcp_app_proxy/agent.py index b866eec382..517380d469 100644 --- a/samples/community/agent/adk/mcp_app_proxy/agent.py +++ b/samples/community/agent/adk/mcp_app_proxy/agent.py @@ -30,7 +30,7 @@ from google.adk.sessions import InMemorySessionService from google.genai import types from pydantic import PrivateAttr -from tools import get_calculator_app, calculate_via_mcp, get_pong_mcp_app_json, commentate_pong_game +from tools import get_calculator_app, calculate_via_mcp, get_pong_mcp_app_json, get_pong_app_web_frame_json, commentate_pong_game from agent_executor import get_a2ui_enabled, get_a2ui_catalog, get_a2ui_examples logger = logging.getLogger(__name__) @@ -39,6 +39,7 @@ You are an expert A2UI Proxy Agent. Your primary functions are to fetch the Calculator App or the Pong App and display it to the user. When the user asks for the calculator, you MUST call the `get_calculator_app` tool. When the user asks for Pong with MCP Apps, you MUST call the `get_pong_mcp_app_json` tool. +When the user asks for Pong with WebApp URL, you MUST call the `get_pong_app_web_frame_json` tool. IMPORTANT: Do NOT attempt to construct the JSON manually. The tools handle it automatically. @@ -51,6 +52,7 @@ 1. **Analyze Request**: - If User asks for calculator: Call `get_calculator_app`. - If User asks for Pong with MCP Apps: Call `get_pong_mcp_app_json`. + - If User asks for Pong with WebApp URL: Call `get_pong_app_web_frame_json`. - If User interacts with the calculator (ACTION: calculate): Extract 'operation', 'a', and 'b' from the event context and call `calculate_via_mcp`. Return the result to the user. - If you receive a `"commentate_pong"` action: Call `commentate_pong_game` with `"game_event"` from `"context" -> "game_event"`. Do not generate text responses; only call the tool. """ @@ -167,6 +169,13 @@ def _build_agent_card(self) -> AgentCard: tags=["html", "app", "demo", "tool"], examples=["open pong with mcp apps"], ), + AgentSkill( + id="open_pong_web_frame", + name="Open Pong with WebApp URL", + description="Opens Pong using the new WebAppFrame URL method.", + tags=["html", "app", "demo", "tool"], + examples=["open pong with webapp url"], + ), ], ) @@ -205,6 +214,7 @@ def _build_llm_agent( get_calculator_app, calculate_via_mcp, get_pong_mcp_app_json, + get_pong_app_web_frame_json, commentate_pong_game, ], planner=BuiltInPlanner( diff --git a/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json b/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json index ddefc2f156..e80794b406 100644 --- a/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json +++ b/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json @@ -87,6 +87,50 @@ ], "unevaluatedProperties": false }, + "WebAppFrameUrl": { + "type": "object", + "allOf": [ + { + "$ref": "common_types.json#/$defs/ComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "WebAppFrameUrl" + }, + "url": { + "$ref": "common_types.json#/$defs/DynamicString", + "description": "The external URL to load inside the iframe." + }, + "height": { + "$ref": "common_types.json#/$defs/DynamicNumber" + }, + "allowedEvents": { + "type": "array", + "items": {"type": "string"} + }, + "allowedFunctions": { + "type": "array", + "items": {"type": "string"} + }, + "data": { + "type": "object", + "properties": { + "paths": { + "type": "object", + "additionalProperties": {"type": "string"} + } + }, + "required": ["paths"], + "additionalProperties": false + } + }, + "required": ["component", "url"] + } + ], + "unevaluatedProperties": false + }, "PongScoreBoard": { "type": "object", "allOf": [ @@ -156,6 +200,9 @@ }, { "$ref": "#/components/Column" + }, + { + "$ref": "#/components/WebAppFrameUrl" } ] } diff --git a/samples/community/agent/adk/mcp_app_proxy/tools.py b/samples/community/agent/adk/mcp_app_proxy/tools.py index 7ca6d051ff..b11ea65975 100644 --- a/samples/community/agent/adk/mcp_app_proxy/tools.py +++ b/samples/community/agent/adk/mcp_app_proxy/tools.py @@ -206,6 +206,71 @@ async def get_pong_mcp_app_json(tool_context: ToolContext): return {"validated_a2ui_json": messages} +async def get_pong_app_web_frame_json(tool_context: ToolContext): + """Fetches the Pong game app using the WebAppFrameUrl component.""" + + # Reset score on reload + global PONG_CURRENT_SCORE + PONG_CURRENT_SCORE = {"player": 0, "cpu": 0} + + messages = [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": PONG_SURFACE_ID, + "catalogId": ( + "https://a2ui.org/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json" + ), + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": PONG_SURFACE_ID, + "path": "/", + "value": { + "pong_state": { + "player_score": PONG_CURRENT_SCORE["player"], + "cpu_score": PONG_CURRENT_SCORE["cpu"], + "commentary": "Let the match begin!", + } + }, + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": PONG_SURFACE_ID, + "components": [ + { + "id": "root", + "component": "PongLayout", + "mcpComponent": "web_frame_app_root", + "scoreboardComponent": "scoreboard_root", + }, + { + "id": "web_frame_app_root", + "component": "WebAppFrameUrl", + "url": "http://localhost:8081/pong_app_web_frame.html", + "allowedEvents": ["commentate_pong"], + "allowedFunctions": ["showWinnerModal"], + "data": {"paths": {"state": "/pong_state"}}, + }, + { + "id": "scoreboard_root", + "component": "PongScoreBoard", + "playerScore": {"path": "/pong_state/player_score"}, + "cpuScore": {"path": "/pong_state/cpu_score"}, + "commentary": {"path": "/pong_state/commentary"}, + }, + ], + }, + }, + ] + tool_context.actions.skip_summarization = True + return {"validated_a2ui_json": messages} + + async def commentate_pong_game(tool_context: ToolContext, game_event: str): """Generates a witty neon-themed sports commentary or lighthearted trash talk comment based on the game event description, and applies it to the game scoreboard. diff --git a/samples/community/client/angular/package.json b/samples/community/client/angular/package.json index 36f288499e..fadae42ac5 100644 --- a/samples/community/client/angular/package.json +++ b/samples/community/client/angular/package.json @@ -5,7 +5,7 @@ "scripts": { "ng": "ng", "start": "yarn build:sandbox && ng serve", - "build:sandbox": "mkdir -p projects/mcp_calculator/public/mcp_apps_inner_iframe && npx esbuild ../../client/shared/mcp_apps_inner_iframe/sandbox.ts --bundle --outfile=projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox.js --format=esm --platform=browser && cp ../../client/shared/mcp_apps_inner_iframe/sandbox.html projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox.html", + "build:sandbox": "mkdir -p projects/mcp_calculator/public/mcp_apps_inner_iframe && npx esbuild ../../client/shared/mcp_apps_inner_iframe/sandbox.ts --bundle --outfile=projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox.js --format=esm --platform=browser && cp ../../client/shared/mcp_apps_inner_iframe/sandbox.html projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox.html && cp ../../client/shared/mcp_apps_inner_iframe/sandbox-url.html projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox-url.html", "build": "ng build a2a-chat-canvas && ng build orchestrator && ng build mcp_calculator", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/catalog.ts b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/catalog.ts index da82f75b17..0efb8064f6 100644 --- a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/catalog.ts +++ b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/catalog.ts @@ -26,6 +26,7 @@ import {McpApp} from './mcp-app'; import {PongScoreBoard} from './pong-scoreboard'; import {PongLayout} from './pong-layout'; import {Column} from '@a2ui/angular'; +import {WebAppFrameUrl} from './web-app-frame-url'; /** * The catalog ID for the MCP App catalog. @@ -53,6 +54,14 @@ const PongLayoutSchema = z.object({ scoreboardComponent: z.string().optional(), }); +const WebAppFrameUrlSchema = z.object({ + url: DynamicStringSchema, + data: DynamicValueSchema.optional(), + height: DynamicNumberSchema.optional(), + allowedEvents: z.array(z.string()).optional(), + allowedFunctions: z.array(z.string()).optional(), +}); + export const SHOW_WINNER_MODAL_FN = createFunctionImplementation( { name: 'showWinnerModal', @@ -127,6 +136,7 @@ export const DEMO_CATALOG = new Catalog( {name: 'McpApp', component: McpApp, schema: McpAppSchema}, {name: 'PongScoreBoard', component: PongScoreBoard, schema: PongScoreBoardSchema}, {name: 'PongLayout', component: PongLayout, schema: PongLayoutSchema}, + {name: 'WebAppFrameUrl', component: WebAppFrameUrl, schema: WebAppFrameUrlSchema}, // Column should use ColumnApi.schema from @a2ui/web_core, but it is not currently // exported by the version of @a2ui/web_core resolved in this community sample. // We use z.any() to avoid duplicating the schema definition here. diff --git a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts new file mode 100644 index 0000000000..73a9f65ba3 --- /dev/null +++ b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts @@ -0,0 +1,405 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {CatalogComponent, A2uiRendererService} from '@a2ui/angular/v0_9'; +import {DataContext} from '@a2ui/web_core/v0_9'; +import {z} from 'zod'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + ElementRef, + inject, + OnDestroy, + OnInit, + signal, + viewChild, +} from '@angular/core'; +import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; + +@Component({ + selector: 'a2ui-web-app-frame-url', + standalone: true, + imports: [], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: ` + :host { + display: flex; + flex-direction: column; + width: 100%; + height: 500px; + border: 1px solid var(--mat-sys-outline-variant); + border-radius: 8px; + overflow: hidden; + position: relative; + } + + iframe { + flex: 1; + max-width: 100%; + max-height: 100%; + border: none; + background-color: white; /* Ensure content is readable */ + } + `, + template: ` `, +}) +export class WebAppFrameUrl extends CatalogComponent implements OnDestroy, OnInit { + private readonly sanitizer = inject(DomSanitizer); + private readonly rendererService = inject(A2uiRendererService); + + protected readonly allowedEvents = computed( + () => this.props()['allowedEvents']?.value() || [], + ); + protected readonly allowedFunctions = computed( + () => this.props()['allowedFunctions']?.value() || [], + ); + + protected readonly iframeSrc = signal( + this.sanitizer.bypassSecurityTrustResourceUrl('about:blank'), + ); + + private iframe = viewChild.required>('iframe'); + private messageHandler: ((event: MessageEvent) => void) | null = null; + private dataSubscriptions: any[] = []; + private resizeTimeout: any = null; + private lastWidth?: number; + private lastHeight?: number; + private lastBoundRootValues: Record = {}; + private isProcessingAppWrite = false; + private expectedOrigin = window.location.origin; // In production this should be validated + private targetUrl: string | null = null; + + private appPort: MessagePort | null = null; + private hostResizeObserver: ResizeObserver | null = null; + + ngOnInit() { + const urlProp = this.props()['url']?.value(); + if (urlProp && typeof urlProp === 'string') { + const url = new URL(urlProp); + url.searchParams.set('origin', window.location.origin); + this.expectedOrigin = url.origin; + this.targetUrl = url.toString(); + } + + const urlParams = new URLSearchParams(window.location.search); + const disableSecuritySelfTest = urlParams.get('disable_security_self_test') === 'true'; + + const currentOrigin = window.location.origin; + let sandboxUrl = `${currentOrigin}/mcp_apps_inner_iframe/sandbox-url.html`; + if (disableSecuritySelfTest) { + sandboxUrl += '?disable_security_self_test=true'; + } + this.iframeSrc.set(this.sanitizer.bypassSecurityTrustResourceUrl(sandboxUrl)); + + this.setupSandbox(); + } + + ngOnDestroy() { + this.clearDataSubscriptions(); + if (this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = null; + } + if (this.messageHandler) { + window.removeEventListener('message', this.messageHandler); + } + if (this.hostResizeObserver) { + this.hostResizeObserver.disconnect(); + this.hostResizeObserver = null; + } + } + + private clearDataSubscriptions() { + if (this.dataSubscriptions) { + this.dataSubscriptions.forEach(sub => sub.unsubscribe()); + this.dataSubscriptions = []; + } + } + + private handleSizeChange(width?: number, height?: number) { + if (this.resizeTimeout) { + return; + } + + this.resizeTimeout = setTimeout(() => { + this.resizeTimeout = null; + const iframeEl = this.iframe().nativeElement; + if (!iframeEl) return; + + const targetWidth = width !== undefined ? Math.max(200, Math.min(width, 3000)) : undefined; + const targetHeight = height !== undefined ? Math.max(100, Math.min(height, 2000)) : undefined; + + const widthDiff = + targetWidth !== undefined && this.lastWidth !== undefined + ? Math.abs(targetWidth - this.lastWidth) + : 100; + const heightDiff = + targetHeight !== undefined && this.lastHeight !== undefined + ? Math.abs(targetHeight - this.lastHeight) + : 100; + + if (targetWidth !== undefined && widthDiff >= 5) { + iframeEl.style.width = `${targetWidth}px`; + const parent = iframeEl.parentElement; + if (parent) { + parent.style.width = `${targetWidth}px`; + } + this.lastWidth = targetWidth; + } + + if (targetHeight !== undefined && heightDiff >= 5) { + iframeEl.style.height = `${targetHeight}px`; + const parent = iframeEl.parentElement; + if (parent) { + parent.style.height = `${targetHeight}px`; + parent.style.aspectRatio = 'auto'; + } + this.lastHeight = targetHeight; + } + }, 100); + } + + private setupSandbox() { + if (this.messageHandler) { + window.removeEventListener('message', this.messageHandler); + } + + this.messageHandler = async (event: MessageEvent) => { + // Basic origin check + if (event.origin !== this.expectedOrigin && event.origin !== window.location.origin) { + return; + } + + const iframeEl = this.iframe().nativeElement; + if (!iframeEl || event.source !== iframeEl.contentWindow) { + return; + } + + const data = event.data; + if (!data) return; + + if (data.type === 'a2ui_sandbox_proxy_ready') { + if (this.targetUrl && iframeEl.contentWindow) { + iframeEl.contentWindow.postMessage( + { + type: 'a2ui_sandbox_resource_ready', + url: this.targetUrl, + }, + '*', + ); + } + return; + } + + if (data.type === 'a2ui_app_frame_ready') { + this.initializeBridge(); + } else if (data.type === 'a2ui_action') { + if (this.allowedEvents().includes(data.action)) { + const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); + if (surface) { + surface.dispatchAction( + { + event: { + name: data.action, + context: data.data || {}, + }, + }, + this.componentId(), + ); + } + } else { + console.warn(`Action ${data.action} not in allowedEvents`); + } + } else if (data.type === 'a2ui_data_model_change') { + const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); + if (surface) { + const dataPaths: Record = + (this.props()['data']?.raw as any)?.paths ?? this.props()['data']?.value()?.paths ?? {}; + + if (dataPaths[data.key]) { + const dataPath = dataPaths[data.key]; + const targetPath = data.subpath + ? `${dataPath}${data.subpath.startsWith('/') ? '' : '/'}${data.subpath}` + : dataPath; + + const currentValue = surface.dataModel.get(targetPath); + if (JSON.stringify(currentValue) !== JSON.stringify(data.value)) { + this.isProcessingAppWrite = true; + try { + surface.dataModel.set(targetPath, data.value); + } finally { + this.isProcessingAppWrite = false; + } + } + } + } + } else if (data.type === 'a2ui_function_call') { + if (this.allowedFunctions().includes(data.call)) { + const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); + if (surface) { + const dataContext = new DataContext(surface, '/'); + try { + const result = await surface.catalog.invoker(data.call, data.args || {}, dataContext); + if (iframeEl.contentWindow) { + iframeEl.contentWindow.postMessage( + { + type: 'a2ui_function_result', + call: data.call, + callId: data.callId, + status: 'success', + result: result, + }, + '*', + ); + } + } catch (err: any) { + if (iframeEl.contentWindow) { + iframeEl.contentWindow.postMessage( + { + type: 'a2ui_function_result', + call: data.call, + callId: data.callId, + status: 'error', + error: { + code: 'EXECUTION_ERROR', + message: err.message || 'Error executing function', + }, + }, + '*', + ); + } + } + } + } else { + console.warn(`Function ${data.call} not in allowedFunctions`); + } + } else if (data.type === 'a2ui_size_changed') { + this.handleSizeChange(data.width, data.height); + } + }; + + window.addEventListener('message', this.messageHandler); + } + + private initializeBridge() { + this.clearDataSubscriptions(); + + const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); + const dataPaths: Record = + (this.props()['data']?.raw as any)?.paths ?? this.props()['data']?.value()?.paths ?? {}; + + const initialData: Record = {}; + + if (surface && Object.keys(dataPaths).length > 0) { + for (const [key, dataPath] of Object.entries(dataPaths)) { + initialData[key] = surface.dataModel.get(dataPath); + this.lastBoundRootValues[key] = JSON.stringify(initialData[key] ?? null); + + const sub = surface.dataModel.subscribe(dataPath, value => { + if (this.isProcessingAppWrite) return; + + const iframeEl = this.iframe().nativeElement; + if (!iframeEl.contentWindow) return; + + const prevStr = this.lastBoundRootValues[key]; + const prev = prevStr ? JSON.parse(prevStr) : null; + this.lastBoundRootValues[key] = JSON.stringify(value ?? null); + + if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value)) { + const oldVal = prev ? prev[k] : undefined; + if (JSON.stringify(oldVal) !== JSON.stringify(v)) { + iframeEl.contentWindow.postMessage( + { + type: 'a2ui_data_model_update', + key, + subpath: `/${k}`, + value: v, + }, + '*', + ); + } + } + } else { + if (JSON.stringify(prev) !== JSON.stringify(value)) { + iframeEl.contentWindow.postMessage( + { + type: 'a2ui_data_model_update', + key, + value, + }, + '*', + ); + } + } + }); + this.dataSubscriptions.push(sub); + } + } + + const iframeEl = this.iframe().nativeElement; + if (iframeEl.contentWindow) { + const channel = new MessageChannel(); + this.appPort = channel.port1; + + const rect = iframeEl.getBoundingClientRect(); + const hostContext = { + containerDimensions: { + width: rect.width, + height: rect.height, + }, + }; + + iframeEl.contentWindow.postMessage( + { + type: 'a2ui_app_frame_init', + value: { + initialData: initialData, + allowedEvents: this.allowedEvents(), + allowedFunctions: this.allowedFunctions(), + hostContext: hostContext, + }, + }, + '*', + [channel.port2], + ); + + if (this.hostResizeObserver) { + this.hostResizeObserver.disconnect(); + } + this.hostResizeObserver = new ResizeObserver(entries => { + const entry = entries[0]; + if (entry && iframeEl.contentWindow) { + iframeEl.contentWindow.postMessage( + { + type: 'a2ui_host_context_update', + value: { + containerDimensions: { + width: entry.contentRect.width, + height: entry.contentRect.height, + }, + }, + }, + '*', + ); + } + }); + this.hostResizeObserver.observe(iframeEl); + } + } +} diff --git a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-component_spec.md b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-component_spec.md new file mode 100644 index 0000000000..d203fe8a90 --- /dev/null +++ b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-component_spec.md @@ -0,0 +1,432 @@ +# A2UI WebApp Iframe Component Specification (v0.9) + +## A Specification for Sandboxed, Rich Interactive Components in the Agent-to-UI Protocol + +Jul 27, 2026 +Status: In progress + +# Abstract + +This specification document defines the A2UI Iframe Component (v0.9) for the secure, sandboxed rendering of rich interactive web applications and model-generated HTML content. This document serves two primary purposes: + +1. **Platform Implementation Blueprint:** It provides client-side platform developers with a strict, standard set of instructions to implement compliant `WebAppFrameUrl` and `WebAppFrameSrcdoc` components in any native programming language (e.g., TypeScript/Web, Kotlin/Android, Swift/iOS, or Dart/Flutter) while maintaining identical security and sandboxing guarantees. +2. **Interoperable Application Standard:** It defines a secure, transport-agnostic runtime environment and messaging contract. Embedded web application developers can build highly portable, rich interactive tools that are guaranteed to run seamlessly, sync state, and invoke local functions inside "any" A2UI-compliant client wrapper. + +# 1. Introduction and motivation + +The A2UI protocol is designed to stream structured, type-safe JSON component trees to a client renderer. While A2UI provides standard primitive components (e.g., `Text`, `Row`, `Button`, `TextField`), complex enterprise use cases often require: + +- **Deterministic rendering** of highly custom legacy dashboards, charts, and visualizations. +- **Interactive embeds** like maps, complex multi-step forms, and dynamic tools (e.g., rich text editors, calculators, interactive games) served directly by remote servers. +- **Strict isolation** of untrusted third-party applications to protect the host application's DOM, session cookies, and storage. + +The **A2UI Iframe Component** bridges this gap. It defines a secure runtime environment inside a sandboxed proxy. + +In **A2UI v0.9**, the following new A2UI features can significantly increase the Iframe component's utility: + +- **Local Client-Side Function Calls:** Allowing isolated apps to trigger secure local custom functions (e.g., querying system hardware, opening URLs, local formatting). +- **Two-Way Local Data Binding:** Establishing a direct, reactive, network-free synchronization loop between the iframe's internal state and the parent A2UI local Data Model. + +# 2. Architectural overview + +In complex agentic workflows, rendering rich third-party widgets, charts, and legacy dashboards safely is critical. A2UI provides web-app embedding frames to run isolated code safely. + +To meet both security and performance requirements, A2UI separates this specification into two layers: + +1. **The WebAppFrame Runtime & Communication Contract:** A single, unified transport protocol that defines how _any_ application running inside an A2UI-based iframe communicates with the host. It covers JSON-RPC event messaging, local Two-Way Data Binding, and client-side function execution to support A2UI v0.9 features. +2. **Component Catalog Definitions & Rendering Setups:** Two separate frontend component definitions—**WebAppFrameUrl** and **WebAppFrameSrcdoc**—each with a tailored schema and unique sandbox/security configurations corresponding to their specific source type (external URL vs. raw inline HTML). + +# 3. The WebAppFrame runtime and communication contract + +While simple, LLM-generated applications can use raw, fire-and-forget `window.postMessage` events for zero-dependency execution, **human developers should use the official `@a2ui/web-bridge` SDK (coming soon)** (or equivalent). + +The `@a2ui/web-bridge` SDK establishes a private `MessageChannel` between the host and the iframe and wraps the underlying protocol into a secure, type-safe, and Promise-based API. This hides the complexity of request correlation, deep-equality checks, and message origin validation. + +However, at the wire level, all communications occur using custom top-level message string tags (`a2ui_*`) with flat keys. The protocol definitions below represent this underlying wire format. + +## 3.1. Sandbox bootstrap lifecycle + +Before the application-level handshake occurs, WebAppFrame components that rely on the **Double-Iframe Sandboxing** architecture (such as `WebAppFrameUrl` loading external 3P content) must complete an infrastructure-level bootstrap sequence. + +This bootstrap ensures that the untrusted URL or HTML content is securely injected into a strict inner sandbox, rather than loading directly into the outer proxy frame. + +```mermaid +sequenceDiagram + participant Host as Host Client (WebAppFrameUrl) + participant Proxy as Outer Proxy (sandbox.html) + participant Inner as Inner Sandbox (Untrusted App) + + Proxy->>Host: 1. a2ui_sandbox_proxy_ready + Host->>Proxy: 2. a2ui_sandbox_resource_ready (url or htmlContent) + Proxy->>Inner: 3. Injects resource into sandboxed iframe +``` + +1. **`a2ui_sandbox_proxy_ready` (Proxy -> Host):** The outer proxy iframe (e.g. `sandbox.html`) is loaded from a trusted origin. Once its script initializes, it sends this message to the Host to signal it is ready to receive untrusted content. + ```json + { + "type": "a2ui_sandbox_proxy_ready" + } + ``` +2. **`a2ui_sandbox_resource_ready` (Host -> Proxy):** The Host intercepts the proxy ready signal and replies with the untrusted URL (or raw HTML for Srcdoc). + ```json + { + "type": "a2ui_sandbox_resource_ready", + "url": "https://untrusted-3p-app.com/" + } + ``` +3. **Inner Sandbox Creation:** The proxy frame dynamically creates an inner ` `, }) -export class WebAppFrameUrl extends CatalogComponent implements OnDestroy, OnInit { +export class WebAppFrameUrl extends CatalogComponent implements OnDestroy, OnInit { private readonly sanitizer = inject(DomSanitizer); private readonly rendererService = inject(A2uiRendererService); - protected readonly allowedEvents = computed>( + protected readonly allowedEvents = computed>( () => this.props()['allowedEvents']?.value() || {}, ); - protected readonly allowedFunctions = computed>( + protected readonly allowedFunctions = computed>( () => this.props()['allowedFunctions']?.value() || {}, ); - protected readonly mutableData = computed>( + protected readonly mutableData = computed>( () => this.props()['mutableData']?.value() || {}, ); protected readonly disableSchemaValidation = computed( () => this.props()['disableSchemaValidation']?.value() || false, ); + protected readonly dataPaths = computed>(() => { + const dataProp = this.props()['data']; + if (!dataProp) return {}; + + const rawPaths = (dataProp.raw as {paths?: Record})?.paths; + const valuePaths = dataProp.value()?.paths; + + return rawPaths ?? valuePaths ?? {}; + }); protected readonly iframeSrc = signal( this.sanitizer.bypassSecurityTrustResourceUrl('about:blank'), @@ -83,8 +105,8 @@ export class WebAppFrameUrl extends CatalogComponent implements OnDestroy, private ajv = new Ajv(); private iframe = viewChild.required>('iframe'); private messageHandler: ((event: MessageEvent) => void) | null = null; - private dataSubscriptions: any[] = []; - private resizeTimeout: any = null; + private dataSubscriptions: { unsubscribe: () => void }[] = []; + private resizeTimeout: ReturnType | null = null; private lastWidth?: number; private lastHeight?: number; private lastBoundRootValues: Record = {}; @@ -256,8 +278,7 @@ export class WebAppFrameUrl extends CatalogComponent implements OnDestroy, } const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); if (surface) { - const dataPaths: Record = - (this.props()['data']?.raw as any)?.paths ?? this.props()['data']?.value()?.paths ?? {}; + const dataPaths = this.dataPaths(); if (dataPaths[data.key]) { const dataPath = dataPaths[data.key]; @@ -318,8 +339,9 @@ export class WebAppFrameUrl extends CatalogComponent implements OnDestroy, '*', ); } - } catch (err: any) { + } catch (err: unknown) { if (iframeEl.contentWindow) { + const errorMessage = err instanceof Error ? err.message : String(err) || 'Error executing function'; iframeEl.contentWindow.postMessage( { type: 'a2ui_function_result', @@ -328,7 +350,7 @@ export class WebAppFrameUrl extends CatalogComponent implements OnDestroy, status: 'error', error: { code: 'EXECUTION_ERROR', - message: err.message || 'Error executing function', + message: errorMessage, }, }, '*', @@ -351,10 +373,9 @@ export class WebAppFrameUrl extends CatalogComponent implements OnDestroy, this.clearDataSubscriptions(); const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); - const dataPaths: Record = - (this.props()['data']?.raw as any)?.paths ?? this.props()['data']?.value()?.paths ?? {}; + const dataPaths = this.dataPaths(); - const initialData: Record = {}; + const initialData: Record = {}; if (surface && Object.keys(dataPaths).length > 0) { for (const [key, dataPath] of Object.entries(dataPaths)) { From f6ba5ae5d8fd0cabfeb56928ce7ca9076a2130aa Mon Sep 17 00:00:00 2001 From: Yuzuru Tanahashi Date: Wed, 29 Jul 2026 21:02:07 +0000 Subject: [PATCH 06/14] style: reformat WebAppFrameUrl component for improved code readability --- .../src/a2ui-catalog/web-app-frame-url.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts index 53eee7b13a..bd338be338 100644 --- a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts +++ b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts @@ -72,7 +72,10 @@ export interface WebAppFrameUrlApi extends ComponentApi `, }) -export class WebAppFrameUrl extends CatalogComponent implements OnDestroy, OnInit { +export class WebAppFrameUrl + extends CatalogComponent + implements OnDestroy, OnInit +{ private readonly sanitizer = inject(DomSanitizer); private readonly rendererService = inject(A2uiRendererService); @@ -91,10 +94,10 @@ export class WebAppFrameUrl extends CatalogComponent implemen protected readonly dataPaths = computed>(() => { const dataProp = this.props()['data']; if (!dataProp) return {}; - + const rawPaths = (dataProp.raw as {paths?: Record})?.paths; const valuePaths = dataProp.value()?.paths; - + return rawPaths ?? valuePaths ?? {}; }); @@ -105,7 +108,7 @@ export class WebAppFrameUrl extends CatalogComponent implemen private ajv = new Ajv(); private iframe = viewChild.required>('iframe'); private messageHandler: ((event: MessageEvent) => void) | null = null; - private dataSubscriptions: { unsubscribe: () => void }[] = []; + private dataSubscriptions: {unsubscribe: () => void}[] = []; private resizeTimeout: ReturnType | null = null; private lastWidth?: number; private lastHeight?: number; @@ -341,7 +344,8 @@ export class WebAppFrameUrl extends CatalogComponent implemen } } catch (err: unknown) { if (iframeEl.contentWindow) { - const errorMessage = err instanceof Error ? err.message : String(err) || 'Error executing function'; + const errorMessage = + err instanceof Error ? err.message : String(err) || 'Error executing function'; iframeEl.contentWindow.postMessage( { type: 'a2ui_function_result', From b2a7a2c74b35a2cb3a9329212a19c0f2e78f18b7 Mon Sep 17 00:00:00 2001 From: Yuzuru Tanahashi Date: Wed, 29 Jul 2026 21:17:23 +0000 Subject: [PATCH 07/14] style: remove extra spaces in additionalProperties JSON schema definitions --- .../adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json b/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json index fa33e922d9..b2ca2557e0 100644 --- a/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json +++ b/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json @@ -111,17 +111,17 @@ "allowedEvents": { "type": "object", "description": "A map of authorized action names to JSON Schemas defining the expected data payload.", - "additionalProperties": { "type": "object" } + "additionalProperties": {"type": "object"} }, "allowedFunctions": { "type": "object", "description": "A map of authorized host client functions to JSON Schemas defining their expected arguments.", - "additionalProperties": { "type": "object" } + "additionalProperties": {"type": "object"} }, "mutableData": { "type": "object", "description": "A map of authorized data model keys to JSON Schemas defining their allowed values.", - "additionalProperties": { "type": "object" } + "additionalProperties": {"type": "object"} }, "data": { "type": "object", From abb268eb0fac7056c8616ca5f19907f6f9548c4a Mon Sep 17 00:00:00 2001 From: Yuzuru Tanahashi Date: Wed, 29 Jul 2026 22:50:12 +0000 Subject: [PATCH 08/14] feat: implement type-safe web frame message handling with Zod schema validation --- .../src/a2ui-catalog/web-app-frame-url.ts | 318 ++++++++++-------- .../src/a2ui-catalog/web-frame-messages.ts | 71 ++++ 2 files changed, 243 insertions(+), 146 deletions(-) create mode 100644 samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-messages.ts diff --git a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts index bd338be338..eb6e1cd2ce 100644 --- a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts +++ b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts @@ -31,6 +31,7 @@ import { } from '@angular/core'; import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; import Ajv from 'ajv'; +import {IncomingWebFrameMessageSchema, IncomingWebFrameMessage, A2uiMessageType} from './web-frame-messages'; const WebAppFrameUrlPropsSchema = z.object({ url: z.string().optional(), @@ -207,6 +208,153 @@ export class WebAppFrameUrl }, 100); } + private handleSandboxProxyReady(iframeEl: HTMLIFrameElement) { + if (this.targetUrl && iframeEl.contentWindow) { + iframeEl.contentWindow.postMessage( + { + type: A2uiMessageType.SandboxResourceReady, + url: this.targetUrl, + }, + window.location.origin, + ); + } + } + + private handleAction(data: Extract) { + if (data.action in this.allowedEvents()) { + const schema = this.allowedEvents()[data.action]; + if (!this.disableSchemaValidation() && schema) { + const validate = this.ajv.compile(schema); + if (!validate(data.data || {})) { + console.warn(`Action ${data.action} failed schema validation:`, validate.errors); + return; + } + } + const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); + if (surface) { + surface.dispatchAction( + { + event: { + name: data.action, + context: data.data || {}, + }, + }, + this.componentId(), + ); + } + } else { + console.warn(`Action ${data.action} not in allowedEvents`); + } + } + + private handleDataModelChange( + data: Extract, + ) { + if (!(data.key in this.mutableData())) { + console.warn(`Data key ${data.key} not authorized for mutation`); + return; + } + const schema = this.mutableData()[data.key]; + if (!this.disableSchemaValidation() && schema) { + const validate = this.ajv.compile(schema); + if (!validate(data.value)) { + console.warn(`Data change for ${data.key} failed schema validation:`, validate.errors); + return; + } + } + const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); + if (surface) { + const dataPaths = this.dataPaths(); + + if (dataPaths[data.key]) { + const dataPath = dataPaths[data.key]; + const targetPath = data.subpath + ? `${dataPath}${data.subpath.startsWith('/') ? '' : '/'}${data.subpath}` + : dataPath; + + const currentValue = surface.dataModel.get(targetPath); + if (JSON.stringify(currentValue) !== JSON.stringify(data.value)) { + this.isProcessingAppWrite = true; + try { + surface.dataModel.set(targetPath, data.value); + } finally { + this.isProcessingAppWrite = false; + } + } + } + } + } + + private async handleFunctionCall( + data: Extract, + iframeEl: HTMLIFrameElement, + ) { + if (data.call in this.allowedFunctions()) { + const schema = this.allowedFunctions()[data.call]; + if (!this.disableSchemaValidation() && schema) { + const validate = this.ajv.compile(schema); + if (!validate(data.args || {})) { + console.warn(`Function ${data.call} failed schema validation:`, validate.errors); + if (iframeEl.contentWindow) { + iframeEl.contentWindow.postMessage( + { + type: A2uiMessageType.FunctionResult, + call: data.call, + callId: data.callId, + status: 'error', + error: { + code: 'VALIDATION_ERROR', + message: 'Arguments failed schema validation', + }, + }, + window.location.origin, + ); + } + return; + } + } + const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); + if (surface) { + const dataContext = new DataContext(surface, '/'); + try { + const result = await surface.catalog.invoker(data.call, data.args || {}, dataContext); + if (iframeEl.contentWindow) { + iframeEl.contentWindow.postMessage( + { + type: A2uiMessageType.FunctionResult, + call: data.call, + callId: data.callId, + status: 'success', + result: result, + }, + window.location.origin, + ); + } + } catch (err: unknown) { + if (iframeEl.contentWindow) { + const errorMessage = + err instanceof Error ? err.message : String(err) || 'Error executing function'; + iframeEl.contentWindow.postMessage( + { + type: A2uiMessageType.FunctionResult, + call: data.call, + callId: data.callId, + status: 'error', + error: { + code: 'EXECUTION_ERROR', + message: errorMessage, + }, + }, + window.location.origin, + ); + } + } + } + } else { + console.warn(`Function ${data.call} not in allowedFunctions`); + } + } + private setupSandbox() { if (this.messageHandler) { window.removeEventListener('message', this.messageHandler); @@ -223,149 +371,27 @@ export class WebAppFrameUrl return; } - const data = event.data; - if (!data) return; + const parsedData = IncomingWebFrameMessageSchema.safeParse(event.data); + if (!parsedData.success) { + return; // Ignore invalid or unrecognized messages + } - if (data.type === 'a2ui_sandbox_proxy_ready') { - if (this.targetUrl && iframeEl.contentWindow) { - iframeEl.contentWindow.postMessage( - { - type: 'a2ui_sandbox_resource_ready', - url: this.targetUrl, - }, - '*', - ); - } + const data = parsedData.data; + + if (data.type === A2uiMessageType.SandboxProxyReady) { + this.handleSandboxProxyReady(iframeEl); return; } - if (data.type === 'a2ui_app_frame_ready') { + if (data.type === A2uiMessageType.AppFrameReady) { this.initializeBridge(); - } else if (data.type === 'a2ui_action') { - if (data.action in this.allowedEvents()) { - const schema = this.allowedEvents()[data.action]; - if (!this.disableSchemaValidation() && schema) { - const validate = this.ajv.compile(schema); - if (!validate(data.data || {})) { - console.warn(`Action ${data.action} failed schema validation:`, validate.errors); - return; - } - } - const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); - if (surface) { - surface.dispatchAction( - { - event: { - name: data.action, - context: data.data || {}, - }, - }, - this.componentId(), - ); - } - } else { - console.warn(`Action ${data.action} not in allowedEvents`); - } - } else if (data.type === 'a2ui_data_model_change') { - if (!(data.key in this.mutableData())) { - console.warn(`Data key ${data.key} not authorized for mutation`); - return; - } - const schema = this.mutableData()[data.key]; - if (!this.disableSchemaValidation() && schema) { - const validate = this.ajv.compile(schema); - if (!validate(data.value)) { - console.warn(`Data change for ${data.key} failed schema validation:`, validate.errors); - return; - } - } - const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); - if (surface) { - const dataPaths = this.dataPaths(); - - if (dataPaths[data.key]) { - const dataPath = dataPaths[data.key]; - const targetPath = data.subpath - ? `${dataPath}${data.subpath.startsWith('/') ? '' : '/'}${data.subpath}` - : dataPath; - - const currentValue = surface.dataModel.get(targetPath); - if (JSON.stringify(currentValue) !== JSON.stringify(data.value)) { - this.isProcessingAppWrite = true; - try { - surface.dataModel.set(targetPath, data.value); - } finally { - this.isProcessingAppWrite = false; - } - } - } - } - } else if (data.type === 'a2ui_function_call') { - if (data.call in this.allowedFunctions()) { - const schema = this.allowedFunctions()[data.call]; - if (!this.disableSchemaValidation() && schema) { - const validate = this.ajv.compile(schema); - if (!validate(data.args || {})) { - console.warn(`Function ${data.call} failed schema validation:`, validate.errors); - if (iframeEl.contentWindow) { - iframeEl.contentWindow.postMessage( - { - type: 'a2ui_function_result', - call: data.call, - callId: data.callId, - status: 'error', - error: { - code: 'VALIDATION_ERROR', - message: 'Arguments failed schema validation', - }, - }, - '*', - ); - } - return; - } - } - const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); - if (surface) { - const dataContext = new DataContext(surface, '/'); - try { - const result = await surface.catalog.invoker(data.call, data.args || {}, dataContext); - if (iframeEl.contentWindow) { - iframeEl.contentWindow.postMessage( - { - type: 'a2ui_function_result', - call: data.call, - callId: data.callId, - status: 'success', - result: result, - }, - '*', - ); - } - } catch (err: unknown) { - if (iframeEl.contentWindow) { - const errorMessage = - err instanceof Error ? err.message : String(err) || 'Error executing function'; - iframeEl.contentWindow.postMessage( - { - type: 'a2ui_function_result', - call: data.call, - callId: data.callId, - status: 'error', - error: { - code: 'EXECUTION_ERROR', - message: errorMessage, - }, - }, - '*', - ); - } - } - } - } else { - console.warn(`Function ${data.call} not in allowedFunctions`); - } - } else if (data.type === 'a2ui_size_changed') { + } else if (data.type === A2uiMessageType.Action) { + this.handleAction(data); + } else if (data.type === A2uiMessageType.DataModelChange) { + this.handleDataModelChange(data); + } else if (data.type === A2uiMessageType.FunctionCall) { + await this.handleFunctionCall(data, iframeEl); + } else if (data.type === A2uiMessageType.SizeChanged) { this.handleSizeChange(data.width, data.height); } }; @@ -402,12 +428,12 @@ export class WebAppFrameUrl if (JSON.stringify(oldVal) !== JSON.stringify(v)) { iframeEl.contentWindow.postMessage( { - type: 'a2ui_data_model_update', + type: A2uiMessageType.DataModelUpdate, key, subpath: `/${k}`, value: v, }, - '*', + window.location.origin, ); } } @@ -415,11 +441,11 @@ export class WebAppFrameUrl if (JSON.stringify(prev) !== JSON.stringify(value)) { iframeEl.contentWindow.postMessage( { - type: 'a2ui_data_model_update', + type: A2uiMessageType.DataModelUpdate, key, value, }, - '*', + window.location.origin, ); } } @@ -443,7 +469,7 @@ export class WebAppFrameUrl iframeEl.contentWindow.postMessage( { - type: 'a2ui_app_frame_init', + type: A2uiMessageType.AppFrameInit, value: { initialData: initialData, allowedEvents: this.allowedEvents(), @@ -452,7 +478,7 @@ export class WebAppFrameUrl hostContext: hostContext, }, }, - '*', + window.location.origin, [channel.port2], ); @@ -464,7 +490,7 @@ export class WebAppFrameUrl if (entry && iframeEl.contentWindow) { iframeEl.contentWindow.postMessage( { - type: 'a2ui_host_context_update', + type: A2uiMessageType.HostContextUpdate, value: { containerDimensions: { width: entry.contentRect.width, @@ -472,7 +498,7 @@ export class WebAppFrameUrl }, }, }, - '*', + window.location.origin, ); } }); diff --git a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-messages.ts b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-messages.ts new file mode 100644 index 0000000000..cc91861190 --- /dev/null +++ b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-messages.ts @@ -0,0 +1,71 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {z} from 'zod'; + +/** + * A2UI protocol message types for cross-frame communication. + * These string literals represent the message types used to sync state, + * invoke functions, and handle lifecycle events between the host application + * and the sandboxed web app frames. + */ +export const A2uiMessageType = { + Action: 'a2ui_action', + DataModelChange: 'a2ui_data_model_change', + DataModelUpdate: 'a2ui_data_model_update', + FunctionCall: 'a2ui_function_call', + FunctionResult: 'a2ui_function_result', + SandboxProxyReady: 'a2ui_sandbox_proxy_ready', + SandboxResourceReady: 'a2ui_sandbox_resource_ready', + AppFrameReady: 'a2ui_app_frame_ready', + AppFrameInit: 'a2ui_app_frame_init', + SizeChanged: 'a2ui_size_changed', + HostContextUpdate: 'a2ui_host_context_update', +} as const; + +/** + * Zod schema defining the expected structure of incoming messages from the sandboxed + * web frame. It uses a discriminated union on the `type` field to strongly type + * the payload for each specific message type (e.g., actions, data changes, function calls). + */ +export const IncomingWebFrameMessageSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal(A2uiMessageType.SandboxProxyReady) }), + z.object({ type: z.literal(A2uiMessageType.AppFrameReady) }), + z.object({ + type: z.literal(A2uiMessageType.Action), + action: z.string(), + data: z.any().optional(), + }), + z.object({ + type: z.literal(A2uiMessageType.DataModelChange), + key: z.string(), + subpath: z.string().optional(), + value: z.any(), + }), + z.object({ + type: z.literal(A2uiMessageType.FunctionCall), + call: z.string(), + callId: z.union([z.string(), z.number()]), + args: z.any().optional(), + }), + z.object({ + type: z.literal(A2uiMessageType.SizeChanged), + width: z.number().optional(), + height: z.number().optional(), + }), +]); + +export type IncomingWebFrameMessage = z.infer; From afe14b256e3e8758573b8d16d864e2c1a2557690 Mon Sep 17 00:00:00 2001 From: Yuzuru Tanahashi Date: Wed, 29 Jul 2026 22:52:05 +0000 Subject: [PATCH 09/14] docs: reformat specification text with consistent line wrapping --- .../a2ui-catalog/web-frame-component_spec.md | 339 +++++++++++++----- 1 file changed, 248 insertions(+), 91 deletions(-) diff --git a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-component_spec.md b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-component_spec.md index cdd98ff123..aefa585868 100644 --- a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-component_spec.md +++ b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-component_spec.md @@ -7,48 +7,80 @@ Status: In progress # Abstract -This specification document defines the A2UI Iframe Component (v0.9) for the secure, sandboxed rendering of rich interactive web applications and model-generated HTML content. This document serves two primary purposes: - -1. **Platform Implementation Blueprint:** It provides client-side platform developers with a strict, standard set of instructions to implement compliant `WebAppFrameUrl` and `WebAppFrameSrcdoc` components in any native programming language (e.g., TypeScript/Web, Kotlin/Android, Swift/iOS, or Dart/Flutter) while maintaining identical security and sandboxing guarantees. -2. **Interoperable Application Standard:** It defines a secure, transport-agnostic runtime environment and messaging contract. Embedded web application developers can build highly portable, rich interactive tools that are guaranteed to run seamlessly, sync state, and invoke local functions inside "any" A2UI-compliant client wrapper. +This specification document defines the A2UI Iframe Component (v0.9) for the secure, sandboxed +rendering of rich interactive web applications and model-generated HTML content. This document +serves two primary purposes: + +1. **Platform Implementation Blueprint:** It provides client-side platform developers with a strict, + standard set of instructions to implement compliant `WebAppFrameUrl` and `WebAppFrameSrcdoc` + components in any native programming language (e.g., TypeScript/Web, Kotlin/Android, Swift/iOS, + or Dart/Flutter) while maintaining identical security and sandboxing guarantees. +2. **Interoperable Application Standard:** It defines a secure, transport-agnostic runtime + environment and messaging contract. Embedded web application developers can build highly + portable, rich interactive tools that are guaranteed to run seamlessly, sync state, and invoke + local functions inside "any" A2UI-compliant client wrapper. # 1. Introduction and motivation -The A2UI protocol is designed to stream structured, type-safe JSON component trees to a client renderer. While A2UI provides standard primitive components (e.g., `Text`, `Row`, `Button`, `TextField`), complex enterprise use cases often require: +The A2UI protocol is designed to stream structured, type-safe JSON component trees to a client +renderer. While A2UI provides standard primitive components (e.g., `Text`, `Row`, `Button`, +`TextField`), complex enterprise use cases often require: - **Deterministic rendering** of highly custom legacy dashboards, charts, and visualizations. -- **Interactive embeds** like maps, complex multi-step forms, and dynamic tools (e.g., rich text editors, calculators, interactive games) served directly by remote servers. -- **Strict isolation** of untrusted third-party applications to protect the host application's DOM, session cookies, and storage. +- **Interactive embeds** like maps, complex multi-step forms, and dynamic tools (e.g., rich text + editors, calculators, interactive games) served directly by remote servers. +- **Strict isolation** of untrusted third-party applications to protect the host application's DOM, + session cookies, and storage. -The **A2UI Iframe Component** bridges this gap. It defines a secure runtime environment inside a sandboxed proxy. +The **A2UI Iframe Component** bridges this gap. It defines a secure runtime environment inside a +sandboxed proxy. -In **A2UI v0.9**, the following new A2UI features can significantly increase the Iframe component's utility: +In **A2UI v0.9**, the following new A2UI features can significantly increase the Iframe component's +utility: -- **Local Client-Side Function Calls:** Allowing isolated apps to trigger secure local custom functions (e.g., querying system hardware, opening URLs, local formatting). -- **Two-Way Local Data Binding:** Establishing a direct, reactive, network-free synchronization loop between the iframe's internal state and the parent A2UI local Data Model. +- **Local Client-Side Function Calls:** Allowing isolated apps to trigger secure local custom + functions (e.g., querying system hardware, opening URLs, local formatting). +- **Two-Way Local Data Binding:** Establishing a direct, reactive, network-free synchronization loop + between the iframe's internal state and the parent A2UI local Data Model. # 2. Architectural overview -In complex agentic workflows, rendering rich third-party widgets, charts, and legacy dashboards safely is critical. A2UI provides web-app embedding frames to run isolated code safely. +In complex agentic workflows, rendering rich third-party widgets, charts, and legacy dashboards +safely is critical. A2UI provides web-app embedding frames to run isolated code safely. -To meet both security and performance requirements, A2UI separates this specification into two layers: +To meet both security and performance requirements, A2UI separates this specification into two +layers: -1. **The WebAppFrame Runtime & Communication Contract:** A single, unified transport protocol that defines how _any_ application running inside an A2UI-based iframe communicates with the host. It covers JSON-RPC event messaging, local Two-Way Data Binding, and client-side function execution to support A2UI v0.9 features. -2. **Component Catalog Definitions & Rendering Setups:** Two separate frontend component definitions—**WebAppFrameUrl** and **WebAppFrameSrcdoc**—each with a tailored schema and unique sandbox/security configurations corresponding to their specific source type (external URL vs. raw inline HTML). +1. **The WebAppFrame Runtime & Communication Contract:** A single, unified transport protocol that + defines how _any_ application running inside an A2UI-based iframe communicates with the host. It + covers JSON-RPC event messaging, local Two-Way Data Binding, and client-side function execution + to support A2UI v0.9 features. +2. **Component Catalog Definitions & Rendering Setups:** Two separate frontend component + definitions—**WebAppFrameUrl** and **WebAppFrameSrcdoc**—each with a tailored schema and unique + sandbox/security configurations corresponding to their specific source type (external URL vs. raw + inline HTML). # 3. The WebAppFrame runtime and communication contract -While simple, LLM-generated applications can use raw, fire-and-forget `window.postMessage` events for zero-dependency execution, **human developers should use the official `@a2ui/web-bridge` SDK (coming soon)** (or equivalent). +While simple, LLM-generated applications can use raw, fire-and-forget `window.postMessage` events +for zero-dependency execution, **human developers should use the official `@a2ui/web-bridge` SDK +(coming soon)** (or equivalent). -The `@a2ui/web-bridge` SDK establishes a private `MessageChannel` between the host and the iframe and wraps the underlying protocol into a secure, type-safe, and Promise-based API. This hides the complexity of request correlation, deep-equality checks, and message origin validation. +The `@a2ui/web-bridge` SDK establishes a private `MessageChannel` between the host and the iframe +and wraps the underlying protocol into a secure, type-safe, and Promise-based API. This hides the +complexity of request correlation, deep-equality checks, and message origin validation. -However, at the wire level, all communications occur using custom top-level message string tags (`a2ui_*`) with flat keys. The protocol definitions below represent this underlying wire format. +However, at the wire level, all communications occur using custom top-level message string tags +(`a2ui_*`) with flat keys. The protocol definitions below represent this underlying wire format. ## 3.1. Sandbox bootstrap lifecycle -Before the application-level handshake occurs, WebAppFrame components that rely on the **Double-Iframe Sandboxing** architecture (such as `WebAppFrameUrl` loading external 3P content) must complete an infrastructure-level bootstrap sequence. +Before the application-level handshake occurs, WebAppFrame components that rely on the +**Double-Iframe Sandboxing** architecture (such as `WebAppFrameUrl` loading external 3P content) +must complete an infrastructure-level bootstrap sequence. -This bootstrap ensures that the untrusted URL or HTML content is securely injected into a strict inner sandbox, rather than loading directly into the outer proxy frame. +This bootstrap ensures that the untrusted URL or HTML content is securely injected into a strict +inner sandbox, rather than loading directly into the outer proxy frame. ```mermaid sequenceDiagram @@ -61,24 +93,30 @@ sequenceDiagram Proxy->>Inner: 3. Injects resource into sandboxed iframe ``` -1. **`a2ui_sandbox_proxy_ready` (Proxy -> Host):** The outer proxy iframe (e.g. `sandbox.html`) is loaded from a trusted origin. Once its script initializes, it sends this message to the Host to signal it is ready to receive untrusted content. +1. **`a2ui_sandbox_proxy_ready` (Proxy -> Host):** The outer proxy iframe (e.g. `sandbox.html`) is + loaded from a trusted origin. Once its script initializes, it sends this message to the Host to + signal it is ready to receive untrusted content. ```json { "type": "a2ui_sandbox_proxy_ready" } ``` -2. **`a2ui_sandbox_resource_ready` (Host -> Proxy):** The Host intercepts the proxy ready signal and replies with the untrusted URL (or raw HTML for Srcdoc). +2. **`a2ui_sandbox_resource_ready` (Host -> Proxy):** The Host intercepts the proxy ready signal and + replies with the untrusted URL (or raw HTML for Srcdoc). ```json { "type": "a2ui_sandbox_resource_ready", "url": "https://untrusted-3p-app.com/" } ``` -3. **Inner Sandbox Creation:** The proxy frame dynamically creates an inner `