Skip to content

Agent-driven grid with A2UI

In this tutorial, you will let an AI agent describe a grid in A2UI JSON and have Handsontable render it. You will build a custom A2UI component, HotGrid, that evaluates agent-authored formulas with HyperFormula in the browser, applies the agent’s follow-up updates in place, and sends the user’s edits back to the agent.

View the code · Open in the demo runner

The grid above is not hand-coded in the page. An agent described it in A2UI JSON: a HotGrid component from a custom catalog, bound to /rows in the surface data model. HotGrid renders it with Handsontable and evaluates the = cells with HyperFormula, in the browser. Edit a cell and click Send to agent: the action carries both the formulas (rows) and the computed numbers (values) back to the agent, and the agent answers with new messages. Here it adds a volume-discount line to the rows you sent, so your edits stay, and every reply is appended to the list under the grid. The two panels below the grid show the agent-to-client message stream and the client-to-agent actions.

Note: in this demo the agent’s messages come from a fixed script and a small reply function that stands in for the model, so there is no backend and no API key. The last section shows where a real agent plugs in.

Overview

A2UI is an open protocol, led by Google, in which an agent sends a declarative description of a user interface instead of code. The description names components from a catalog that the client controls, so the agent can only render what you allow. The client keeps a data model per surface, components bind to paths in it, and user actions carry parts of it back to the agent.

The A2UI basic catalog has no table or grid component. This recipe adds one. HotGrid is a catalog component whose props are a Zod schema (the API the agent sees) and whose implementation mounts Handsontable with the HyperFormula engine. The agent writes columns, rows, and = formulas. The grid computes the numbers. The agent never does arithmetic.

Difficulty: Intermediate Time: ~25 minutes Stack: React, @a2ui/react 0.12 (A2UI v0.9 protocol), Handsontable, HyperFormula

What You’ll Build

  • A HotGrid A2UI component with a typed API: column labels, optional cell types, rows bound to the data model, and a values binding for computed results.
  • A catalog that contains the A2UI basic components plus HotGrid, which the agent selects by id.
  • A page that feeds agent messages to the A2UI message processor, renders the surface, forwards every user action to a reply function that stands in for the model, and logs both directions.
  • A second message script in which the agent keeps editing the grid it already rendered, one cell at a time.

Before you begin

This recipe assumes a React app built with Vite. Install the dependencies:

Terminal window
npm install handsontable hyperformula @a2ui/react @a2ui/web_core zod@3

@a2ui/react declares a peer dependency on Zod 3, so pin that major.

  1. Define the HotGrid component

    TypeScript
    import { useEffect, useRef } from 'react';
    import { z } from 'zod';
    import { CommonSchemas } from '@a2ui/web_core/v0_9';
    import { createComponentImplementation } from '@a2ui/react/v0_9';
    import Handsontable from 'handsontable/base';
    import { registerAllModules } from 'handsontable/registry';
    import { HyperFormula } from 'hyperformula';
    import 'handsontable/styles/handsontable.min.css';
    import 'handsontable/styles/ht-theme-main.min.css';
    import { currentMode, onModeChange, type Mode } from './theme';
    registerAllModules();
    type Cell = string | number | boolean | null;
    const themeFor = (mode: Mode) => (mode === 'dark' ? 'ht-theme-main-dark' : 'ht-theme-main');
    const cellSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
    // 1. The component API: what the agent is allowed to send.
    // `rows` and `values` accept a literal 2D array or a `{ path }` binding into the
    // surface data model. Cells that start with "=" are HyperFormula formulas.
    export const HotGridApi = {
    name: 'HotGrid',
    schema: z
    .object({
    accessibility: CommonSchemas.AccessibilityAttributes.optional(),
    weight: z.number().optional(),
    columns: CommonSchemas.DynamicStringList.describe('Column header labels.'),
    columnTypes: CommonSchemas.DynamicStringList.optional().describe(
    'Handsontable cell type per column, in column order: "text", "numeric", "checkbox" or "date". Defaults to text.',
    ),
    rows: z
    .union([z.array(z.array(cellSchema)), CommonSchemas.DataBinding])
    .describe('Source cells. A string starting with "=" is a formula, evaluated by HyperFormula in the browser.'),
    values: z
    .union([z.array(z.array(cellSchema)), CommonSchemas.DataBinding])
    .optional()
    .describe('Computed cell values, written back by the grid after every edit. Bind it to a path so an action can send it to the agent.'),
    readOnly: CommonSchemas.DynamicBoolean.optional(),
    height: z.number().optional().describe('Grid height in pixels. Defaults to 260.'),
    })
    .strict(),
    };
    // 2. The React implementation. The generic binder resolves `{ path }` bindings before
    // render and injects `setRows` / `setValues` setters for two-way binding.
    export const HotGrid = createComponentImplementation(HotGridApi, ({ props }) => {
    const containerRef = useRef<HTMLDivElement>(null);
    const hotRef = useRef<Handsontable | null>(null);
    const propsRef = useRef(props);
    propsRef.current = props;
    const rows = (Array.isArray(props.rows) ? props.rows : []) as Cell[][];
    const rowsKey = JSON.stringify(rows);
    // What the grid last wrote to the data model. When `rows` comes back equal to
    // it, the change is our own echo and the grid must not be reloaded.
    const lastSyncedKey = useRef<string | null>(null);
    // Push the grid's state into the A2UI data model. `rows` keeps the formulas,
    // `values` holds what HyperFormula computed, so the agent can read either.
    const syncToDataModel = () => {
    const hot = hotRef.current;
    if (!hot) return;
    const source = hot.getSourceData() as Cell[][];
    lastSyncedKey.current = JSON.stringify(source);
    propsRef.current.setRows?.(source);
    propsRef.current.setValues?.(hot.getData() as Cell[][]);
    };
    // Mount Handsontable once. The grid owns its data after this.
    useEffect(() => {
    if (!containerRef.current) return;
    const hot = new Handsontable(containerRef.current, {
    data: rows.map((r) => [...r]),
    colHeaders: props.columns,
    columns: props.columnTypes?.map((type) => ({ type: String(type) })),
    rowHeaders: true,
    themeName: themeFor(currentMode()),
    height: props.height ?? 260,
    stretchH: 'all',
    readOnly: props.readOnly ?? false,
    formulas: { engine: HyperFormula },
    // Structural changes are off on purpose. Sorting, filtering and row
    // moving break aggregate formulas such as =SUM(D1:D4) that live in the
    // grid, and inserting or removing rows or columns shifts the ranges those
    // formulas cover. That covers every path: the context menu, a paste that
    // is taller than the grid, and dragging the fill handle past the last row.
    // The user edits values; the agent owns the shape of the sheet and
    // rewrites the formulas when it adds or removes rows.
    columnSorting: false,
    filters: false,
    manualRowMove: false,
    allowInsertRow: false,
    allowRemoveRow: false,
    allowInsertColumn: false,
    allowRemoveColumn: false,
    fillHandle: { autoInsertRow: false },
    contextMenu: ['undo', 'redo'],
    afterChange: (_changes, source) => {
    if (source !== 'loadData' && source !== 'updateData') syncToDataModel();
    },
    licenseKey: 'non-commercial-and-evaluation',
    });
    hotRef.current = hot;
    // Publish the initial computed values so `values` is never empty.
    propsRef.current.setValues?.(hot.getData() as Cell[][]);
    // Follow the page's light/dark mode.
    const unsubscribe = onModeChange((mode) => hot.useTheme(themeFor(mode)));
    return () => {
    unsubscribe();
    hot.destroy();
    hotRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);
    // The agent changed `rows` (a full replacement or a patch to one path):
    // load the new cells into the existing grid instead of rebuilding it.
    useEffect(() => {
    const hot = hotRef.current;
    if (!hot || rowsKey === lastSyncedKey.current) return;
    hot.updateData(rows.map((r) => [...r]));
    propsRef.current.setValues?.(hot.getData() as Cell[][]);
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [rowsKey]);
    useEffect(() => {
    hotRef.current?.updateSettings({
    colHeaders: props.columns,
    columns: props.columnTypes?.map((type) => ({ type: String(type) })),
    readOnly: props.readOnly ?? false,
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [JSON.stringify(props.columns), JSON.stringify(props.columnTypes), props.readOnly]);
    return <div ref={containerRef} />;
    });

    What’s happening: the file has two halves. HotGridApi is the contract: a name and a Zod schema. createComponentImplementation pairs that contract with a React function and returns a component the catalog can register.

    The schema uses the common A2UI types where they exist, so the agent gets the same binding rules as the basic catalog:

    columns: CommonSchemas.DynamicStringList,
    rows: z.union([z.array(z.array(cellSchema)), CommonSchemas.DataBinding]),
    values: z.union([z.array(z.array(cellSchema)), CommonSchemas.DataBinding]).optional(),
    readOnly: CommonSchemas.DynamicBoolean.optional(),

    A DynamicStringList accepts a literal array or a { path } into the data model. rows and values do the same for a 2D array of cells. Before the React function runs, the A2UI generic binder resolves every { path } to its current value, and for each bound prop it injects a setter: props.setRows and props.setValues. Calling a setter writes to the data model, which is how the grid reports its state back.

    Why DynamicStringList and not z.array(z.string()): the binder treats a plain array of strings as a list of child component ids and tries to resolve each one as a component. DynamicStringList is the A2UI type for an ordinary list of strings.

    The React function mounts Handsontable once and never rebuilds it:

    const hot = new Handsontable(containerRef.current, {
    data: rows.map((r) => [...r]),
    colHeaders: props.columns,
    columns: props.columnTypes?.map((type) => ({ type: String(type) })),
    formulas: { engine: HyperFormula },
    columnSorting: false,
    filters: false,
    manualRowMove: false,
    afterChange: (_changes, source) => {
    if (source !== 'loadData' && source !== 'updateData') syncToDataModel();
    },
    // ...
    });

    formulas: { engine: HyperFormula } turns every cell that starts with = into a live formula. After each user edit, syncToDataModel writes two things: getSourceData() (the cells with their formulas intact) into rows, and getData() (the computed values) into values. The agent can read whichever it needs.

    Light and dark mode: the component passes themeName at construction and calls useTheme() whenever the page’s mode changes, switching between ht-theme-main and ht-theme-main-dark. The mode comes from a small theme.ts module that reads a ?theme= query parameter, a postMessage from the embedding page, or the OS preference, in that order, and forces the page’s color-scheme so the A2UI components and the grid agree. demos.handsontable.com’s embed wrapper speaks the same message protocol, which is how the demos on this page follow the docs theme.

    TypeScript
    // Light/dark mode for the page. Three sources, in priority order:
    // 1. `?theme=light|dark` in the URL.
    // 2. A message from the embedding page. demos.handsontable.com's embed wrapper
    // speaks this protocol: the iframe posts `{ source, ready: true }` to its
    // parent and the parent replies with `{ source, mode }`.
    // 3. The OS preference (`prefers-color-scheme`).
    export type Mode = 'light' | 'dark';
    const SOURCE = 'hot-runner-scheme';
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    const listeners = new Set<(mode: Mode) => void>();
    const asMode = (value: unknown): Mode | null => (value === 'light' || value === 'dark' ? value : null);
    let forced: Mode | null = asMode(new URLSearchParams(window.location.search).get('theme'));
    export const currentMode = (): Mode => forced ?? (media.matches ? 'dark' : 'light');
    export const onModeChange = (listener: (mode: Mode) => void) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
    };
    const apply = () => {
    const mode = currentMode();
    // Forces every `light-dark()` color on the page, including the A2UI
    // components, to the chosen side.
    document.documentElement.style.colorScheme = mode;
    document.documentElement.dataset.mode = mode;
    listeners.forEach((listener) => listener(mode));
    };
    media.addEventListener('change', apply);
    window.addEventListener('message', (event: MessageEvent) => {
    const data = event.data as { source?: string; mode?: string } | null;
    if (!data || data.source !== SOURCE || typeof data.mode !== 'string') return;
    forced = asMode(data.mode);
    apply();
    });
    apply();

    Why structural changes are off: sorting, filtering, and row moving operate on visual indexes and rewrite in-grid aggregate formulas such as =SUM(D1:D4), so an agent-authored total would silently change. Inserting or removing rows shifts the ranges those formulas cover, so a line added below the last item would fall outside the total. The component therefore turns those plugins off, sets allowInsertRow, allowRemoveRow, allowInsertColumn, and allowRemoveColumn to false so that a paste taller than the grid or a fill-handle drag past the last row cannot add rows either, and limits the context menu to undo and redo. The user edits values; the agent owns the shape of the sheet and rewrites the formulas when it adds or removes rows, as the reply in Step 5 does.

  2. Register a catalog

    TypeScript
    import { Catalog } from '@a2ui/web_core/v0_9';
    import { basicCatalog } from '@a2ui/react/v0_9';
    import { HotGrid } from './HotGrid';
    // A custom catalog: everything from the A2UI basic catalog, plus HotGrid.
    // The agent selects it by this id in `createSurface`.
    export const CATALOG_ID = 'https://handsontable.com/a2ui/catalogs/hot-grid/v0_9';
    export const hotCatalog = new Catalog(
    CATALOG_ID,
    '0.9',
    [...basicCatalog.components.values(), HotGrid],
    [...basicCatalog.functions.values()],
    basicCatalog.themeSchema,
    );

    What’s happening: a Catalog is an id, a protocol version, and the components and functions it contains. This one copies everything from the basic catalog that ships with @a2ui/react and adds HotGrid. The id is a string the agent quotes in createSurface; by convention it looks like a URL, but nothing is fetched from it.

    Keep the id stable. Once a surface is created with a catalog id, that id is fixed for the surface’s lifetime.

  3. Process messages and render the surface

    TypeScript
    import { useEffect, useRef, useState } from 'react';
    import { createRoot } from 'react-dom/client';
    import { MessageProcessor, type ActionPayload, type ProcessableMessage, type SurfaceModel } from '@a2ui/web_core/v0_9';
    import { A2uiSurface, type ReactComponentImplementation } from '@a2ui/react/v0_9';
    import { hotCatalog } from './catalog';
    import { agentMessages, replyTo } from './agentMessages';
    import './theme';
    import './styles.css';
    const STREAM_DELAY_MS = 900;
    // createSurface + updateComponents + the first updateDataModel.
    const OPENING_MESSAGES = 3;
    const AGENT_THINKING_MS = 1200;
    const App = () => {
    const [actions, setActions] = useState<ActionPayload[]>([]);
    const [sent, setSent] = useState(0);
    // Every message the agent has sent so far, in order, for the stream panel.
    const [stream, setStream] = useState<ProcessableMessage[]>([]);
    const [surfaces, setSurfaces] = useState<SurfaceModel<ReactComponentImplementation>[]>([]);
    // The processor turns agent messages into surface state. The second argument
    // receives every action a user triggers; a real app forwards it to the agent.
    const processorRef = useRef<MessageProcessor<ReactComponentImplementation> | null>(null);
    if (!processorRef.current) {
    processorRef.current = new MessageProcessor<ReactComponentImplementation>([hotCatalog], (action) => {
    setActions((prev) => [action, ...prev]);
    // The agent answers the action. `replyTo` stands in for the model call.
    setTimeout(() => {
    const reply = replyTo(action);
    processorRef.current?.processMessages(reply);
    setStream((prev) => [...prev, ...reply]);
    }, AGENT_THINKING_MS);
    });
    }
    const processor = processorRef.current;
    useEffect(() => {
    const sync = () => setSurfaces(Array.from(processor.model.surfacesMap.values()));
    const created = processor.onSurfaceCreated(sync);
    const deleted = processor.onSurfaceDeleted(sync);
    return () => {
    created.unsubscribe();
    deleted.unsubscribe();
    };
    }, [processor]);
    // Replay the agent's messages. The three that open the surface (create it,
    // define the components, fill the data model) arrive together, the way a
    // model's first response does. Any later messages are paced out so the
    // agent's follow-up edits are visible one at a time.
    useEffect(() => {
    if (sent >= agentMessages.length) return;
    const batch = sent === 0 ? agentMessages.slice(0, OPENING_MESSAGES) : [agentMessages[sent]];
    const timer = setTimeout(() => {
    processor.processMessages(batch);
    setStream((prev) => [...prev, ...batch]);
    setSent(sent + batch.length);
    }, sent === 0 ? 0 : STREAM_DELAY_MS);
    return () => clearTimeout(timer);
    }, [sent, processor]);
    const latestAction = actions[0];
    const latestValues = latestAction?.context?.values as unknown[][] | undefined;
    return (
    <main className="demo">
    <section className="surface-host">
    {surfaces.length === 0 && <p className="muted">Waiting for the agent…</p>}
    {surfaces.map((surface) => (
    <A2uiSurface key={surface.id} surface={surface} />
    ))}
    </section>
    <section className="panels">
    <div className="panel">
    <h3>
    Agent → client <span className="pill">{stream.length} messages</span>
    </h3>
    <ol className="stream">
    {stream.map((m, i) => (
    <li key={i}>
    <details>
    <summary>{Object.keys(m).filter((k) => k !== 'version')[0]}</summary>
    <pre>{JSON.stringify(m, null, 2)}</pre>
    </details>
    </li>
    ))}
    </ol>
    </div>
    <div className="panel">
    <h3>
    Client → agent <span className="pill">{actions.length} actions</span>
    </h3>
    {!latestAction && <p className="muted">Click a button in the surface to dispatch an action.</p>}
    {latestValues && (
    <p>
    Grand total the agent will receive:{' '}
    <strong>{String(latestValues[latestValues.length - 1]?.[3])}</strong>
    </p>
    )}
    <ol className="stream">
    {actions.map((a, i) => (
    <li key={a.timestamp + i}>
    <details>
    <summary>{a.name}</summary>
    <pre>{JSON.stringify(a, null, 2)}</pre>
    </details>
    </li>
    ))}
    </ol>
    </div>
    </section>
    </main>
    );
    };
    createRoot(document.getElementById('root')!).render(<App />);

    What’s happening: MessageProcessor is the A2UI client runtime. It takes the catalogs it may render from and an action handler. Every message from the agent goes through processMessages, which creates or updates surfaces. The page subscribes to onSurfaceCreated and onSurfaceDeleted, keeps the surface list in React state, and renders each one with A2uiSurface.

    The action handler receives an ActionPayload each time the user triggers an action in the surface: the action name, the surface and component ids, a timestamp, and the resolved context. The demo’s handler logs the payload, then calls replyTo(action) after a short delay and feeds the returned messages back into the processor. In a real app the handler forwards the payload to the agent over your transport, and the agent’s response arrives the same way the first messages did.

    The page’s styling is plain CSS in styles.css. The one part worth noting is that it reserves the surface’s height, for the reason the next paragraph gives.

    The replay loop sends the three opening messages together, the way a model’s first response arrives, and paces any later messages out so the agent’s follow-up edits are visible one at a time. Sending the opening messages one by one made the page jump three times as the surface, then the components, then the data appeared; the page also reserves the surface’s height in CSS for the same reason.

  4. What the agent sends

    TypeScript
    import type { ActionPayload, ProcessableMessage } from '@a2ui/web_core/v0_9';
    import { CATALOG_ID } from './catalog';
    export const SURFACE_ID = 'purchase-order-review';
    type Cell = string | number | boolean | null;
    type LogEntry = { text: string };
    // The messages an agent streams to the client to open the surface. In a real
    // app they arrive over A2A, AG-UI, or MCP. Here they are hardcoded so the demo
    // runs without a backend or an API key. The format is A2UI v0.9.
    export const agentMessages: ProcessableMessage[] = [
    {
    version: 'v0.9',
    createSurface: {
    surfaceId: SURFACE_ID,
    catalogId: CATALOG_ID,
    // Ask the client to attach the whole data model to every action it sends.
    sendDataModel: true,
    },
    },
    {
    version: 'v0.9',
    updateComponents: {
    surfaceId: SURFACE_ID,
    components: [
    { id: 'root', component: 'Column', children: ['title', 'intro', 'grid', 'actions', 'log'] },
    { id: 'title', component: 'Text', text: 'Purchase order PO-4471: workstation refresh', variant: 'h3' },
    {
    id: 'intro',
    component: 'Text',
    text: 'Adjust any quantity or unit price. The Total column and the grand total are formulas; HyperFormula recalculates them in your browser.',
    variant: 'body',
    },
    {
    id: 'grid',
    component: 'HotGrid',
    columns: ['Item', 'Qty', 'Unit price', 'Total'],
    columnTypes: ['text', 'numeric', 'numeric', 'numeric'],
    rows: { path: '/rows' },
    values: { path: '/values' },
    height: 230,
    },
    { id: 'actions', component: 'Row', children: ['send_button'], justify: 'end' },
    {
    id: 'send_button',
    component: 'Button',
    child: 'send_label',
    variant: 'primary',
    action: {
    event: {
    name: 'submit_purchase_order',
    // Resolved from the data model when the button is clicked.
    context: { rows: { path: '/rows' }, values: { path: '/values' }, log: { path: '/log' } },
    },
    },
    },
    { id: 'send_label', component: 'Text', text: 'Send to agent' },
    // Every message the agent has written, rendered as a list. `List` expands
    // the `log_item` template once per entry of the array at `/log`.
    { id: 'log', component: 'List', children: { path: '/log', componentId: 'log_item' } },
    { id: 'log_item', component: 'Text', text: { path: 'text' }, variant: 'caption' },
    ],
    },
    },
    {
    version: 'v0.9',
    updateDataModel: {
    surfaceId: SURFACE_ID,
    path: '/',
    value: {
    log: [{ text: 'Agent: here is the order as requested. Change anything, then send it to me for review.' }],
    rows: [
    ['Standing desk', 4, 480, '=B1*C1'],
    ['27" monitor', 8, 219, '=B2*C2'],
    ['Docking station', 8, 145, '=B3*C3'],
    ['Cable kit', 20, 12.5, '=B4*C4'],
    ['Grand total', null, null, '=SUM(D1:D4)'],
    ],
    },
    },
    },
    ];
    // A stand-in for the model: what the agent sends back after it receives an
    // action. It reads the rows the user sent, so the user's edits are kept, and
    // inserts a discount line before the grand total the first time it sees an
    // order above the discount threshold.
    export function replyTo(action: ActionPayload): ProcessableMessage[] {
    const rows = (action.context.rows as Cell[][] | undefined) ?? [];
    const values = (action.context.values as Cell[][] | undefined) ?? [];
    const log = (action.context.log as LogEntry[] | undefined) ?? [];
    const grandTotal = values[values.length - 1]?.[3];
    const hasDiscount = rows.some((r) => String(r[0]).startsWith('Volume discount'));
    if (hasDiscount || typeof grandTotal !== 'number' || grandTotal < 2500) {
    return [
    {
    version: 'v0.9',
    updateDataModel: {
    surfaceId: SURFACE_ID,
    path: '/log',
    value: [...log, { text: `Agent: received. Grand total ${grandTotal}. I will route PO-4471 for approval.` }],
    },
    },
    ];
    }
    const lineItems = rows.slice(0, -1);
    const n = lineItems.length;
    return [
    {
    version: 'v0.9',
    updateDataModel: {
    surfaceId: SURFACE_ID,
    path: '/rows',
    value: [
    ...lineItems,
    ['Volume discount (8%)', null, null, `=-SUM(D1:D${n})*0.08`],
    ['Grand total', null, null, `=SUM(D1:D${n + 1})`],
    ],
    },
    },
    {
    version: 'v0.9',
    updateDataModel: {
    surfaceId: SURFACE_ID,
    path: '/log',
    value: [...log, { text: 'Agent: orders over 2,500 get an 8% volume discount, so I added it as a line. Send again if you change anything.' }],
    },
    },
    ];
    }

    What’s happening: three message types build the surface.

    • createSurface names the surface, picks the catalog by id, and sets sendDataModel: true. With that flag the client attaches the surface’s whole data model to every message it sends to the agent, so the agent always sees the current state.
    • updateComponents is a flat list of components with ids. root is required. Containers such as Column and Row reference their children by id, which is why the list can stream incrementally. The HotGrid entry binds rows to /rows and values to /values. The List at the bottom binds to /log and expands its log_item Text template once per entry, so every message the agent writes appears in order under the grid.
    • updateDataModel writes a value at a path. Here it fills the root with log (one entry) and rows.

    The row data holds formulas as strings. =B1*C1 is a per-row formula and =SUM(D1:D4) an aggregate. HyperFormula evaluates both.

    After these three messages the agent waits. Nothing else happens until the user acts.

  5. Send edits back to the agent

    The button’s action lists the paths whose values should travel with the event:

    action: {
    event: {
    name: 'submit_purchase_order',
    context: { rows: { path: '/rows' }, values: { path: '/values' } },
    },
    },

    What’s happening: in A2UI, two-way binding is local. Typing in the grid updates the data model through setRows and setValues, but nothing leaves the client until an action fires. When the user clicks Send to agent, the client resolves each entry in context against the data model at that moment and sends the result. The agent receives the formulas the user may have edited and the numbers HyperFormula computed from them, in one payload. Edit a quantity in the demo, click the button, and compare the two arrays in the Client → agent panel.

    The agent then answers. In the demo, replyTo in agentMessages.ts stands in for the model:

    export function replyTo(action: ActionPayload): ProcessableMessage[] {
    const rows = (action.context.rows as Cell[][] | undefined) ?? [];
    // ...
    return [
    { version: 'v0.9', updateDataModel: { surfaceId: SURFACE_ID, path: '/rows', value: [...lineItems, discountRow, totalRow] } },
    { version: 'v0.9', updateDataModel: { surfaceId: SURFACE_ID, path: '/log', value: [...log, { text: 'Agent: ...' }] } },
    ];
    }

    It reads the rows from the action, so the user’s edits survive, inserts a volume-discount line before the grand total, and rewrites the total’s formula to cover the new range. The reply is an ordinary list of updateDataModel messages fed to the same MessageProcessor, which is what a real agent streams back. HotGrid applies the new rows with updateData() (Example 2 explains how), and HyperFormula recomputes the totals. Each reply also appends a line to the log. The action’s context includes log: { path: '/log' }, so the agent receives the current list and sends it back with one more entry; updateDataModel replaces the value at a path, so appending means sending the whole array. A second send gets a log entry only.

Example 2: Let the agent keep editing the grid

View the code · Open in the demo runner

Here the agent renders a reorder plan, then keeps editing it. Watch the message stream under the grid: after the first three messages the agent sends only partial updateDataModel messages. One targets a single cell (/rows/2/2), one replaces the row list to add a SKU. The grid applies each one with updateData(), so it is never rebuilt, and HyperFormula recomputes the order quantities and totals. Edit any number and click Approve plan or Ask for changes: the action carries the formulas and the computed values to the agent, which appends its answer to the list.

Note: as in the first demo, the agent is a fixed script plus a reply function, so there is no backend and no API key.

The component, catalog, and page are the same. Only the message script and the reply function change. Here the agent does not wait for the user: it renders the plan, then keeps sending partial updates on its own, and answers Approve plan or Ask for changes with a new line in the log.

TypeScript
import type { ActionPayload, ProcessableMessage } from '@a2ui/web_core/v0_9';
import { CATALOG_ID } from './catalog';
export const SURFACE_ID = 'reorder-plan';
type Cell = string | number | boolean | null;
type LogEntry = { text: string };
// Example 2: the agent keeps editing a grid it already rendered. Every message
// after the first three is a partial `updateDataModel` aimed at one path inside
// the data model, so the grid updates in place instead of being rebuilt.
export const agentMessages: ProcessableMessage[] = [
{
version: 'v0.9',
createSurface: { surfaceId: SURFACE_ID, catalogId: CATALOG_ID, sendDataModel: true },
},
{
version: 'v0.9',
updateComponents: {
surfaceId: SURFACE_ID,
components: [
{ id: 'root', component: 'Column', children: ['title', 'grid', 'actions', 'log'] },
{ id: 'title', component: 'Text', text: 'Reorder plan for next week', variant: 'h3' },
{
id: 'grid',
component: 'HotGrid',
columns: ['SKU', 'On hand', 'Reorder point', 'Order qty', 'Unit cost', 'Line cost'],
columnTypes: ['text', 'numeric', 'numeric', 'numeric', 'numeric', 'numeric'],
rows: { path: '/rows' },
values: { path: '/values' },
height: 230,
},
{ id: 'actions', component: 'Row', children: ['approve', 'reject'], justify: 'end' },
{
id: 'approve',
component: 'Button',
child: 'approve_label',
variant: 'primary',
action: { event: { name: 'approve_plan', context: { rows: { path: '/rows' }, values: { path: '/values' }, log: { path: '/log' } } } },
},
{ id: 'approve_label', component: 'Text', text: 'Approve plan' },
{
id: 'reject',
component: 'Button',
child: 'reject_label',
action: { event: { name: 'reject_plan', context: { values: { path: '/values' }, log: { path: '/log' } } } },
},
{ id: 'reject_label', component: 'Text', text: 'Ask for changes' },
// Every message the agent has written, rendered as a list. `List` expands
// the `log_item` template once per entry of the array at `/log`.
{ id: 'log', component: 'List', children: { path: '/log', componentId: 'log_item' } },
{ id: 'log_item', component: 'Text', text: { path: 'text' }, variant: 'caption' },
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: SURFACE_ID,
path: '/',
value: {
log: [{ text: 'Agent: here is the plan from the current stock levels.' }],
rows: [
['SKU-1041', 12, 40, '=MAX(0,C1*2-B1)', 3.2, '=D1*E1'],
['SKU-2210', 55, 30, '=MAX(0,C2*2-B2)', 11.5, '=D2*E2'],
['SKU-3307', 4, 25, '=MAX(0,C3*2-B3)', 42, '=D3*E3'],
['Total', null, null, '=SUM(D1:D3)', null, '=SUM(F1:F3)'],
],
},
},
},
// The agent changes one cell: the reorder point of the third SKU.
{
version: 'v0.9',
updateDataModel: { surfaceId: SURFACE_ID, path: '/rows/2/2', value: 60 },
},
{
version: 'v0.9',
// Appending to the log: the agent sends the whole array, since A2UI's
// updateDataModel replaces the value at a path.
updateDataModel: {
surfaceId: SURFACE_ID,
path: '/log',
value: [{ text: 'Agent: here is the plan from the current stock levels.' }, { text: 'Agent: SKU-3307 sells out weekly, so I raised its reorder point to 60.' }],
},
},
// The agent inserts a row. The totals row moves down and its formulas are rewritten.
{
version: 'v0.9',
updateDataModel: {
surfaceId: SURFACE_ID,
path: '/rows',
value: [
['SKU-1041', 12, 40, '=MAX(0,C1*2-B1)', 3.2, '=D1*E1'],
['SKU-2210', 55, 30, '=MAX(0,C2*2-B2)', 11.5, '=D2*E2'],
['SKU-3307', 4, 60, '=MAX(0,C3*2-B3)', 42, '=D3*E3'],
['SKU-4102', 0, 15, '=MAX(0,C4*2-B4)', 7.75, '=D4*E4'],
['Total', null, null, '=SUM(D1:D4)', null, '=SUM(F1:F4)'],
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: SURFACE_ID,
path: '/log',
value: [{ text: 'Agent: here is the plan from the current stock levels.' }, { text: 'Agent: SKU-3307 sells out weekly, so I raised its reorder point to 60.' }, { text: 'Agent: SKU-4102 is out of stock, so I added it. Approve, or edit the plan and ask for changes.' }],
},
},
];
// A stand-in for the model: what the agent sends back after an action.
export function replyTo(action: ActionPayload): ProcessableMessage[] {
const values = (action.context.values as Cell[][] | undefined) ?? [];
const log = (action.context.log as LogEntry[] | undefined) ?? [];
const total = values[values.length - 1];
const text =
action.name === 'approve_plan'
? `Agent: approved. I will raise purchase orders for ${values.length - 1} SKUs, ${total?.[3]} units, ${total?.[5]} total cost.`
: 'Agent: understood. Edit the plan in the grid and click Approve plan when it looks right.';
return [{ version: 'v0.9', updateDataModel: { surfaceId: SURFACE_ID, path: '/log', value: [...log, { text }] } }];
}

What’s happening: after the surface exists, the agent sends only partial updateDataModel messages. One targets a single cell:

{ version: 'v0.9', updateDataModel: { surfaceId, path: '/rows/2/2', value: 60 } }

The bound rows prop re-resolves with that one cell changed. HotGrid compares the new rows with what it last wrote to the data model, sees that this change did not come from the grid, and calls updateData() on the existing instance:

useEffect(() => {
const hot = hotRef.current;
if (!hot || rowsKey === lastSyncedKey.current) return;
hot.updateData(rows.map((r) => [...r]));
propsRef.current.setValues?.(hot.getData() as Cell[][]);
}, [rowsKey]);

updateData replaces the cells without destroying the instance, so column widths and the HyperFormula engine survive, and the order quantities and totals recompute from the new reorder point. The later message that adds a row works the same way with a full /rows replacement.

Why compare against lastSyncedKey: the grid’s own setRows call also changes /rows, which re-resolves the prop. Without the guard, every user edit would be echoed back into updateData and interrupt the editor.

Connect a real agent

The demos replay a script. To drive HotGrid from a model, keep the component and catalog, and replace the replay loop with a transport:

  • CopilotKit ships an A2UI renderer that accepts a custom catalog. Define the component with createCatalog(definitions, renderers) from @copilotkit/a2ui-renderer and pass it as a2ui={{ catalog }} on the <CopilotKit> provider. See the CopilotKit A2UI guide.
  • A2A and AG-UI carry A2UI messages between a remote agent and your client. The A2UI documentation has a guide for each transport, and the Python a2ui-agent-sdk package generates the messages on the agent side.
  • Give the agent the catalog schema. hotCatalog.catalogSchema returns the JSON Schema for every registered component, including HotGrid, which is what the model needs in its context to produce valid messages.

The model call and its API key belong on a server. The client only ever receives A2UI JSON, which is data, not code.

Match your design system

An A2UI catalog is meant to reflect the host application’s own components, so an agent-rendered grid should look like every other grid in your product. HotGrid uses the default ht-theme-main theme. Swap the imported theme stylesheet, or register a custom theme that maps your design tokens to Handsontable’s, and every surface the agent creates follows it.

Known limitations

  • Two-way binding is client-local. The agent learns about edits only when an action fires or, with sendDataModel: true, when the client sends its next message.
  • Sorting, filtering, row moving, and user-driven row or column insert or remove, including through paste and the fill handle, all change what aggregate formulas in the grid cover, so HotGrid disables them and leaves row changes to the agent.
  • The A2UI protocol is at v0.9.1, with v1.0 a release candidate. This recipe targets the v0.9 import paths of @a2ui/react and @a2ui/web_core.

What you learned

  • How an A2UI catalog component pairs a Zod schema with a React implementation, and how the generic binder resolves { path } bindings and injects setters.
  • How to give an agent a grid that computes: Handsontable renders, HyperFormula evaluates the agent’s formulas, and the agent never does arithmetic.
  • How updateDataModel messages reach the grid and why updateData() keeps the instance alive across them.
  • How an action’s context carries both formulas and computed values back to the agent.
  • Why sorting, filtering, and user-driven row changes stay off when the grid holds formulas the agent depends on.

Next steps

  • Register HotGrid in a CopilotKit catalog and let a model choose it from a chat prompt.
  • Add a checks rule to the schema so the surface can validate a row before the action fires.
  • Compare with the Liveblocks multiplayer recipe, where the other editor is a person instead of an agent.