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
HotGridA2UI component with a typed API: column labels, optional cell types, rows bound to the data model, and avaluesbinding 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:
npm install handsontable hyperformula @a2ui/react @a2ui/web_core zod@3@a2ui/react declares a peer dependency on Zod 3, so pin that major.
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.
HotGridApiis the contract: a name and a Zod schema.createComponentImplementationpairs 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
DynamicStringListaccepts a literal array or a{ path }into the data model.rowsandvaluesdo 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.setRowsandprops.setValues. Calling a setter writes to the data model, which is how the grid reports its state back.Why
DynamicStringListand notz.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.DynamicStringListis 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,syncToDataModelwrites two things:getSourceData()(the cells with their formulas intact) intorows, andgetData()(the computed values) intovalues. The agent can read whichever it needs.Light and dark mode: the component passes
themeNameat construction and callsuseTheme()whenever the page’s mode changes, switching betweenht-theme-mainandht-theme-main-dark. The mode comes from a smalltheme.tsmodule that reads a?theme=query parameter, apostMessagefrom the embedding page, or the OS preference, in that order, and forces the page’scolor-schemeso 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, setsallowInsertRow,allowRemoveRow,allowInsertColumn, andallowRemoveColumntofalseso 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.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
Catalogis an id, a protocol version, and the components and functions it contains. This one copies everything from the basic catalog that ships with@a2ui/reactand addsHotGrid. The id is a string the agent quotes increateSurface; 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.
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:
MessageProcessoris the A2UI client runtime. It takes the catalogs it may render from and an action handler. Every message from the agent goes throughprocessMessages, which creates or updates surfaces. The page subscribes toonSurfaceCreatedandonSurfaceDeleted, keeps the surface list in React state, and renders each one withA2uiSurface.The action handler receives an
ActionPayloadeach time the user triggers an action in the surface: the action name, the surface and component ids, a timestamp, and the resolvedcontext. The demo’s handler logs the payload, then callsreplyTo(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.
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.
createSurfacenames the surface, picks the catalog by id, and setssendDataModel: 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.updateComponentsis a flat list of components with ids.rootis required. Containers such asColumnandRowreference their children by id, which is why the list can stream incrementally. TheHotGridentry bindsrowsto/rowsandvaluesto/values. TheListat the bottom binds to/logand expands itslog_itemTexttemplate once per entry, so every message the agent writes appears in order under the grid.updateDataModelwrites a value at a path. Here it fills the root withlog(one entry) androws.
The row data holds formulas as strings.
=B1*C1is 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.
Send edits back to the agent
The button’s
actionlists 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
setRowsandsetValues, but nothing leaves the client until an action fires. When the user clicks Send to agent, the client resolves each entry incontextagainst 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,
replyToinagentMessages.tsstands 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
updateDataModelmessages fed to the sameMessageProcessor, which is what a real agent streams back.HotGridapplies the new rows withupdateData()(Example 2 explains how), and HyperFormula recomputes the totals. Each reply also appends a line to the log. The action’scontextincludeslog: { path: '/log' }, so the agent receives the current list and sends it back with one more entry;updateDataModelreplaces 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.
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-rendererand pass it asa2ui={{ 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-sdkpackage generates the messages on the agent side. - Give the agent the catalog schema.
hotCatalog.catalogSchemareturns the JSON Schema for every registered component, includingHotGrid, 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
HotGriddisables 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/reactand@a2ui/web_core.
Related
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
updateDataModelmessages reach the grid and whyupdateData()keeps the instance alive across them. - How an action’s
contextcarries 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
HotGridin a CopilotKit catalog and let a model choose it from a chat prompt. - Add a
checksrule 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.