diff --git a/specification/proposals/elemental/README.md b/specification/proposals/elemental/README.md new file mode 100644 index 0000000000..a811da143c --- /dev/null +++ b/specification/proposals/elemental/README.md @@ -0,0 +1,85 @@ +# A2UI Elemental developer guide + +A2UI Elemental is a model-optimized declarative UI format that uses plain HTML5-like +custom-element markup (no custom JavaScript or CSS) as the inference format. A host-side +compiler parses this markup and compiles it into standard A2UI v1.0 wire protocol payloads. + +This guide covers the standalone developer scripts in +`specification/proposals/elemental/scripts/` for compiling, decompiling, and generating +prompts from a catalog. + +> **Note:** A2UI Elemental is `@experimental` and may change without notice. Unlike A2UI +> Express, it is **not** gated behind an environment variable — no `A2UI_*_ENABLED` flag is +> required. + +--- + +## Prerequisites + +The scripts load the experimental Elemental code from the repo source and resolve the rest +of the A2UI SDK (and its dependencies such as `google-adk` and `antlr4`) from the +`a2ui_agent` project. The simplest way to run them is with [`uv`](https://docs.astral.sh/uv/), +which provisions the project environment automatically: + +```bash +cd specification/proposals/elemental +``` + +All commands below run from that directory and use +`uv run --project ../../../agent_sdks/python/a2ui_agent` so the correct environment is used +regardless of your global Python setup. + +--- + +## CLI utility reference + +### Prompt generation + +Generate the model system prompt contract, containing the HTML5 markup rules and the +per-component TypeScript/TSX interface signatures compiled from the active catalog schema: + +```bash +uv run --project ../../../agent_sdks/python/a2ui_agent scripts/run_prompt_generator.py \ + --catalog ../../v1_0/catalogs/basic/catalog.json +``` + +### Markup compiler (Elemental → A2UI JSON) + +Compile an A2UI Elemental markup file (optionally wrapped in `` sentinels, i.e. the +shape a model emits) into standard, pretty-printed A2UI v1.0 JSON: + +```bash +uv run --project ../../../agent_sdks/python/a2ui_agent scripts/run_compiler.py \ + path/to/sample.elemental \ + --surface-id "dashboard_surface" +``` + +`--surface-id` is optional: when provided it overrides the surface id, otherwise the id is +taken from the `` element (falling back to `main` if absent). + +### Decompiler (A2UI JSON → Elemental) + +Convert a standard A2UI v1.0 JSON example (either a gallery example with a `messages` +array or a raw envelope) back into A2UI Elemental markup, wrapped in the `` sentinel: + +```bash +uv run --project ../../../agent_sdks/python/a2ui_agent scripts/run_decompiler.py \ + ../../v1_0/catalogs/basic/examples/01_flight-status.json +``` + +--- + +## End-to-end round trip + +Decompile a standard example to Elemental, then compile it back to verify fidelity: + +```bash +uv run --project ../../../agent_sdks/python/a2ui_agent scripts/run_decompiler.py \ + ../../v1_0/catalogs/basic/examples/01_flight-status.json > /tmp/flight.elemental + +uv run --project ../../../agent_sdks/python/a2ui_agent scripts/run_compiler.py \ + /tmp/flight.elemental +``` + +The decompiler emits the surface (components + data model) as ``-wrapped markup; +the compiler parses it back into a standard `createSurface` payload. diff --git a/specification/proposals/elemental/scripts/run_compiler.py b/specification/proposals/elemental/scripts/run_compiler.py new file mode 100755 index 0000000000..cf65eff89f --- /dev/null +++ b/specification/proposals/elemental/scripts/run_compiler.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# 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. + + +"""Command-line tool to compile A2UI Elemental markup into standard A2UI v1.0 JSON. + +Loads an A2UI Elemental file (HTML5-like markup, optionally wrapped in the +`` sentinel and a markdown code fence, i.e. the exact shape a model emits), +parses and compiles it against the specified catalog schema, and prints the +pretty-printed standard A2UI JSON message on stdout. +""" + +import argparse +import json +import os +import sys + +sys.path.insert( + 0, + os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "..", + "agent_sdks", + "python", + "a2ui_agent", + "src", + ) + ), +) +from typing import List, Union + +from a2ui.core.catalog import Catalog +from a2ui.inference_formats.experimental.elemental.parser import ElementalParser + + +def _force_surface_id(envelope: dict, surface_id: str) -> None: + """Overrides the surfaceId on whichever surface operation the envelope carries.""" + for op_key in ( + "createSurface", + "updateComponents", + "updateDataModel", + "deleteSurface", + ): + op = envelope.get(op_key) + if isinstance(op, dict): + op["surfaceId"] = surface_id + + +def compile_elemental_file( + elemental_path: str, catalog_path: str, surface_id=None +) -> Union[dict, List[dict]]: + """Compiles an A2UI Elemental markup file into standard JSON. + + Args: + elemental_path: Path to the A2UI Elemental markup file. + catalog_path: Path to the catalog JSON schema. + surface_id: Optional surface identifier. When provided it overrides the + `id` encoded in the `` element; when omitted, the `` id is + used (falling back to "main" if absent). + + Returns: + The compiled A2UI v1.0 JSON envelope. + + Raises: + FileNotFoundError: If the Elemental or catalog file does not exist. + ValueError: If no A2UI payload could be compiled from the input. + """ + if not os.path.exists(elemental_path): + raise FileNotFoundError(f"Elemental file not found: {elemental_path}") + if not os.path.exists(catalog_path): + raise FileNotFoundError(f"Catalog schema not found: {catalog_path}") + + with open(elemental_path, "r", encoding="utf-8") as f: + elemental_text = f.read() + + with open(catalog_path, "r", encoding="utf-8") as f: + catalog_dict = json.load(f) + catalog = Catalog.from_json(catalog_dict, spec_version="1.0") + + # The parser locates UI blocks by the sentinel. If the input is a + # bare block (no sentinel), wrap it so it can still be compiled. + if "" not in elemental_text.lower(): + elemental_text = f"\n{elemental_text}\n" + + parser = ElementalParser(catalog, surface_id or "main") + parts = parser.parse_response(elemental_text) + + envelopes = [] + for part in parts: + if getattr(part, "a2ui_json", None): + envelopes.extend(part.a2ui_json) + + if not envelopes: + raise ValueError( + "No A2UI payload was produced. Ensure the input is valid A2UI" + " Elemental markup wrapped in ... ." + ) + + # An explicit --surface-id overrides the id encoded in the element. + if surface_id is not None: + for envelope in envelopes: + _force_surface_id(envelope, surface_id) + + return envelopes[0] if len(envelopes) == 1 else envelopes + + +def main(): + """CLI entrypoint for the compiler.""" + parser = argparse.ArgumentParser( + description="Compile A2UI Elemental markup into standard A2UI v1.0 wire JSON." + ) + parser.add_argument( + "elemental_file", help="Path to the A2UI Elemental markup file to compile." + ) + parser.add_argument( + "--surface-id", + default=None, + help=( + "Override the surface id. If omitted, the id is taken from the " + " element (or 'main' when absent)." + ), + ) + parser.add_argument( + "--catalog", + default=os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "v1_0", + "catalogs", + "basic", + "catalog.json", + ), + help="Path to the catalog JSON schema (default: basic catalog).", + ) + + args = parser.parse_args() + + try: + compiled = compile_elemental_file( + args.elemental_file, args.catalog, args.surface_id + ) + print(json.dumps(compiled, indent=2)) + sys.exit(0) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/specification/proposals/elemental/scripts/run_decompiler.py b/specification/proposals/elemental/scripts/run_decompiler.py new file mode 100755 index 0000000000..ab806532c8 --- /dev/null +++ b/specification/proposals/elemental/scripts/run_decompiler.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +# 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. + + +"""Command-line script to decompile A2UI JSON examples into A2UI Elemental markup. + +Loads an A2UI JSON example file, merges its surface/component/data-model messages +into a single createSurface envelope, decompiles it against the catalog schema, +and prints the result wrapped in the `` sentinel (the shape a model emits) +on stdout. +""" + +import argparse +import json +import os +import sys + +sys.path.insert( + 0, + os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "..", + "agent_sdks", + "python", + "a2ui_agent", + "src", + ) + ), +) +from a2ui.core.catalog import Catalog +from a2ui.inference_formats.experimental.elemental.parser import ElementalParser + + +def decompile_example(example_path: str, catalog_path: str) -> str: + """Decompiles an A2UI JSON example file to A2UI Elemental markup. + + Accepts both the gallery example wrapper (`{name, description, messages: [...]}`) + and a raw envelope (`{version, createSurface: {...}}`). Gallery messages are + merged into one surface: components come from `updateComponents` (or an inline + `createSurface.components`), and the initial data model from `updateDataModel`. + + Args: + example_path: Path to the A2UI JSON example file. + catalog_path: Path to the catalog JSON schema. + + Returns: + The decompiled A2UI Elemental markup, wrapped in the `` sentinel. + + Raises: + FileNotFoundError: If the example or catalog file does not exist. + ValueError: If no components can be found in the example. + """ + if not os.path.exists(example_path): + raise FileNotFoundError(f"Example file not found: {example_path}") + if not os.path.exists(catalog_path): + raise FileNotFoundError(f"Catalog schema not found: {catalog_path}") + + with open(example_path, "r", encoding="utf-8") as f: + ex_data = json.load(f) + + surface_id = "test_surf" + catalog_id = "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + components_list = None + data_model = None + + # Collect messages from either the gallery wrapper, a raw single envelope, + # or a raw list of envelopes. + if isinstance(ex_data, list): + messages = ex_data + else: + messages = ex_data.get("messages") + if messages is None: + messages = [ex_data] + + for msg in messages: + if not isinstance(msg, dict): + continue + create = msg.get("createSurface") + if isinstance(create, dict): + surface_id = create.get("surfaceId", surface_id) + catalog_id = create.get("catalogId", catalog_id) + if create.get("components"): + components_list = create["components"] + if create.get("dataModel"): + data_model = create["dataModel"] + update = msg.get("updateComponents") + if isinstance(update, dict): + components_list = update.get("components", components_list) + surface_id = update.get("surfaceId", surface_id) + update_dm = msg.get("updateDataModel") + if isinstance(update_dm, dict): + data_model = update_dm.get("value", update_dm.get("contents", data_model)) + + if not components_list: + raise ValueError( + "Could not find components (via 'updateComponents' or an inline" + f" 'createSurface.components') in {example_path}" + ) + + envelope = { + "version": "v1.0", + "createSurface": { + "surfaceId": surface_id, + "catalogId": catalog_id, + "components": components_list, + }, + } + if data_model: + envelope["createSurface"]["dataModel"] = data_model + + with open(catalog_path, "r", encoding="utf-8") as f: + catalog_dict = json.load(f) + catalog = Catalog.from_json(catalog_dict, spec_version="1.0") + + parser = ElementalParser(catalog) + block = parser.decompile(envelope) + # Wrap in the sentinel (and markdown fence) — the exact shape a model + # would emit and that run_compiler.py accepts back. + return parser.wrap_decompiled_blocks([block]) + + +def main(): + """CLI entrypoint for the decompiler.""" + parser = argparse.ArgumentParser( + description="Decompile standard A2UI JSON examples into A2UI Elemental markup." + ) + parser.add_argument( + "example_file", help="Path to the A2UI JSON example file to decompile." + ) + parser.add_argument( + "--catalog", + default=os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "v1_0", + "catalogs", + "basic", + "catalog.json", + ), + help="Path to the catalog JSON schema (default: basic catalog).", + ) + + args = parser.parse_args() + + try: + markup_output = decompile_example(args.example_file, args.catalog) + print(markup_output) + sys.exit(0) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/specification/proposals/elemental/scripts/run_prompt_generator.py b/specification/proposals/elemental/scripts/run_prompt_generator.py new file mode 100755 index 0000000000..beeb4ca7e4 --- /dev/null +++ b/specification/proposals/elemental/scripts/run_prompt_generator.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# 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. + + +"""Command-line tool to generate prompt contracts for A2UI Elemental. + +Crawls the specified catalog JSON schema and outputs a formatted model system +prompt containing the markup rules and the per-component TypeScript/TSX interface +signatures (converted to elements) on stdout. +""" + +import argparse +import os +import sys + +sys.path.insert( + 0, + os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "..", + "agent_sdks", + "python", + "a2ui_agent", + "src", + ) + ), +) +import json +from a2ui.core.catalog import Catalog +from a2ui.inference_formats.experimental.elemental.format import ElementalFormat + + +def generate_prompt_text(catalog_path: str) -> str: + """Generates the A2UI Elemental system prompt contract. + + Args: + catalog_path: Path to the catalog JSON schema. + + Returns: + The compiled system prompt text block, including the per-component TSX + interfaces (include_schema=True). + + Raises: + FileNotFoundError: If the catalog schema file does not exist. + """ + if not os.path.exists(catalog_path): + raise FileNotFoundError(f"Catalog schema not found: {catalog_path}") + + with open(catalog_path, "r", encoding="utf-8") as f: + catalog_dict = json.load(f) + catalog = Catalog.from_json(catalog_dict, spec_version="1.0") + + elemental_format = ElementalFormat(catalog=catalog) + return elemental_format.prompt_generator.generate( + role_description=( + "You are a helpful UI assistant that outputs interfaces using A2UI" + " Elemental markup." + ), + include_schema=True, + ) + + +def main(): + """CLI entrypoint for the prompt generator.""" + parser = argparse.ArgumentParser( + description=( + "Generate model system prompts for A2UI Elemental from a catalog schema." + ) + ) + parser.add_argument( + "--catalog", + default=os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "v1_0", + "catalogs", + "basic", + "catalog.json", + ), + help="Path to the catalog JSON schema (default: basic catalog).", + ) + + args = parser.parse_args() + + try: + prompt_content = generate_prompt_text(args.catalog) + print(prompt_content) + sys.exit(0) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()