Bring Your Own AI
| Type | DataEditorChat<TRow> |
When provided, the editor footer gains an AI button. It opens the chat over the grid, with a log of past requests and a prompt field. Users type natural language (“fix all emails”, “fill empty roles with Viewer”) and your AI turns those requests into changes the SDK applies to the data.
You bring the AI integration. The SDK gives you context about the current dataset and applies your streamed response.
How it works
Section titled “How it works”- The user types a message.
- The SDK calls your
onMessage(context)with the prompt and dataset context (columns, a row sample, error summary, counts). - You build a prompt, call your LLM, and stream chunks back to the SDK.
- The SDK applies
opsandrows, groups each chunk into a single undo step, and marks the request in the log as applied, failed or cancelled.
The chat prop
Section titled “The chat prop”type DataEditorChat<TRow> = { sampleSize?: number; onMessage: (context: ChatContext<TRow>) => AsyncIterable<ChatResponseChunk<TRow>>; onCancel?: () => void;};| Field | Purpose |
|---|---|
sampleSize |
How many rows to include in the context sample. |
onMessage |
Handles the user’s message. Receives dataset context, streams back response chunks. |
onCancel |
Called when the user cancels a pending request. |
sampleSize
Section titled “sampleSize”| Type | number |
How many rows to include in context.sample. The SDK picks a representative slice and prioritizes rows with validation errors. When omitted, the SDK chooses.
Raise it when your LLM benefits from broader coverage. Lower it to save tokens. The sample exists so the LLM can learn the shape of the data. For full-dataset reads, call context.getRows().
onMessage
Section titled “onMessage”| Type | (context: ChatContext<TRow>) => AsyncIterable<ChatResponseChunk<TRow>> |
Called when the user sends a message. Returns an async iterable of response chunks. The SDK streams them into the UI as they arrive.
The request is marked as applied when the iterable finishes, and as failed when onMessage throws. Throw on a non-OK HTTP response or a broken stream. A message chunk describing the error still counts as success.
onMessage is where the integration work lives.
- Read the context the SDK gives you.
- Write a transformation function the SDK can apply per row.
- Write a prompt that makes your LLM produce that function.
1. The context you receive
Section titled “1. The context you receive”type ChatContext<TRow> = { message: string; columns: DataEditorColumn[]; primaryKey: keyof TRow | readonly (keyof TRow)[]; totalRowCount: number; filteredRowCount: number; sample: ChatRow<TRow>[]; errorSummary: ChatErrorSummary[]; getRows: () => ChatRow<TRow>[]; signal: AbortSignal;};| Field | What it is | What to do with it |
|---|---|---|
message |
The user’s chat prompt. | Pass it through to your LLM as the user message. |
columns |
Full column definitions, including id, editor, validators. |
Serialize a compact schema into your prompt so the LLM knows the field shape. |
primaryKey |
The row identifier field, or the list of fields that identify a row together. | Only needed if you emit rows chunks (the SDK matches rows by this key). A row you return matches only when every field in the list matches. |
totalRowCount |
Rows in the dataset. | Lets the LLM reason about scope. |
filteredRowCount |
Rows in the current filtered view. | Ops only apply to these. Tell the LLM “you are looking at N of M rows” when it matters. |
sample |
A representative slice, weighted toward rows with errors. Size controlled by sampleSize. |
Send to the LLM as example data. Each item is a ChatRow. |
errorSummary |
Aggregated error counts grouped by field and message, with a few example values. | The field that answers “fix all the bad emails” requests. Send the whole thing. |
getRows |
Function that returns every row, status and errors included. | Use for full-dataset operations (counting, statistics). Avoid sending the result wholesale to an LLM. |
signal |
An AbortSignal the SDK aborts when the user cancels the request. |
Pass it to fetch. The SDK stops reading your stream on its own; the signal is what stops the network call. |
ChatRow
Section titled “ChatRow”type ChatRow<TRow> = { data: TRow; status: "new" | "edited" | "original"; errors: Record<string, string[]>; source: string;};data is the row keyed by column ID. errors is keyed by column ID with one or more validation messages per field. status tells you whether the row is freshly added, edited since import, or unchanged. source is which import source the row came from.
ChatErrorSummary
Section titled “ChatErrorSummary”type ChatErrorSummary = { field: string; message: string; count: number; examples: string[];};One entry per (field, message) pair across the current view. count is how many rows hit it. examples is a short list of values that triggered the error.
errorSummary lets the LLM see what is wrong without receiving every bad row. A prompt like “fix all the email errors” works because the LLM sees { field: "email", message: "Invalid email", count: 47, examples: ["jane@", "bob..com"] } and infers the fix pattern.
2. Writing the transformation function
Section titled “2. Writing the transformation function”The SDK applies changes by running a function you provide, once per row in the current filtered view. You send the function as a string of JavaScript source inside an ops chunk; the SDK runs it.
type ChatOp = | { action: "edit"; fn: string } // (r, ctx) => void | { action: "delete"; fn: string }; // (r, ctx) => booleanaction |
Signature | Behavior |
|---|---|---|
"edit" |
(r, ctx) => void |
Mutate r in place. Changed fields become column deltas. Rows with no changes are no-ops. |
"delete" |
(r, ctx) => boolean |
Truthy flags the row for soft deletion. Subsequent ops skip this row. |
Rules the function must follow
Section titled “Rules the function must follow”- Use exact column IDs.
ris keyed by theidof each column in your schema, so a column with the idfirstNameis read asr["firstName"]. A title or a shortened name reads asundefined. ctx.opts[fieldId]is aSet<string>of allowed values for columns witheditor.type === "select". Usectx.opts.country.has(value), never inline the option list into the function source.- Per-row errors are silent. When the function throws on a row, the SDK skips that row and the rest continue, so one malformed row never aborts the batch. A buggy function therefore no-ops without a sign. Always include a
messagechunk that says what you intended, so the user can compare it to what they see in the grid. - A typed column keeps its own shape. A value written into a
numbercolumn is stored as its digits, so returning a JavaScript number is safe and5e20 * 2lands as1000000000000000000000. A cell that reads back as the value it already held is left alone and stays out of the undo step. The same holds for a row you return through arowschunk.
How ops compose
Section titled “How ops compose”- Multiple ops in one
opschunk run in array order, per row. Use multiple ops when the request has independent steps (“set country to ‘US’ for empty rows, then delete inactive users”). deleteis terminal for the row. Once an op flags a row, later ops in the same chunk do not see that row.- One
opschunk produces one undo step. Splitting a single user request across multiple chunks splits its undo.
Worked examples
Section titled “Worked examples”// Normalize emails: trim whitespace, lowercase, append ".com" if missing TLD.{ action: "edit", fn: `(r) => { if (typeof r.email !== "string") return; let v = r.email.trim().toLowerCase(); if (v && !/\.[a-z]{2,}$/.test(v)) v += ".com"; r.email = v; }`}
// Delete rows where country is not in the allowed set.{ action: "delete", fn: `(r, ctx) => !ctx.opts.country.has(r.country)`}3. Writing a prompt
Section titled “3. Writing a prompt”Your prompt has two parts: a system prompt that pins down the output contract, and a user message built from the request and the dataset context.
What your system prompt must say
Section titled “What your system prompt must say”The LLM has no idea what shape the SDK expects. Your system prompt has to spell it out:
- Output a JSON object with optional
opsand optionalmessage. Nothing else at the top level. - Each op is
{ "action": "edit" | "delete", "fn": "<JavaScript function source>" }. ris keyed by exact column IDs from the schema in the user message.editfunctions mutaterin place and return nothing.deletefunctions return a boolean.- For
selectcolumns, usectx.opts[fieldId].has(value); never inline the option list. - Skip values you cannot fix. Do not clear or guess unless the user asked for that.
What goes in the user message
Section titled “What goes in the user message”Build the user message from ChatContext:
- A compact schema: per column,
idpluseditor.type, and options for selects. - The
errorSummaryrendered as a short list:field: "message" (count×) e.g. "example1", "example2". - A trimmed
sample, holdingdataplus an_errorsfield for rows that have validation errors. - The user’s
messageas the final line.
Keep it compact. The LLM does not need a row’s status or source to write a transformation; it needs the schema and a few representative rows.
Skeleton system prompt
Section titled “Skeleton system prompt”This prompt is enough for a working integration. Extend it with fix patterns from your own domain, such as date formats and phone normalization.
You generate per-row transformations for spreadsheet data.
Output a single JSON object: { "ops": [ ... ], "message": "short summary" }
Both fields are optional. Omit "ops" for non-data requests (questions, clarifications).
Each op has one of two shapes: { "action": "edit", "fn": "(r, ctx) => { ... }" } // mutate r in place { "action": "delete", "fn": "(r, ctx) => <boolean>" } // truthy flags the row
Rules:- r is keyed by EXACT column IDs from the schema below. Use r["id"]; do not shorten or rename.- edit functions mutate r in place. Do not return.- delete functions return a boolean. True means flag the row for deletion.- ctx.opts[fieldId] is a Set of allowed values for select columns. Use .has(value). Never inline the option list.- Skip values you cannot confidently fix. Do not clear or guess.
Examples:
Request: "Lowercase all emails." → { "ops": [ { "action": "edit", "fn": "(r) => { if (typeof r.email === 'string') r.email = r.email.toLowerCase(); }" } ], "message": "Lowercased all email values." }
Request: "Remove rows where country is not in the allowed list." → { "ops": [ { "action": "delete", "fn": "(r, ctx) => !ctx.opts.country.has(r.country)" } ], "message": "Flagged rows with an unrecognized country for deletion." }Reliability
Section titled “Reliability”If your model supports JSON mode or structured output (DeepSeek, OpenAI, Anthropic with tool use, Gemini), enable it. It removes the failure mode where the model wraps its JSON in prose, code fences, or commentary.
4. What you stream back
Section titled “4. What you stream back”onMessage returns an async iterable of chunks. The SDK consumes them in order.
type ChatResponseChunk<TRow> = | { type: "status"; content: string } | { type: "message"; content: string } | { type: "rows"; content: TRow[] } | { type: "ops"; content: ChatOp[] };| Type | When to use it |
|---|---|
status |
Progress text for the running request (“Analyzing 500 rows…”). The SDK accepts and stores it; the chat does not display it yet. |
message |
Your model’s reply in words. The SDK accepts and stores it; the chat does not display it yet. Send one per response, at the end. |
ops |
Per-row transformations applied to the current filtered view. The recommended path for data changes. |
rows |
Concrete row data to merge into the grid, matched by primaryKey. Use as an escape hatch (see below). |
A typical response looks like:
yield { type: "status", content: "Fixing emails..." };yield { type: "ops", content: [{ action: "edit", fn: "..." }] };yield { type: "message", content: "Fixed 47 invalid emails." };Prefer ops for data changes
Section titled “Prefer ops for data changes”- One function covers any row count. A single function string applies to a 500-row or a 50,000-row filtered view, and no row payload travels through the LLM.
- Deletion needs an op. Only
opscan flag rows for soft delete. - Undo stays grouped. All ops in a single chunk become one undo step.
When to send rows
Section titled “When to send rows”Use rows when you already hold the concrete row data and a transformation function would be awkward. The common case is enrichment from an external API: you fetched fresh data for a set of rows and want to merge it in by primaryKey. For more than a few hundred rows, prefer ops.
Safety
Section titled “Safety”The fn string is JavaScript that runs in your user’s browser. Ops apply to the rows in the current filtered view, so the filter bounds what one request can touch. For a destructive operation the user did not ask for, surface a confirmation or preview step before applying the chunk.
onCancel
Section titled “onCancel”| Type | () => void |
Called when the user cancels a pending request from the log, after the SDK aborts context.signal. Passing context.signal to fetch already stops the network call, so most integrations need no onCancel. Use it for cleanup the signal cannot reach, such as a WebSocket or a server-side job.
const chat: DataEditorChat<Row> = { async *onMessage(context) { const res = await fetch("/api/ai", { method: "POST", body: JSON.stringify({ prompt: context.message }), signal: context.signal, }); // ... read the stream ... }, onCancel() { // optional: tear down anything the signal did not stop },};Full streaming example
Section titled “Full streaming example”A working onMessage that POSTs to your server, reads a server-sent-events stream, and yields response chunks. Your server is responsible for calling the LLM and serializing each chunk as a data: <json>\n\n line.
import type { ChatResponseChunk, DataEditorChat } from "@updog/data-editor";import { useMemo } from "react";
export function useChat<Row>(): DataEditorChat<Row> { return useMemo<DataEditorChat<Row>>( () => ({ sampleSize: 50,
async *onMessage(context) { yield { type: "status", content: "Thinking..." };
const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: context.message, primaryKey: context.primaryKey, totalRowCount: context.totalRowCount, filteredRowCount: context.filteredRowCount, errorSummary: context.errorSummary, sample: context.sample, columns: context.columns.map((c) => ({ id: c.id, title: c.title, editor: c.editor, unique: c.validators?.some((v) => v.type === "unique"), })), }), signal: context.signal, });
if (!res.ok || !res.body) { throw new Error(`Server error: ${res.status}`); }
const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = "";
while (true) { const { done, value } = await reader.read(); if (done) break;
buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? "";
for (const line of lines) { if (!line.startsWith("data: ")) continue; const data = line.slice(6).trim(); if (data === "[DONE]") return;
try { yield JSON.parse(data) as ChatResponseChunk<Row>; } catch { // skip malformed chunks } } } }, }), [], );}On the server side, your handler builds the system prompt, calls your LLM, parses the JSON response, and emits each ChatResponseChunk as a data: <json>\n\n line followed by data: [DONE]\n\n. The skeleton system prompt is a starting point for the first part.
