A2UI Integration

Use @json-render/core to support A2UI natively.

Concept: This page demonstrates how json-render can support A2UI. The examples are illustrative and may require adaptation for production use.

Native A2UI Support

@json-render/core is schema-agnostic. Define a catalog that matches A2UI's format and build a renderer that understands it - no conversion layer needed.

Example A2UI Message

A2UI uses an adjacency list model - a flat list of components with ID references. This makes it easy to patch individual components:

{
  "surfaceUpdate": {
    "surfaceId": "main",
    "components": [
      {
        "id": "header",
        "component": {
          "Text": {
            "text": {"literalString": "Book Your Table"},
            "usageHint": "h1"
          }
        }
      },
      {
        "id": "date-picker",
        "component": {
          "DateTimeInput": {
            "label": {"literalString": "Select Date"},
            "value": {"path": "/reservation/date"},
            "enableDate": true
          }
        }
      },
      {
        "id": "submit-btn",
        "component": {
          "Button": {
            "child": "submit-text",
            "action": {"name": "confirm_booking"}
          }
        }
      },
      {
        "id": "submit-text",
        "component": {
          "Text": {"text": {"literalString": "Confirm Reservation"}}
        }
      }
    ]
  }
}

Define the A2UI Catalog

import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';

// A2UI BoundValue schema
const BoundString = z.object({
  literalString: z.string().optional(),
  path: z.string().optional(),
}).refine(d => d.literalString || d.path);

// A2UI children schema
const Children = z.object({
  explicitList: z.array(z.string()).optional(),
  template: z.object({
    dataBinding: z.string(),
    componentId: z.string(),
  }).optional(),
}).refine(d => d.explicitList || d.template);

export const a2uiCatalog = defineCatalog(schema, {
  components: {
    Text: {
      description: 'Displays text content',
      props: z.object({
        text: BoundString,
        usageHint: z.enum(['h1', 'h2', 'h3', 'body', 'caption']).optional(),
      }),
    },
    Button: {
      description: 'Interactive button',
      props: z.object({
        child: z.string(),
        action: z.object({
          name: z.string(),
          context: z.array(z.object({
            key: z.string(),
            value: BoundString,
          })).optional(),
        }).optional(),
      }),
    },
    DateTimeInput: {
      description: 'Date/time picker',
      props: z.object({
        label: BoundString.optional(),
        value: BoundString.optional(),
        enableDate: z.boolean().optional(),
        enableTime: z.boolean().optional(),
      }),
    },
    Column: {
      description: 'Vertical layout',
      props: z.object({
        children: Children,
      }),
    },
    Row: {
      description: 'Horizontal layout',
      props: z.object({
        children: Children,
      }),
    },
    // Add more A2UI standard components...
  },
});

Define the A2UI Schema

Define the schema for A2UI message types:

import { z } from 'zod';

// Component instance in the adjacency list
const A2UIComponent = z.object({
  id: z.string(),
  component: z.record(z.record(z.unknown())),
});

// Surface update message
const SurfaceUpdate = z.object({
  surfaceId: z.string().optional(),
  components: z.array(A2UIComponent),
});

// State model update message
const StateModelUpdate = z.object({
  surfaceId: z.string().optional(),
  path: z.string().optional(),
  contents: z.array(z.object({
    key: z.string(),
    valueString: z.string().optional(),
    valueNumber: z.number().optional(),
    valueBoolean: z.boolean().optional(),
    valueMap: z.array(z.unknown()).optional(),
  })),
});

// Begin rendering message
const BeginRendering = z.object({
  surfaceId: z.string().optional(),
  root: z.string(),
  catalogId: z.string().optional(),
});

// Complete A2UI message schema
export const A2UIMessage = z.object({
  surfaceUpdate: SurfaceUpdate.optional(),
  dataModelUpdate: StateModelUpdate.optional(),
  beginRendering: BeginRendering.optional(),
  deleteSurface: z.object({ surfaceId: z.string() }).optional(),
});

Build an A2UI Renderer

Create a renderer that processes the A2UI adjacency list format:

import { a2uiCatalog } from './catalog';

// Component registry
const components = {
  Text: ({ text, usageHint }) => {
    const Tag = usageHint?.startsWith('h') ? usageHint : 'p';
    return <Tag>{text}</Tag>;
  },
  Button: ({ children, action, onAction }) => (
    <button onClick={() => onAction?.(action)}>{children}</button>
  ),
  DateTimeInput: ({ label, value, onChange }) => (
    <label>
      {label}
      <input type="date" value={value} onChange={e => onChange?.(e.target.value)} />
    </label>
  ),
  Column: ({ children }) => <div className="flex flex-col gap-2">{children}</div>,
  Row: ({ children }) => <div className="flex gap-2">{children}</div>,
};

// Render A2UI surface
export function renderA2UI(
  componentMap: Map<string, any>,
  dataModel: Record<string, any>,
  rootId: string,
  onAction?: (action: any) => void
) {
  function resolveBoundValue(bound: any) {
    if (!bound) return undefined;
    if (bound.literalString) return bound.literalString;
    if (bound.path) {
      const parts = bound.path.replace(/^\//, '').split('/');
      let value = dataModel;
      for (const p of parts) value = value?.[p];
      return value;
    }
  }

  function render(id: string): React.ReactNode {
    const comp = componentMap.get(id);
    if (!comp) return null;

    const [type, props] = Object.entries(comp.component)[0];
    const Component = components[type];
    if (!Component) return null;

    // Resolve props
    const resolved: any = {};
    for (const [key, val] of Object.entries(props as any)) {
      if (key === 'child') {
        resolved.children = render(val as string);
      } else if (key === 'children' && val?.explicitList) {
        resolved.children = val.explicitList.map(render);
      } else if (val && typeof val === 'object' && ('literalString' in val || 'path' in val)) {
        resolved[key] = resolveBoundValue(val);
      } else {
        resolved[key] = val;
      }
    }

    return <Component key={id} {...resolved} onAction={onAction} />;
  }

  return render(rootId);
}

Usage

const [components] = useState(() => new Map());
const [dataModel, setDataModel] = useState({});
const [rootId, setRootId] = useState<string | null>(null);

// Process A2UI messages
function handleMessage(msg: any) {
  if (msg.surfaceUpdate) {
    for (const comp of msg.surfaceUpdate.components) {
      components.set(comp.id, comp);
    }
  }
  if (msg.dataModelUpdate) {
    setDataModel(prev => ({ ...prev, ...msg.dataModelUpdate.contents }));
  }
  if (msg.beginRendering) {
    setRootId(msg.beginRendering.root);
  }
}

// Render
{rootId && renderA2UI(components, dataModel, rootId, handleAction)}

Next

Learn about Adaptive Cards integration for another UI protocol.