Agent-driven grid with CopilotKit
In this tutorial, you will let an AI agent edit a Handsontable DataGrid through CopilotKit tools. You keep an ordinary grid that your app owns. The agent calls functions you register to write columns, data, and = formulas; HyperFormula computes the numbers in the browser; and one undo stack covers both the agent’s edits and the user’s.
The grid above is a normal Handsontable instance. An agent builds the sheet, then edits one cell: it calls createSpreadsheet, then setCells. Below the grid, the panel titled “Agent tool calls” logs each call as it runs, and “What the agent sees” shows the read-only snapshot the agent reads before acting. Edit a cell yourself, then press Agent: undo — the same undo stack reverts your edit and the agent’s alike. Every = cell is computed by HyperFormula; the agent writes the formula, never the number.
Note: in this demo the agent’s calls come from a fixed script 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
CopilotKit connects an agent to a React app through two hooks. useFrontendTool registers a function the agent can call, with a Zod schema for its arguments. useAgentContext hands the agent a read-only view of your state, so it knows what it is editing. The agent runs in CopilotKit’s chat; your code decides what it can read and what it can do.
That makes the grid the opposite of a controlled component. Handsontable owns the data. The agent writes through the live instance, and your React state keeps only a read-only snapshot for the agent to read. Because every change — the agent’s and the user’s — goes through the one instance, Handsontable’s own undo covers both.
The agent writes columns, rows, and = formulas. The grid computes the numbers. The agent never does arithmetic.
This recipe is the tool-calling counterpart to the agent-driven grid with A2UI. There, the agent describes the grid in a declarative protocol and the client renders it. Here, the agent calls functions against a grid your app already owns. What this approach buys you is agent-driven undo: a tool can undo across both the agent’s and the user’s edits.
Difficulty: Intermediate Time: ~25 minutes Stack: React, CopilotKit 1.79 (v2 API), Handsontable, HyperFormula
What you’ll build
- An instance-owned grid component that Handsontable renders and HyperFormula computes.
- A set of grid operations with no knowledge of the agent, so one code path serves both the agent’s tools and any script or button you write.
- A read-only snapshot of the sheet, exposed to the agent with
useAgentContext. - Three agent tools —
createSpreadsheet,setCells, andundo— registered withuseFrontendTool. - A CopilotKit chat that drives all three.
Before you begin
This recipe assumes a React app. Install the dependencies:
npm install handsontable @handsontable/react-wrapper hyperformula @copilotkit/react-core zodCopilotKit’s v2 API lives under the @copilotkit/react-core/v2 entry point. This recipe is written against @copilotkit/react-core 1.79. The provider, the chat component, useFrontendTool, and useAgentContext all import from that entry, and its stylesheet is @copilotkit/react-core/v2/styles.css.
Handsontable touches window, so render the grid on the client only. In the Next.js App Router, mark the file 'use client'; with a framework that renders on the server, load the grid through a client-only dynamic import.
The instance-owned grid
TypeScript import { RefObject, memo } from 'react';import { HotTable, HotTableRef } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';import { HyperFormula } from 'hyperformula';import 'handsontable/styles/handsontable.min.css';import 'handsontable/styles/ht-theme-main.min.css';registerAllModules();export type GridProps = {// Lifted to the parent so the agent tools and the snapshot bridge can reach the live instance// (setDataAtCell, alter, getPlugin('undoRedo'), getSourceData, and so on).hotRef: RefObject<HotTableRef>;// Passed once, at mount, and never fed back. After this the grid owns its data: every later// change goes through the instance, not through this prop. See the memo note below.initialColumns: string[];initialData: (string | number)[][];themeName?: string;// Runs after any change that touches the source data, so the parent can re-snapshot for the agent.onMutate: () => void;};function GridImpl({ hotRef, initialColumns, initialData, themeName = 'ht-theme-main', onMutate }: GridProps) {return (<HotTableref={hotRef}themeName={themeName}// Passed once, then left alone. All mutations go through the instance; the grid is the source// of truth for the data, and React keeps only a read-only snapshot (see the canvas in Step 3).data={initialData}colHeaders={initialColumns.length ? initialColumns : true}rowHeaders={true}height="auto"width="100%"stretchH="all"formulas={{ engine: HyperFormula }}licenseKey="non-commercial-and-evaluation"// Formula-safe interactive features: none of these reorder rows or columns, so the in-grid// formulas and the source-data snapshot stay correct.contextMenu={true}manualColumnResize={true}manualRowResize={true}// columnSorting, filters, dropdownMenu, manualRowMove, and manualColumnMove are off on purpose.// Reordering rewrites in-grid aggregate formulas: a sort turns a totals row's =SUM(D1:D3) into// =SUM(#REF!), and a manual row move silently mangles the range. See "Known limitations".afterChange={(_changes, source) => {if (source !== 'loadData') onMutate();}}afterCreateRow={onMutate}afterRemoveRow={onMutate}afterCreateCol={onMutate}afterRemoveCol={onMutate}afterUpdateData={onMutate}/>);}// Memoized with referentially stable props so the parent's snapshot re-renders never re-render the// grid. Without the memo, @handsontable/react-wrapper would re-apply the data prop through// updateSettings on every render, overwriting the instance-owned data and resetting the undo// stack. Keep the initial data in a ref and the handlers in useCallback so these props never change.export const Grid = memo(GridImpl);What’s happening: the grid takes its data once, through
initialData, and never again. After mount, every change goes through the live instance, which the parent reaches withhotRef.formulas: { engine: HyperFormula }turns every cell that starts with=into a live formula.afterChangeand the create and remove hooks callonMutate, so the parent can refresh its snapshot after any change, including one the user makes by hand.Gridis wrapped inReact.memo, and the memo is a correctness requirement, not an optimization.@handsontable/react-wrapperre-applies thedataprop throughupdateSettingson every render, which would overwrite the instance-owned data and reset the undo stack. Keeping the component from re-rendering is how the grid gets to own its data while you still use the official React wrapper, so pass it only referentially stable props: auseReffor the initial data, anduseCallbackhandlers.Sorting, filtering, and row or column moving stay off. Those plugins work on visual indexes and rewrite the in-grid aggregate formulas — a sort turns a totals row’s
=SUM(D1:D3)into=SUM(#REF!), and a manual row move silently mangles the range. See Known limitations.Grid operations the agent and your app share
TypeScript import Handsontable from 'handsontable/base';// Grid operations, with no knowledge of any agent framework. The scripted driver in the// demo and the CopilotKit tools in Step 4 both call these functions, so a hand-written// script and the agent drive the grid through one code path. Every function writes through// the live instance, which is what keeps undo working across agent and user edits alike.export type Cell = string | number;// A model sometimes wraps a cell value in an object ({ value } or { formula }) even when the// schema asks for a string or a number. Coerce anything to a primitive before it reaches a cell.export function toCell(value: unknown): Cell {if (value == null) return '';if (typeof value === 'number' || typeof value === 'string') return value;if (typeof value === 'object') {const object = value as Record<string, unknown>;const picked = object.formula ?? object.value ?? object.text;if (typeof picked === 'number' || typeof picked === 'string') return picked;}return String(value);}export function normalizeRows(rows: unknown): Cell[][] {if (!Array.isArray(rows)) return [];return rows.map((row) => (Array.isArray(row) ? row.map(toCell) : [toCell(row)]));}// Build or replace the whole sheet. Clone the rows first: Handsontable binds to the array by// reference, so a shared array would let later edits mutate the caller's copy.export function createSpreadsheet(hot: Handsontable.Core, columns: string[], rows: unknown): void {hot.updateData(structuredClone(normalizeRows(rows)));hot.updateSettings({ colHeaders: columns.length ? columns : true });}// Set one or more cells. The agent addresses cells by physical (source) coordinates, so translate// each to a visual coordinate and write with setDataAtCell. That path recomputes HyperFormula and// records the change for undo; setSourceDataAtCell does neither, so it is not used here. Grow the// grid first when the agent addresses a cell past the current bounds.export function setCells(hot: Handsontable.Core, cells: { row: number; col: number; value: Cell }[]): void {if (cells.length === 0) return;hot.batch(() => {const maxRow = Math.max(...cells.map((cell) => cell.row));const maxCol = Math.max(...cells.map((cell) => cell.col));const sourceCols = hot.countSourceCols();if (maxCol >= sourceCols) {const add = maxCol - sourceCols + 1;if (sourceCols === 0) hot.alter('insert_col_start', 0, add);else hot.alter('insert_col_end', hot.countCols() - 1, add);}const sourceRows = hot.countSourceRows();if (maxRow >= sourceRows) {const add = maxRow - sourceRows + 1;if (sourceRows === 0) hot.alter('insert_row_above', 0, add);else hot.alter('insert_row_below', hot.countRows() - 1, add);}cells.forEach((cell) => {const visualRow = hot.toVisualRow(cell.row);const visualCol = hot.toVisualColumn(cell.col);if (typeof visualRow === 'number' && visualRow >= 0 && typeof visualCol === 'number' && visualCol >= 0) {hot.setDataAtCell(visualRow, visualCol, toCell(cell.value));}});});}// Append a labeled column to the right of the sheet.export function addColumn(hot: Handsontable.Core, name: string): void {const headers = (hot.getColHeader() as (string | number)[]).map(String);hot.alter('insert_col_end', hot.countCols() - 1, 1);hot.updateSettings({ colHeaders: [...headers, name] });}// Undo the last change. It spans both agent and user edits, because every change goes through the// instance. undo and redo live on the UndoRedo plugin, not on the core -- hot.undo() does not exist.export function undo(hot: Handsontable.Core): boolean {const plugin = hot.getPlugin('undoRedo');if (!plugin.isUndoAvailable()) return false;plugin.undo();return true;}What’s happening: these functions take a Handsontable instance and change it. They know nothing about CopilotKit, so the demo’s script and the agent’s tools call the same code. Three details matter:
setDataAtCell, notsetSourceDataAtCell. The source-data path skips the edit pipeline, so HyperFormula would not recompute and the change would not enter the undo stack.setDataAtCelltakes visual coordinates, so each physical coordinate the agent sends is translated withtoVisualRowandtoVisualColumn.alteractions are directional. The valid actions areinsert_row_above,insert_row_below,insert_col_start,insert_col_end,remove_row, andremove_col. There is no bareinsert_row.undolives on the plugin. Callhot.getPlugin('undoRedo').undo(). There is nohot.undo()on the typed core.
A model sometimes wraps a value in an object even when the schema asks for a string or a number, so
toCellcoerces anything to a primitive before it reaches a cell.Expose the sheet to the agent
Hold a read-only snapshot in React state and refresh it on every change.
useAgentContexthands that snapshot to the agent, with a description of what it is and how to address it:const [mirror, setMirror] = useState<{ columns: string[]; data: Cell[][] }>({ columns: [], data: [] });const getHot = useCallback(() => hotRef.current?.hotInstance ?? null, []);const snapshot = useCallback(() => {const hot = getHot();if (!hot) return;setMirror({columns: (hot.getColHeader() as (string | number)[]).map(String),data: structuredClone(hot.getSourceData()) as Cell[][],});}, [getHot]);// Take the first snapshot after mount. The initial load fires afterChange with source// 'loadData', which Grid skips, so nothing else fills the mirror until the first change.useEffect(() => {snapshot();}, [snapshot]);const context = useMemo(() => ({description:"The spreadsheet the user sees. 'columns' are the headers, 'data' is the rows; a cell " +"starting with '=' is a live formula. Tools take 0-based row and col indices into the data " +'(first data row = 0). Formulas use A1 style, where the first data row is row 1. Write ' +'formulas as strings that start with =, and never compute the numbers yourself.',value: mirror,}),[mirror],);useAgentContext(context);What’s happening: the snapshot reads
getSourceData, so the agent sees the formulas the cells hold, not only the computed values. PassuseAgentContexta memoized value object. A fresh object literal on every render would make its effect run in a loop. Becausesnapshotruns on every change, the agent’s view stays current after a user edit as well as an agent edit.The
useEffectmatters: without it, the agent sees an empty sheet until the first change. The initial load is not a “change” — the onlyafterChangeit fires has source'loadData', whichGridskips on purpose, andafterUpdateDatadoes not fire on first load. The parent’s effect runs afterHotTablehas created its instance, sogetHot()returns the live grid there.Give the agent tools
Each tool pairs a Zod schema with a handler that calls one of the operations from Step 2, then refreshes the snapshot:
useFrontendTool({name: 'createSpreadsheet',description: 'Create or replace the whole sheet in one call, formulas included. Replacing the sheet clears the undo history.',parameters: z.object({columns: z.array(z.string()).describe('Column header labels'),rows: z.array(z.array(z.union([z.string(), z.number()]))).describe("Rows aligned to columns; put '='-prefixed formula strings wherever a value is computed",),}),handler: async ({ columns, rows }) => {const hot = getHot();if (!hot) return 'The grid is not ready yet';createSpreadsheet(hot, columns, rows);snapshot();return `Built a ${columns.length}-column sheet`;},}, [getHot, snapshot]);useFrontendTool({name: 'setCells',description: "Set one or more cells. A value may be a number, text, or a '='-prefixed formula.",parameters: z.object({cells: z.array(z.object({ row: z.number(), col: z.number(), value: z.union([z.string(), z.number()]) }),),}),handler: async ({ cells }) => {const hot = getHot();if (!hot) return 'The grid is not ready yet';setCells(hot, cells);snapshot();return `Updated ${cells.length} cell(s)`;},}, [getHot, snapshot]);useFrontendTool({name: 'undo',description: 'Undo the last change. It spans both agent and user edits.',parameters: z.object({}),handler: async () => {const hot = getHot();if (!hot) return 'The grid is not ready yet';const reverted = undo(hot);snapshot();return reverted ? 'Undid the last change' : 'Nothing to undo';},}, [getHot, snapshot]);What’s happening: the handler returns a short string, which the agent reads as the result of its call. The schema is the contract the model fills, so its field descriptions double as instructions.
Keep each handler idempotent, so a repeated call does no harm.
createSpreadsheetandsetCellswrite the same result twice over;undodoes not, because a second call reverts a second step. If your setup ever dispatches a tool call more than once, guardundo— ignore a repeat that arrives within a second of the previous one, or dedupe by the tool-call id.Wire it together
The canvas holds the
hotRef, registers the context and the tools, renders the grid, and adds the chat:'use client';import { useCallback, useMemo, useRef, useState } from 'react';import { CopilotSidebar, useAgentContext, useFrontendTool } from '@copilotkit/react-core/v2';import { type HotTableRef } from '@handsontable/react-wrapper';import { z } from 'zod';import { Grid } from './Grid';import { createSpreadsheet, setCells, undo, type Cell } from './operations';export function SpreadsheetCanvas() {const hotRef = useRef<HotTableRef>(null);// A blank starter, passed once, so the instance exists before the agent builds anything.const initialData = useRef<Cell[][]>(Array.from({ length: 5 }, () => Array(5).fill('')));const initialColumns = useRef<string[]>([]);// ...snapshot, context, and the three tools from Steps 3 and 4...return (<><GridhotRef={hotRef}initialColumns={initialColumns.current}initialData={initialData.current}onMutate={snapshot}/><CopilotSidebar /></>);}Wrap the app in the
CopilotKitprovider once, above the canvas. On Copilot Cloud a public key is all it needs, and there is no self-hosted route to run:app/layout.tsx import { CopilotKit } from '@copilotkit/react-core/v2';import '@copilotkit/react-core/v2/styles.css';export default function RootLayout({ children }: { children: React.ReactNode }) {return (<html lang="en"><body><CopilotKit publicApiKey={process.env.NEXT_PUBLIC_COPILOTKIT_API_KEY!}>{children}</CopilotKit></body></html>);}Ask the chat for “a three-month budget with income, expenses, and a net row”, then “raise February groceries to 900”, then “undo that”. The agent calls
createSpreadsheet, thensetCells, thenundo, and HyperFormula recomputes after each.
Connect a real agent
The demo runs a script in place of the model. To drive the grid from a real agent, keep the grid, the operations, the context, and the tools, and give the provider a backend:
- Copilot Cloud is the shortest path. Create a free key at cloud.copilotkit.ai, set it as
publicApiKeyon the provider, and pick a model in the dashboard. The tools and context you registered are sent to the model as-is. - A self-hosted runtime keeps the model call on your own server. Point the provider at your runtime endpoint instead of a public key, and hold the model’s API key there.
Either way, the model call and its key belong on a server or on Cloud, never in the client. The browser only decides what the agent may read and do, through the context and the tools.
Match your design system
The grid uses the default ht-theme-main theme, which adapts to light and dark. Swap the imported theme stylesheet, or register a custom theme that maps your design tokens to Handsontable’s, and the agent’s grid looks like every other grid in your product.
CopilotKit’s chat is themed separately. Its v2 stylesheet ships light and dark styles; import it once, as the provider snippet does.
Known limitations
- The agent reads the sheet through the snapshot that
useAgentContextexposes. It learns about a user’s edit on its next turn, once the snapshot has refreshed, not the instant the user types. - Sorting, filtering, and row or column moving all change what the in-grid aggregate formulas cover, so this recipe leaves them off and lets the agent own the shape of the sheet. Per-row relative formulas such as
=B1-C1survive a reorder; ranges and totals such as=SUM(D1:D3)do not. For column totals that survive sorting, compute them with theColumnSummaryplugin instead of an in-grid=SUMcell. createSpreadsheetreplaces the sheet throughupdateData, which clears the undo history — the recorded steps describe the dataset it replaced. Undo spans agent and user edits within a sheet, but it cannot restore the sheet thatcreateSpreadsheetoverwrote.- Keep tool handlers idempotent, and guard a non-idempotent action such as
undo, so a tool call that arrives twice cannot revert two steps.
Related
What you learned
- How
useFrontendToolregisters a function the agent can call, and howuseAgentContextgives the agent a read-only view of the grid. - How to let Handsontable own its data and keep only a read-only snapshot in React, so the agent’s edits and the user’s share one undo stack.
- How to give an agent a grid that computes: the agent writes formulas, HyperFormula evaluates them, and the agent never does arithmetic.
- Why
setDataAtCellkeeps formulas and undo working wheresetSourceDataAtCellwould not, and why the grid must be memoized. - Why sorting, filtering, and row moving stay off when the grid holds formulas the agent depends on.
Next steps
- Add a tool for a structural edit, such as
addColumnorinsertRow, by copying theuseFrontendToolpattern and calling one more operation. - Expose the selected cell in the context, so the user can ask the agent about “this cell”.
- Compare with the agent-driven grid with A2UI, where the agent describes the grid instead of calling tools against it.