Skip to content

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.

  1. The user types a message.
  2. The SDK calls your onMessage(context) with the prompt and dataset context (columns, a row sample, error summary, counts).
  3. You build a prompt, call your LLM, and stream chunks back to the SDK.
  4. The SDK applies ops and rows, groups each chunk into a single undo step, and marks the request in the log as applied, failed or cancelled.
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.
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().

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.

  1. Read the context the SDK gives you.
  2. Write a transformation function the SDK can apply per row.
  3. Write a prompt that makes your LLM produce that function.
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.
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.

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.

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) => boolean
action 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.
  • Use exact column IDs. r is keyed by the id of each column in your schema, so a column with the id firstName is read as r["firstName"]. A title or a shortened name reads as undefined.
  • ctx.opts[fieldId] is a Set<string> of allowed values for columns with editor.type === "select". Use ctx.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 message chunk 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 number column is stored as its digits, so returning a JavaScript number is safe and 5e20 * 2 lands as 1000000000000000000000. 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 a rows chunk.
  • Multiple ops in one ops chunk 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”).
  • delete is terminal for the row. Once an op flags a row, later ops in the same chunk do not see that row.
  • One ops chunk produces one undo step. Splitting a single user request across multiple chunks splits its undo.
// 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)`
}

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.

The LLM has no idea what shape the SDK expects. Your system prompt has to spell it out:

  1. Output a JSON object with optional ops and optional message. Nothing else at the top level.
  2. Each op is { "action": "edit" | "delete", "fn": "<JavaScript function source>" }.
  3. r is keyed by exact column IDs from the schema in the user message.
  4. edit functions mutate r in place and return nothing. delete functions return a boolean.
  5. For select columns, use ctx.opts[fieldId].has(value); never inline the option list.
  6. Skip values you cannot fix. Do not clear or guess unless the user asked for that.

Build the user message from ChatContext:

  • A compact schema: per column, id plus editor.type, and options for selects.
  • The errorSummary rendered as a short list: field: "message" (count×) e.g. "example1", "example2".
  • A trimmed sample, holding data plus an _errors field for rows that have validation errors.
  • The user’s message as 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.

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."
}

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.

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." };
  • 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 ops can flag rows for soft delete.
  • Undo stays grouped. All ops in a single chunk become one undo step.

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.

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.

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
},
};

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.