diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..b0be515ab --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +## Unreleased + +### Added +- feat(agent-sdk/template): add template schema, `{{}}` data-binding spec, and 5 reference templates ([#721](https://github.com/google/A2UI/issues/721)) diff --git a/agent_sdks/python/agent_development.md b/agent_sdks/python/agent_development.md index 04bb588b5..0799e8c27 100644 --- a/agent_sdks/python/agent_development.md +++ b/agent_sdks/python/agent_development.md @@ -399,4 +399,74 @@ agent_card = AgentCard( ``` +### 5. Template-based Inference + +For agents that render a fixed set of known UI layouts, **template-based +inference** offers a more efficient alternative to raw schema-based generation. +Instead of the LLM generating full A2UI JSON payloads, it selects a pre-defined +template and provides only the dynamic data. + +**Benefits:** +- **Smaller LLM output** — the model returns a template name + data, not an + entire component tree. +- **Guaranteed valid UI** — templates are pre-validated at design time. +- **Visual authoring** — templates can be created in the A2UI Composer. + +#### How It Works + +1. The agent registers templates with the `A2uiTemplateManager`. +2. The LLM receives a catalog of template names and their `dataSchema`. +3. The LLM returns a compact response: + ```json + { + "templateName": "restaurant-card", + "data": { + "restaurant": { + "name": "The French Bistro", + "cuisine": "French", + "rating": 4.7 + } + } + } + ``` +4. The SDK inflates `{{}}` placeholders and produces standard v0.9 A2UI messages. + +#### Template Format + +Templates are stored as JSON files in the `template/examples/` directory. Each +template contains: + +- **`name`** — Unique kebab-case identifier (e.g. `restaurant-card`). +- **`catalogId`** — The A2UI catalog the components belong to. +- **`dataSchema`** — JSON Schema describing required LLM data. +- **`components`** — Flat list of v0.9 components with `{{placeholder}}` + expressions. +- **`dataModel`** (optional) — Default values for the data model. + +#### Placeholder Syntax + +String values may contain `{{path.to.field}}` expressions that resolve against +the LLM-provided `data`: + +```json +{"text": "{{restaurant.name}}"} // Whole-string → preserves type +{"text": "Rating: {{restaurant.rating}}"} // Inline → string interpolation +``` + +See [`template/template_schema.md`](src/a2ui/template/template_schema.md) for +the full specification including resolution rules, error behavior, and the +distinction between `{{}}` (server-side inflation) and `{"path": "..."}` +(client-side data binding). + +#### Reference Templates + +The SDK includes 5 reference templates in +[`template/examples/`](src/a2ui/template/examples/): +| Template | Use Case | +|----------|----------| +| `restaurant-card` | Restaurant display with booking button | +| `contact-card` | Contact info with message button | +| `product-detail` | Product with add-to-cart button | +| `weather-summary` | Weather conditions card | +| `booking-form` | Interactive reservation form (demonstrates `{{}}` + data binding coexistence) | diff --git a/agent_sdks/python/src/a2ui/template/examples/booking_form.json b/agent_sdks/python/src/a2ui/template/examples/booking_form.json new file mode 100644 index 000000000..4785af5e8 --- /dev/null +++ b/agent_sdks/python/src/a2ui/template/examples/booking_form.json @@ -0,0 +1,105 @@ +{ + "name": "booking-form", + "description": "An interactive reservation form with date/time picker, party size input, dietary requirements, and a submit button. Uses data binding for live form state.", + "version": "1.0.0", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json", + "dataSchema": { + "type": "object", + "required": ["booking"], + "properties": { + "booking": { + "type": "object", + "required": ["restaurantName"], + "properties": { + "restaurantName": { "type": "string", "description": "Name of the restaurant to book." }, + "imageUrl": { "type": "string", "description": "Restaurant image URL." }, + "address": { "type": "string", "description": "Restaurant address." }, + "minDate": { "type": "string", "format": "date", "description": "Earliest available booking date (ISO 8601)." }, + "defaultPartySize": { "type": "number", "description": "Suggested party size." } + } + } + } + }, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "form-title", + "restaurant-image", + "restaurant-address", + "party-size-field", + "datetime-field", + "dietary-field", + "submit-btn" + ] + }, + { + "id": "form-title", + "component": "Text", + "variant": "h2", + "text": "Book a Table at {{booking.restaurantName}}" + }, + { + "id": "restaurant-image", + "component": "Image", + "url": "{{booking.imageUrl}}", + "description": "Photo of {{booking.restaurantName}}" + }, + { + "id": "restaurant-address", + "component": "Text", + "variant": "caption", + "text": "{{booking.address}}" + }, + { + "id": "party-size-field", + "component": "TextField", + "label": "Party Size", + "value": { "path": "/partySize" }, + "variant": "number" + }, + { + "id": "datetime-field", + "component": "DateTimeInput", + "label": "Date & Time", + "value": { "path": "/reservationTime" }, + "enableDate": true, + "enableTime": true, + "min": "{{booking.minDate}}" + }, + { + "id": "dietary-field", + "component": "TextField", + "label": "Dietary Requirements", + "value": { "path": "/dietary" } + }, + { + "id": "submit-btn-text", + "component": "Text", + "text": "Submit Reservation" + }, + { + "id": "submit-btn", + "component": "Button", + "child": "submit-btn-text", + "variant": "primary", + "action": { + "event": { + "name": "submit_booking", + "context": { + "restaurantName": "{{booking.restaurantName}}", + "partySize": { "path": "/partySize" }, + "reservationTime": { "path": "/reservationTime" }, + "dietary": { "path": "/dietary" } + } + } + } + } + ], + "dataModel": { + "partySize": "{{booking.defaultPartySize}}", + "reservationTime": "", + "dietary": "" + } +} diff --git a/agent_sdks/python/src/a2ui/template/examples/contact_card.json b/agent_sdks/python/src/a2ui/template/examples/contact_card.json new file mode 100644 index 000000000..f609f3897 --- /dev/null +++ b/agent_sdks/python/src/a2ui/template/examples/contact_card.json @@ -0,0 +1,119 @@ +{ + "name": "contact-card", + "description": "Displays a contact with name, email, phone number, and a button to send a message.", + "version": "1.0.0", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json", + "dataSchema": { + "type": "object", + "required": ["contact"], + "properties": { + "contact": { + "type": "object", + "required": ["name", "email", "phone"], + "properties": { + "name": { "type": "string", "description": "Full name of the contact." }, + "email": { "type": "string", "description": "Email address." }, + "phone": { "type": "string", "description": "Phone number." }, + "role": { "type": "string", "description": "Job title or role." } + } + } + } + }, + "components": [ + { + "id": "root", + "component": "Card", + "child": "card-layout" + }, + { + "id": "card-layout", + "component": "Column", + "children": [ + "contact-name", + "contact-role", + "divider-1", + "email-row", + "phone-row", + "divider-2", + "message-btn" + ] + }, + { + "id": "contact-name", + "component": "Text", + "variant": "h2", + "text": "{{contact.name}}" + }, + { + "id": "contact-role", + "component": "Text", + "variant": "caption", + "text": "{{contact.role}}" + }, + { + "id": "divider-1", + "component": "Divider" + }, + { + "id": "email-row", + "component": "Row", + "children": ["email-icon", "email-text"], + "align": "center" + }, + { + "id": "email-icon", + "component": "Icon", + "name": "mail" + }, + { + "id": "email-text", + "component": "Text", + "text": "{{contact.email}}" + }, + { + "id": "phone-row", + "component": "Row", + "children": ["phone-icon", "phone-text"], + "align": "center" + }, + { + "id": "phone-icon", + "component": "Icon", + "name": "phone" + }, + { + "id": "phone-text", + "component": "Text", + "text": "{{contact.phone}}" + }, + { + "id": "divider-2", + "component": "Divider" + }, + { + "id": "message-btn-text", + "component": "Text", + "text": "Send Message" + }, + { + "id": "message-btn", + "component": "Button", + "child": "message-btn-text", + "variant": "primary", + "action": { + "event": { + "name": "send_message", + "context": { + "contactName": "{{contact.name}}", + "contactEmail": "{{contact.email}}" + } + } + } + } + ], + "dataModel": { + "contactName": "{{contact.name}}", + "contactEmail": "{{contact.email}}", + "contactPhone": "{{contact.phone}}" + } +} diff --git a/agent_sdks/python/src/a2ui/template/examples/product_detail.json b/agent_sdks/python/src/a2ui/template/examples/product_detail.json new file mode 100644 index 000000000..de1a1938f --- /dev/null +++ b/agent_sdks/python/src/a2ui/template/examples/product_detail.json @@ -0,0 +1,95 @@ +{ + "name": "product-detail", + "description": "Displays a product with title, image, price, description, and an add-to-cart button.", + "version": "1.0.0", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json", + "dataSchema": { + "type": "object", + "required": ["product"], + "properties": { + "product": { + "type": "object", + "required": ["title", "price", "description"], + "properties": { + "title": { "type": "string", "description": "Product name." }, + "price": { "type": "string", "description": "Formatted price string (e.g. '$29.99')." }, + "description": { "type": "string", "description": "Product description." }, + "imageUrl": { "type": "string", "description": "Product image URL." }, + "sku": { "type": "string", "description": "Stock-keeping unit identifier." } + } + } + } + }, + "components": [ + { + "id": "root", + "component": "Card", + "child": "product-layout" + }, + { + "id": "product-layout", + "component": "Column", + "children": [ + "product-image", + "product-title", + "product-price", + "divider-1", + "product-desc", + "cart-btn" + ] + }, + { + "id": "product-image", + "component": "Image", + "url": "{{product.imageUrl}}", + "description": "Image of {{product.title}}", + "variant": "largeFeature" + }, + { + "id": "product-title", + "component": "Text", + "variant": "h2", + "text": "{{product.title}}" + }, + { + "id": "product-price", + "component": "Text", + "variant": "h3", + "text": "{{product.price}}" + }, + { + "id": "divider-1", + "component": "Divider" + }, + { + "id": "product-desc", + "component": "Text", + "text": "{{product.description}}" + }, + { + "id": "cart-btn-text", + "component": "Text", + "text": "Add to Cart" + }, + { + "id": "cart-btn", + "component": "Button", + "child": "cart-btn-text", + "variant": "primary", + "action": { + "event": { + "name": "add_to_cart", + "context": { + "productTitle": "{{product.title}}", + "productSku": "{{product.sku}}", + "productPrice": "{{product.price}}" + } + } + } + } + ], + "dataModel": { + "productTitle": "{{product.title}}", + "productPrice": "{{product.price}}" + } +} diff --git a/agent_sdks/python/src/a2ui/template/examples/restaurant_card.json b/agent_sdks/python/src/a2ui/template/examples/restaurant_card.json new file mode 100644 index 000000000..24f8bce53 --- /dev/null +++ b/agent_sdks/python/src/a2ui/template/examples/restaurant_card.json @@ -0,0 +1,100 @@ +{ + "name": "restaurant-card", + "description": "Displays a restaurant with its name, cuisine type, star rating, and a booking button.", + "version": "1.0.0", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json", + "dataSchema": { + "type": "object", + "required": ["restaurant"], + "properties": { + "restaurant": { + "type": "object", + "required": ["name", "cuisine", "rating"], + "properties": { + "name": { "type": "string", "description": "The restaurant name." }, + "cuisine": { "type": "string", "description": "The cuisine type (e.g. French, Italian)." }, + "rating": { "type": "number", "description": "Star rating from 0 to 5." }, + "imageUrl": { "type": "string", "description": "URL of the restaurant image." }, + "address": { "type": "string", "description": "The restaurant street address." } + } + } + } + }, + "components": [ + { + "id": "root", + "component": "Card", + "child": "card-content" + }, + { + "id": "card-content", + "component": "Column", + "children": [ + "restaurant-image", + "restaurant-name", + "cuisine-text", + "rating-text", + "divider-1", + "address-text", + "book-btn" + ] + }, + { + "id": "restaurant-image", + "component": "Image", + "url": "{{restaurant.imageUrl}}", + "description": "Photo of {{restaurant.name}}", + "variant": "header" + }, + { + "id": "restaurant-name", + "component": "Text", + "variant": "h2", + "text": "{{restaurant.name}}" + }, + { + "id": "cuisine-text", + "component": "Text", + "text": "Cuisine: {{restaurant.cuisine}}" + }, + { + "id": "rating-text", + "component": "Text", + "text": "Rating: {{restaurant.rating}} / 5" + }, + { + "id": "divider-1", + "component": "Divider" + }, + { + "id": "address-text", + "component": "Text", + "variant": "caption", + "text": "{{restaurant.address}}" + }, + { + "id": "book-btn-text", + "component": "Text", + "text": "Book a Table" + }, + { + "id": "book-btn", + "component": "Button", + "child": "book-btn-text", + "variant": "primary", + "action": { + "event": { + "name": "book_restaurant", + "context": { + "restaurantName": "{{restaurant.name}}" + } + } + } + } + ], + "dataModel": { + "restaurantName": "{{restaurant.name}}", + "cuisine": "{{restaurant.cuisine}}", + "rating": "{{restaurant.rating}}" + } +} diff --git a/agent_sdks/python/src/a2ui/template/examples/weather_summary.json b/agent_sdks/python/src/a2ui/template/examples/weather_summary.json new file mode 100644 index 000000000..65793ad7b --- /dev/null +++ b/agent_sdks/python/src/a2ui/template/examples/weather_summary.json @@ -0,0 +1,97 @@ +{ + "name": "weather-summary", + "description": "Displays current weather conditions for a location including temperature, condition, and a brief forecast.", + "version": "1.0.0", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json", + "dataSchema": { + "type": "object", + "required": ["weather"], + "properties": { + "weather": { + "type": "object", + "required": ["location", "temperature", "condition"], + "properties": { + "location": { "type": "string", "description": "City or location name." }, + "temperature": { "type": "string", "description": "Current temperature with unit (e.g. '72°F')." }, + "condition": { "type": "string", "description": "Weather condition (e.g. 'Sunny', 'Rainy')." }, + "humidity": { "type": "string", "description": "Humidity percentage (e.g. '65%')." }, + "forecast": { "type": "string", "description": "Brief forecast summary for the next period." } + } + } + } + }, + "components": [ + { + "id": "root", + "component": "Card", + "child": "weather-layout" + }, + { + "id": "weather-layout", + "component": "Column", + "children": [ + "location-heading", + "temp-condition-row", + "divider-1", + "details-row", + "divider-2", + "forecast-text" + ] + }, + { + "id": "location-heading", + "component": "Text", + "variant": "h2", + "text": "{{weather.location}}" + }, + { + "id": "temp-condition-row", + "component": "Row", + "children": ["temp-text", "condition-text"], + "justify": "spaceBetween", + "align": "center" + }, + { + "id": "temp-text", + "component": "Text", + "variant": "h1", + "text": "{{weather.temperature}}" + }, + { + "id": "condition-text", + "component": "Text", + "variant": "h3", + "text": "{{weather.condition}}" + }, + { + "id": "divider-1", + "component": "Divider" + }, + { + "id": "details-row", + "component": "Row", + "children": ["humidity-text"], + "justify": "start" + }, + { + "id": "humidity-text", + "component": "Text", + "variant": "caption", + "text": "Humidity: {{weather.humidity}}" + }, + { + "id": "divider-2", + "component": "Divider" + }, + { + "id": "forecast-text", + "component": "Text", + "text": "{{weather.forecast}}" + } + ], + "dataModel": { + "location": "{{weather.location}}", + "temperature": "{{weather.temperature}}", + "condition": "{{weather.condition}}" + } +} diff --git a/agent_sdks/python/src/a2ui/template/schema.json b/agent_sdks/python/src/a2ui/template/schema.json new file mode 100644 index 000000000..319985a6e --- /dev/null +++ b/agent_sdks/python/src/a2ui/template/schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/agent_sdk/template/schema.json", + "title": "A2UI Template", + "description": "Defines a reusable A2UI UI template. Templates contain static component trees with {{placeholder}} expressions that are resolved at inflation time using data provided by an LLM. The inflated output is a standard sequence of v0.9 A2UI messages.", + "type": "object", + "required": ["name", "version", "catalogId", "components"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "A unique, human-readable identifier for this template. Must be kebab-case. This is the value the LLM returns in its 'templateName' response field.", + "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$", + "examples": ["restaurant-card", "booking-form", "contact-card"] + }, + "description": { + "type": "string", + "description": "A concise, human-readable summary of the UI this template renders. Included in LLM prompts to help the model choose the right template." + }, + "version": { + "type": "string", + "description": "The semantic version of this template.", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "examples": ["1.0.0"] + }, + "catalogId": { + "type": "string", + "format": "uri", + "description": "The A2UI catalog that this template's components belong to. Must match a valid catalog ID (e.g. the basic catalog URL). This value is used in the generated 'createSurface' message.", + "examples": ["https://a2ui.org/specification/v0_9/basic_catalog.json"] + }, + "surfaceId": { + "type": "string", + "description": "The surface ID to use when inflating this template into A2UI messages. If omitted, defaults to the template name.", + "pattern": "^([a-z][a-z0-9]*(-[a-z0-9]+)*)?$", + "default": "" + }, + "theme": { + "type": "object", + "description": "Optional theme parameters for the generated surface. Passed through to the 'createSurface' message.", + "additionalProperties": true + }, + "dataSchema": { + "type": "object", + "description": "A JSON Schema object describing the shape of the data the LLM must provide to inflate this template. Used by the SDK to (1) generate LLM prompt instructions and (2) validate the LLM response at runtime.", + "additionalProperties": true + }, + "components": { + "type": "array", + "description": "A flat, ordered list of A2UI component descriptors following the v0.9 catalog format. The first component with id 'root' serves as the component tree root. String-typed property values may contain {{placeholder}} expressions that reference fields from the LLM-provided data.", + "minItems": 1, + "prefixItems": [ + { + "properties": { "id": { "const": "root" } }, + "required": ["id"] + } + ], + "items": { + "$ref": "#/$defs/TemplateComponent" + } + }, + "dataModel": { + "type": "object", + "description": "An optional default data model object. Values may contain {{placeholder}} expressions. After inflation, this becomes the 'value' of an 'updateDataModel' message. Use this for initial field values, labels bound via {\"path\": \"...\"} in components, etc.", + "additionalProperties": true + } + }, + "$defs": { + "TemplateComponent": { + "type": "object", + "description": "A single A2UI component descriptor. Follows the v0.9 catalog component format with the addition that any string value may contain {{placeholder}} expressions.", + "required": ["id", "component"], + "properties": { + "id": { + "type": "string", + "description": "Unique component ID within this template." + }, + "component": { + "type": "string", + "description": "The component type name matching a catalog entry (e.g. 'Text', 'Card', 'Button', 'Column')." + } + }, + "additionalProperties": true + } + } +} diff --git a/agent_sdks/python/src/a2ui/template/template_schema.md b/agent_sdks/python/src/a2ui/template/template_schema.md new file mode 100644 index 000000000..6db4b4815 --- /dev/null +++ b/agent_sdks/python/src/a2ui/template/template_schema.md @@ -0,0 +1,291 @@ +# A2UI Template Schema & Data-Binding Specification + +This document defines the formal schema for A2UI templates and the syntax rules +for `{{}}` dynamic placeholder expressions used in template-based inference. + +## Overview + +A **template** is a reusable A2UI component tree stored as a JSON file. Instead +of asking the LLM to generate a full A2UI message payload from scratch (which is +large and error-prone), the agent: + +1. Presents the LLM with a **catalog of template names** and their `dataSchema`. +2. The LLM returns a compact response: a **`templateName`** + a **`data`** + object. +3. The SDK's `A2uiTemplateManager` **inflates** the template by resolving all + `{{}}` placeholders with the LLM-supplied data. +4. The inflated output is a standard sequence of **v0.9 A2UI messages** + (`createSurface`, `updateComponents`, `updateDataModel`). + +This pattern reduces LLM output size, improves UI consistency, and enables the +[A2UI Composer](https://a2ui-composer.ag-ui.com/) to visually manage template +libraries. + +### Relationship to A2UI Data Binding + +Templates use **two distinct mechanisms** for dynamic values: + +| Mechanism | Syntax | When Resolved | Who Resolves | Purpose | +|-----------|--------|---------------|-------------|---------| +| **Template placeholder** | `{{path.to.field}}` | Server-side, at inflation time | `A2uiTemplateManager` | Inject LLM-provided data into the component tree before sending to client | +| **Data binding** | `{"path": "/field"}` | Client-side, at render time | A2UI renderer | Bind UI components to the live data model for interactive two-way binding | + +Both can coexist in a single template. Template placeholders are resolved first, +producing standard A2UI messages that the client renders as usual. + +--- + +## Template File Format + +Templates are stored as JSON files and validated against +[`schema.json`](schema.json) in this package. + +### Required Fields + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `string` | Unique kebab-case identifier (e.g. `restaurant-card`). This is the value the LLM returns to select the template. | +| `version` | `string` | Semantic version of this template (e.g. `1.0.0`). | +| `catalogId` | `string` | The A2UI catalog ID that this template's components belong to. Used in the generated `createSurface` message. | +| `components` | `array` | Flat, ordered list of v0.9 component descriptors. Must contain a component with `"id": "root"`. | + +### Optional Fields + +| Field | Type | Description | +|-------|------|-------------| +| `description` | `string` | Human-readable summary. Included in LLM prompts. | +| `surfaceId` | `string` | Surface ID for the generated messages. Defaults to the template `name`. | +| `theme` | `object` | Theme parameters passed to `createSurface`. | +| `dataSchema` | `object` | JSON Schema describing the data the LLM must provide. Used for prompt generation and runtime validation. | +| `dataModel` | `object` | Default data model values. May contain `{{}}` placeholders. Becomes the `updateDataModel` message value. | + +### Example Structure + +```json +{ + "name": "restaurant-card", + "description": "Displays a restaurant with name, cuisine, rating, and a booking button.", + "version": "1.0.0", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json", + "dataSchema": { + "type": "object", + "required": ["restaurant"], + "properties": { + "restaurant": { + "type": "object", + "required": ["name", "cuisine", "rating"], + "properties": { + "name": { "type": "string" }, + "cuisine": { "type": "string" }, + "rating": { "type": "number" } + } + } + } + }, + "components": [ + { "id": "root", "component": "Card", "child": "card-content" }, + { "id": "card-content", "component": "Column", "children": ["title", "cuisine-text", "rating-text", "book-btn"] }, + { "id": "title", "component": "Text", "variant": "h2", "text": "{{restaurant.name}}" }, + ... + ], + "dataModel": { + "restaurantName": "{{restaurant.name}}", + "cuisine": "{{restaurant.cuisine}}", + "rating": "{{restaurant.rating}}" + } +} +``` + +--- + +## Placeholder Syntax: `{{}}` + +### Basic Form + +Any string value inside `components` or `dataModel` may contain one or more +placeholder expressions: + +``` +{{path.to.value}} +``` + +Placeholders are delimited by double curly braces (`{{` and `}}`). The content +between the braces is a **dot-separated path** into the LLM-provided `data` +object. + +### Path Resolution Rules + +Note: Keys containing literal dots are not supported in path expressions; dots are always treated as delimiters. + +| Rule | Placeholder | Data | Result | +|------|-------------|------|--------| +| Top-level key | `{{name}}` | `{"name": "Café Lux"}` | `"Café Lux"` | +| Nested key | `{{restaurant.cuisine}}` | `{"restaurant": {"cuisine": "French"}}` | `"French"` | +| Array index | `{{items.0.title}}` | `{"items": [{"title": "Soup"}]}` | `"Soup"` | +| Deep nesting | `{{order.items.2.name}}` | `{"order": {"items": [{}, {}, {"name": "Wine"}]}}` | `"Wine"` | + +### Value Semantics + +The behavior depends on how the placeholder appears in the string: + +| Pattern | Example | Data | Result | Type | +|---------|---------|------|--------|------| +| **Whole-string placeholder** | `"{{rating}}"` | `{"rating": 4.7}` | `4.7` | Preserves original type (number, boolean, array, object) | +| **Inline interpolation** | `"Rating: {{rating}}/5"` | `{"rating": 4.7}` | `"Rating: 4.7/5"` | Always string (values are coerced via `str()`) | +| **Multiple placeholders** | `"{{city}}, {{country}}"` | `{"city": "Paris", "country": "France"}` | `"Paris, France"` | Always string | + +#### Whole-String Placeholder (Type Preservation) + +When a string value consists of **exactly one placeholder with no surrounding +text**, the placeholder resolves to the raw value from the data, preserving its +original JSON type: + +```json +// Template: +{"text": "{{restaurant.name}}"} // → "Café Lux" (string) +{"max": "{{slider.max}}"} // → 100 (number) +{"enableDate": "{{config.dates}}"} // → true (boolean) +``` + +This is critical for non-string component properties like `min`, `max`, +`enableDate`, etc. + +#### Inline Interpolation (String Coercion) + +When the placeholder appears alongside other text, all values are coerced to +strings via `str()`: + +```json +// Template: +{"text": "Rating: {{restaurant.rating}} stars"} +// Data: {"restaurant": {"rating": 4.7}} +// Result: {"text": "Rating: 4.7 stars"} +``` + +### Error Behavior + +| Scenario | Behavior | +|----------|----------| +| Missing key | **Raises `KeyError`** — there are no silent fallbacks or empty-string defaults. | +| Wrong type at path segment | **Raises `TypeError`** — e.g. indexing into a string. | +| Malformed placeholder (unclosed `{{`) | **Treated as literal text** — no substitution occurs. | +| Empty placeholder `{{}}` | **Raises `ValueError`** — path must be non-empty. | + +> **Design rationale:** Failing loudly on missing keys prevents the LLM from +> silently producing broken UIs. The `dataSchema` field allows the SDK to +> validate the LLM's data *before* attempting inflation. + +### Limitations (v1) + +- **No expressions:** `{{price * 1.1}}` is NOT supported. Placeholders are + pure path lookups. +- **No filters:** `{{name | uppercase}}` is NOT supported. +- **No conditionals:** `{{#if available}}` is NOT supported. +- **No default values:** `{{name || "Unknown"}}` is NOT supported. + +These may be considered for future versions if there is community demand. + +--- + +## LLM Response Format + +When an agent uses template-based inference, the LLM returns a compact JSON +response selecting a template and providing data: + +```json +{ + "templateName": "restaurant-card", + "data": { + "restaurant": { + "name": "The French Bistro", + "cuisine": "French", + "rating": 4.7 + } + } +} +``` + +The SDK: +1. Looks up `"restaurant-card"` in the registered template catalog. +2. Validates `data` against the template's `dataSchema`. +3. Inflates all `{{}}` placeholders. +4. Packages the result as a standard v0.9 A2UI message sequence: + - `createSurface` (with `catalogId` and optional `theme`) + - `updateComponents` (the inflated component tree) + - `updateDataModel` (the inflated data model, if present) + +### Multiple Templates + +The LLM may return multiple templates in a single response: + +```json +[ + { + "templateName": "restaurant-card", + "data": { "restaurant": { "name": "Café Lux", "cuisine": "French", "rating": 4.5 } } + }, + { + "templateName": "booking-form", + "data": { "booking": { "restaurantName": "Café Lux", "minDate": "2026-01-15" } } + } +] +``` + +--- + +## Inflated Output Example + +Given the `restaurant-card` template and the data above, the inflated output is: + +```json +[ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "restaurant-card", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "restaurant-card", + "components": [ + { "id": "root", "component": "Card", "child": "card-content" }, + { "id": "card-content", "component": "Column", "children": ["title", "cuisine-text", "rating-text", "book-btn"] }, + { "id": "title", "component": "Text", "variant": "h2", "text": "The French Bistro" }, + { "id": "cuisine-text", "component": "Text", "text": "Cuisine: French" }, + { "id": "rating-text", "component": "Text", "text": "Rating: 4.7 / 5" }, + { "id": "book-btn-text", "component": "Text", "text": "Book a Table" }, + { "id": "book-btn", "component": "Button", "child": "book-btn-text", "variant": "primary", "action": { "event": { "name": "book_restaurant", "context": { "restaurantName": "The French Bistro" } } } } + ] + } + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "restaurant-card", + "path": "/", + "value": { + "restaurantName": "The French Bistro", + "cuisine": "French", + "rating": 4.7 + } + } + } +] +``` + +--- + +## Reference Templates + +The [`examples/`](examples/) directory provides 5 reference templates: + +| File | Template Name | Use Case | +|------|---------------|----------| +| [`restaurant_card.json`](examples/restaurant_card.json) | `restaurant-card` | Restaurant name, cuisine, rating, and booking button | +| [`contact_card.json`](examples/contact_card.json) | `contact-card` | Contact with name, email, phone, and message button | +| [`product_detail.json`](examples/product_detail.json) | `product-detail` | Product title, price, description, and add-to-cart button | +| [`weather_summary.json`](examples/weather_summary.json) | `weather-summary` | Location, temperature, condition, and forecast | +| [`booking_form.json`](examples/booking_form.json) | `booking-form` | Interactive reservation form with date, time, and party size |