React table AI assistant — widgets and custom UI
A table can tell an agent what it can do right now, without sending every row or every feature guide up front.
Use the optional native assistant or the headless
useTableAssistant controller to build a conversation around that contract.
Your application supplies the transport; table operations still pass through
the same permissions, validation and approval path.
That contract lives in @adapttable/ai. It is optional. @adapttable/core,
every adapter root, and @adapttable/server import none of it. Compose
tableAgent from @adapttable/ai-react when a table should publish a
manifest; omit the import and the bytes stay out.
What is wired, not what is installed
Section titled “What is wired, not what is installed”Capabilities come from the live table:
- A table with search and pagination advertises
view.setSearchandview.setPage. - Grouping, filters, export, editing and reorder appear only when that feature is composed and the host callback (where a write needs one) is present.
view.setSelectionappears when selection is wired (apply.setSelection).view.pinColumnappears when column pinning is wired and at least one column is pinnable.view.hideColumnandview.setColumnOrderappear when a layout-owning feature (the Columns menu) is composed.view.pinRowappears when row pinning is wired. These are view operations, so none takes the write-approval path.views.applyappears whenfeatureIdsincludessaved-viewsandapply.applyViewexists.rows.read/rows.resolveappear when the table has columns.rows.readredactsreadable: falsecells and is bounded bylimits.readMax.scope: "full"requiressource.fullDataset === true.rows.add/rows.deleteappear when the host apply methods exist andwritePolicyis"allow".rows.deleteis destructive, and takes the same row referencesedit.cellsdoes — a stablerowKey, or a 1-basedpositionin a namedscope— resolved before any row is removed.- Data-layer truth comes from the source’s
TableSourceCapabilities— the manifest copies those fields and never re-infers them from shape.
Package availability never participates. Installing @adapttable/ai does
not enable grouping on a table that never imported it.
Filters
Section titled “Filters”view.setFilters is the extra bag the table already applies — the same
keys the filter form writes (team, salaryMin, salaryOp). describe
lists each visible filter’s type, operators and, when the static list is
short enough, its options. A { key, op, value } array is accepted and
converted to that bag.
FilterDef.ai controls what the assistant sees. Omit it and the filter is
visible, with options sent only when there are 50 or fewer static choices.
ai: false hides the filter. { options: false } keeps the filter and
omits the values — the usual 10k-customer case. { options: 10 } raises or
lowers that cutoff. A list over the cutoff is omitted, not truncated, so a
sample cannot look like the full set. "auto" and async loaders are never
fetched into the prompt.
view.describe reports the current extras and the same catalog
(AgentFilter / AgentFilterOption).
Pagination, sort and search already publish current state and a typed
schema (page / pageMax, sortBy / sortable columns, the search
string). Filters were the gap this catalog closes.
Pinning
Section titled “Pinning”Pinning is addressing, not styling, so both capabilities take identity rather than a position on screen.
view.pinColumn takes a column key and a logical side: "start" is
the inline-start edge, which is the right edge under dir="rtl". The same
call is therefore correct in both writing directions. Pass side: null to
unpin. A column the host marked pinnable: false refuses a pin but still
accepts an unpin, so a column the host pinned itself is never stranded. The
end edge belongs to the table’s trailing actions column, which is chrome an
agent never addresses.
view.pinRow takes a side of "top" or "bottom" — physical, because a
pinned row sits above or below the scrolled body in every direction — plus a
row reference. Address the row by stable rowKey, or by 1-based position
with the scope and the expectedRevision that position was read at; a
position read against a view the table has since left is refused rather than
applied to whatever row now sits there. Summary rows are chrome, not data,
and cannot be pinned this way.
view.describe reports the live pinnedColumns map and pinnedRows lists,
so unpinning is an inverse of what is actually pinned rather than a reset of
the layout.
Assistant contracts
Section titled “Assistant contracts”A conversational assistant is a wrapper around this same executor — there is
no chat-specific dispatcher. @adapttable/ai exports the shapes a controller
and a widget are written against: AssistantRequest, AssistantAction,
AssistantProposal, AssistantOutcome, AssistantTurn,
AssistantConversation and the AssistantPlanner seam that turns a sentence
into actions.
An AssistantAction is exactly the (capabilityKey, args, expectedRevision, idempotencyKey) tuple execute already takes, so a planned turn is governed
identically to a scripted call, and an action planned against a stale view
fails instead of applying to a different one.
AssistantSuggestion is an authored prompt with a stable id, a localizable
title, and the capability keys it requires. Suggestions are never derived
from capability keys — a key is not a sentence.
eligibleSuggestions(suggestions, available) hides the ones this table
cannot run, and assertUniqueSuggestions catches a repeated id. A capability
definition may contribute its own through presentation.
Nothing in these contracts imports React or calls a model.
The headless assistant
Section titled “The headless assistant”@adapttable/ai-react turns those contracts into a conversation, and
still renders nothing. useTableAssistant({ session, transport, suggestions })
returns status, messages, draft/setDraft, send, stop, clear,
the live suggestions, runSuggestion, open/setOpen
and error. A host renders its own panel from those; the widget each kit
ships is written against the same values, so it is a convenience and never a
requirement. examples/ai-assistant-custom-ui.tsx is a complete panel with no
widget in it.
The rules it keeps:
- One send at a time, reserved before any await, so two clicks in one tick cannot interleave two turns’ actions against one table.
- A draft survives a failed turn. It is cleared optimistically and put back if the turn fails — unless the reader typed something else meanwhile.
- Stopping is not failing, and nothing is retried. An action whose outcome is unknown stays unknown; the assistant never sends it twice.
- A late reply is dropped. A turn belonging to a previous table, or to a panel that has unmounted, never writes into the transcript.
- Closing the panel discards nothing — not the draft, not the transcript, not a submitted action.
- A new session is a new conversation. Switching tables aborts the turn in flight and starts empty, so history never crosses between tables.
transport may be a fresh object every render; the controller reads the
latest one rather than reconnecting on its identity. A host that genuinely
swaps transports — a backend for a scripted one — says so with transportKey,
because a backend must never quietly become a simulated one.
Receipts come from results, never from an outer flag. receiptFromResult and
receiptsFromResults report executed, staged, rejected,
awaiting-approval, cancelled, stale or failed; turnStatus summarizes
a turn as applied, partial, none, cancelled or failed. An approved
write that has not reached the host is staged, not executed — Save is still
the reader’s, on the table’s own dirty path.
A transport is the only thing that knows about HTTP or a model.
assistantHttpTransport on @adapttable/ai/http adapts the existing backend
bridge; a host writing its own implements AssistantTransport from
@adapttable/ai and pulls in neither.
The optional widget
Section titled “The optional widget”Every kit ships a panel on @adapttable/<kit>/assistant, and it is a separate
entry point on purpose: a table that never imports it carries none of it.
import { TableAssistant } from "@adapttable/mantine/assistant";import { useTableAssistant } from "@adapttable/ai-react";
const assistant = useTableAssistant({ session, transport, suggestions });
<TableAssistant assistant={assistant} open={assistant.open} onOpenChange={assistant.setOpen}/>;TableAssistant takes TableAssistantProps: the assistant view, open and
onOpenChange, an optional presentation (TableAssistantPresentation —
"panel" beside the table, or "sheet" for a modal on a narrow viewport),
labels, className, launcher (set false when the host supplies its own
trigger — the toolbar button and the floating launcher drive ONE panel), and
onSettings. tableAssistant() binds the same component to the
TABLE_ASSISTANT slot for hosts that compose it as a feature.
The panel is a sibling of the table, never a cell inside it, so it can sit beside the grid without covering the rows a reader is asking about.
What the panel does, in every kit
Section titled “What the panel does, in every kit”Structure, keyboard and announcements live in TableAssistantChrome
(TableAssistantChromeProps); each kit fills TableAssistantSlots with its
own Panel, Sheet, Button, Composer and Badge
(TableAssistantPanelProps, TableAssistantSheetProps,
TableAssistantButtonProps, TableAssistantComposerProps,
TableAssistantBadgeProps). Core draws no control, so a Mantine table’s
assistant is Mantine and an antd table’s is antd —
createAdapterTableAssistantFeature is what an adapter calls to bind its own.
- Empty state asks what to do, then offers only the suggestions this table can actually run.
- Enter sends, Shift+Enter starts a line, and Enter mid-IME-composition belongs to the IME — sending there would post a half-written word.
- Send becomes Stop while a turn runs. A disabled composer always says why rather than becoming a dead end.
- Roles are named, not coloured. Each message shows its speaker, and each receipt says in words what became of the action. A staged write says it still needs saving in the table.
- New messages follow only when the reader is already at the bottom; otherwise the panel offers to take them there, so an earlier result stays readable.
- Escape closes the panel, unless something inside it already answered — one key never dismisses two things. Closing returns focus to the launcher.
- Backend text is rendered as text, never as markup.
The view it reads is TableAssistantView, built from
TableAssistantMessageView, TableAssistantReceiptView and
TableAssistantSuggestionView. useTableAssistant’s return satisfies it, and
so does a host driving the panel from its own state. assistantIsBusy and
assistantIsUsable answer the two questions a host’s own chrome usually asks
of a status token.
Six ways to wire it
Section titled “Six ways to wire it”Every one of these runs through the SAME governed executor. What changes is how much of the UI you keep.
1 — The ready widget. The short path: a panel in your kit’s own components, beside the table.
import { useTableAssistant } from "@adapttable/ai-react";import { assistantHttpTransport } from "@adapttable/ai/http";import { TableAssistant } from "@adapttable/mantine/assistant";import { tableAgent } from "@adapttable/ai-react";
const transport = useMemo( () => assistantHttpTransport({ endpoint: "/api/table-agent" }), []);const assistant = useTableAssistant({ session, transport, suggestions });
<DataTable {...props} features={[tableAgent({ tableId: "orders" })]} /><TableAssistant assistant={assistant} open={assistant.open} onOpenChange={assistant.setOpen}/>;session comes from the table. Either read it inside the table’s tree with
useFeatureState(TABLE_AGENT_STATE), or lift it out with
tableAgent({ tableId, bridge: { attach: setSession } }) when the panel is a
sibling.
2 — Your own launcher. The floating launcher and a toolbar button drive one panel, so turn the built-in one off and open it yourself.
<button type="button" onClick={() => { assistant.setOpen(true); }}> Ask AI</button><TableAssistant assistant={assistant} open={assistant.open} onOpenChange={assistant.setOpen} launcher={false}/>;3 — A controlled panel. Own the open state and the surface. Pass
presentation="sheet" on a viewport too narrow for a table and a panel side
by side, and the kit’s own modal is used.
const [open, setOpen] = useState(false);const narrow = useMediaQuery("(max-width: 900px)");const assistant = useTableAssistant({ session, transport, open, onOpenChange: setOpen,});
<TableAssistant assistant={assistant} open={open} onOpenChange={setOpen} presentation={narrow ? "sheet" : "panel"} launcher={narrow}/>;4 — Your own UI, our controller. Keep the lifecycle, render nothing of
ours. examples/ai-assistant-custom-ui.tsx is a complete panel built this
way; the widget above uses these same public values, which is what makes it
optional rather than required.
const a = useTableAssistant({ session, transport, suggestions });
<ol> {a.messages.map((m) => ( <li key={m.id}> <strong>{m.role}</strong> {m.text} {m.receipts?.map((r) => ( <span key={r.idempotencyKey}> {r.capabilityKey}: {r.status} </span> ))} </li> ))}</ol>;5 — Your own transport. The seam names nothing about HTTP or any model, so this pulls in neither. Anything that turns a sentence into actions is valid — a backend, an in-process planner, or a fixed script.
import type { AssistantTransport } from "@adapttable/ai";
const transport: AssistantTransport = { async send({ session, text }) { const result = await session.execute( "view.setFilters", { filters: planFilters(text) }, session.manifest().viewRevision, crypto.randomUUID() ); return { text: "Filtered.", results: [result], keys: ["view.setFilters"] }; },};Pass the live revision, not a remembered one: an action planned against a view the table has left must fail rather than apply to a different one.
What a transport owes the panel. signal is a request, and a transport is
your code: honour it if you can, by passing it to fetch or to whatever does
the waiting. The panel does not depend on that. If a stopped turn answers
anyway, its reply is dropped rather than appended, and Stop frees the composer
immediately rather than waiting for a transport that may never settle.
What a transport must NOT do is retry. Stopping cancels the panel’s interest in an answer; it does not undo a write that already reached the host, and it cannot cancel work a backend has already started. An action whose outcome is unknown stays unknown — sending it again is how one stopped edit becomes two.
6 — The HTTP backend you already run. assistantHttpTransport adapts the
existing bridge; examples/ai-http-backend.ts is the runnable server.
const transport = assistantHttpTransport({ endpoint: "/api/table-agent", headers: { authorization: `Bearer ${yourEndpointToken}` },});That token is your endpoint’s, never a model provider’s. Provider credentials belong on the backend; the browser never holds one, and this library contains no model client to hold it with.
What a reader is actually told
Section titled “What a reader is actually told”- Eligibility is live. Suggestions and capabilities are re-checked against the current manifest, so a feature the host turns off stops being offered rather than failing when pressed.
- Descriptions are progressive.
catalog()is small and stays small;describe(key)fetches a schema only when something needs it. - Approval is not persistence. Approving a write lets it reach the host.
Under
commit: "stage"the host callback is the staging one, so the change sits on the table’s own dirty path and the panel says it still needs saving. Approving and saving are two separate acts by design. - Continuation is optional. A local action receipt needs no second model call; nothing forces one turn to become two.
- Sessions are isolated. One session per table, one conversation per session. Switching tables aborts the turn in flight and starts empty, so a reply about one table can never land under another.
Three portable calls
Section titled “Three portable calls”Any agent runtime can speak this:
catalog()— keys and one-line summaries, in a stable order.describe(key)— the guide plus strict input/output JSON Schemas.execute(key, arguments, expectedRevision, idempotencyKey)— validate, refuse a stale revision, replay an idempotent key, then dispatch.
Runtimes that support typed tools can wrap each described capability as its own tool. The three calls stay the fallback.
Protocol identity is the schema version (adapttable.agent.v1) and the
capability keys. Labels may be translated for people; execution is
locale-independent.
The manifest does not send rows
Section titled “The manifest does not send rows”Initialization publishes:
- table id and view revision
- the enabled capability keys
- readable/writable column metadata
- how rows are addressed (
visible/page/full) - limits (
pageMax,readMax) and policy (write,approval,commit) - the source capability record
It never dumps the dataset or every feature instruction. Bounded
rows.read and write approval (approval / commit / kit chrome) live
in @adapttable/ai. The kit strip uses agent-approval,
agent-approval-list, agent-approval-approve, agent-approval-reject,
and agent-approval-row. Escape rejects. Enter is not a silent confirm.
Registering a capability of your own
Section titled “Registering a capability of your own”createAgentSession({ capabilities }) takes AgentCapabilityDefinitions. A
definition carries a namespaced key, a one-line summary for the catalog, a
guide with JSON Schema for its input and output, an isEnabled(observation)
that decides whether it is wired right now, and execute.
const archive: AgentCapabilityDefinition = { key: "orders.archive", summary: "Archive an order.", kind: "write", guide: { guide: "…", input: archiveInput, output: archiveOutput }, isEnabled: (observation) => observation.writePolicy === "allow", execute: (context, args) => host.archive((args as ArchiveArgs).rowKey),};kind is what makes it governed. A "write" or "destructive" capability
goes through the same path as a built-in mutation, and the session — not your
handler — enforces it:
- the table’s write policy, then the commit mode;
plan, if you wrote one, to resolve a side-effect-freeCapabilityPlanthe approver can read;- approval, when the table’s
approvalpolicy asks for it; - the revision and the permissions again, after every await;
- only then
execute.
A handler that never calls onApprove therefore cannot write unapproved, and a
denied or still-pending approval calls it zero times.
Staging is declared, not assumed. A governed capability defaults to
staging: "unsupported", so a table running commit: "stage" rejects the call
with commit-incompatible before your handler runs rather than committing
something the host wanted staged. Set staging: "supported" when the
capability really can stage.
AgentCapabilityContext is what execute receives: the observation it was
authorized against, the host’s apply callbacks, a live observe(), the bound
onApprove, the request’s signal and throwIfCancelled(), and — for a
governed write — the approved plan and the resolved commit mode.
Wiring the review
Section titled “Wiring the review”The ready-made path is two props. The assistant hands you the write; the panel draws it:
import { useTableAssistant } from "@adapttable/ai-react";import { TableAssistant } from "@adapttable/mantine/assistant";
const assistant = useTableAssistant({ session, transport });
<TableAssistant assistant={assistant} open={assistant.open} onOpenChange={assistant.setOpen} approval={assistant.approval}/>;approval is safe to pass always: the panel draws it only when the resolved
presentation names the widget.
A panel mounted outside the table cannot read its feature state, so the bridge hands the same value over:
const [approval, setApproval] = useState<AgentApprovalPending | null>(null);
tableAgent({ bridge: { approvals: setApproval } });To draw the review yourself, read the model and render whatever you like. The counting, the labels and the preview are all in it, so a custom surface says the same things the built-in ones do:
import { approvalReview } from "@adapttable/react/adapter";
function MyApproval({ pending }: { pending: AgentApprovalPending }) { const review = approvalReview(pending, labels); if (!review) return null; return ( <aside> <p>{review.summary}</p> {review.items.map((item) => ( <button key={item.id} onClick={() => pending.decideAt?.(item.index, true)} > {item.proposal.rowLabel ?? item.proposal.rowKey} </button> ))} <button onClick={pending.approve}>{review.approveLabel}</button> <button onClick={pending.reject}>{review.rejectLabel}</button> </aside> );}decideAt is absent when the write cannot be split — hide per-row controls
rather than drawing dead ones.
One write at a time
Section titled “One write at a time”An approval is one transaction, and a second write while one is open is refused rather than queued behind it. Every decision control belongs to the transaction it was made for: a control left over from an approval that has settled does nothing, so a stale click cannot answer the next write. The write settles exactly once, whichever comes first — the reader deciding, the turn being aborted, or the last row being answered.
Approving is not saving. commit: "stage" puts an approved change on the
table’s own dirty path, where the reader still presses Save; commit: "immediate" calls the host save path and reports what it returned. Rejecting
stops a write that has not run. It does not undo one that already has —
cancellation after the host callback is the host’s own concern.
Where a waiting write is reviewed
Section titled “Where a waiting write is reviewed”Three surfaces can review an approval, and exactly one of them draws the
controls: whichever the resolved presentation names.
presentation |
Where |
|---|---|
widget (default) |
In the conversation, under the messages |
table |
A strip above the rows it changes |
modal |
The kit’s own dialog, over the page |
The others do not repeat the buttons. The assistant says a change is waiting when the decision is being made elsewhere; that is all.
Every surface draws the same review, because they all read one model:
import { approvalReview } from "@adapttable/react/adapter";
const review = approvalReview(pending, labels);review.changes; // 12review.rows; // 8 — three edits to one row are three changes and one rowreview.preview; // the first threereview.approveLabel; // "Approve all" — "Approve" for one, "Approve remaining"// once any change has been decidedA long write opens with a summary — 12 proposed changes across 8 rows — and the first three changes. Review all 12 changes opens the rest inside the same surface: in widget mode the conversation gives way and Back to conversation returns, rather than a second overlay opening over the first.
Approve all becomes Approve remaining the moment any single change is
decided, because a row already refused stays refused and the first label
would be a promise the control cannot keep. A running tally sits beside it.
A write with one change says Approve and offers no per-row pair: “all” of
one names a set that does not exist, and two controls answering the same
question is a decision the reader has to make before they can act.
A write that enumerates no rows — an opaque server operation — is shown by name and its arguments as pairs, not as the JSON the capability will receive. It gets no invented row count and no per-row checkboxes, and it is answered whole.
Closing the assistant does not answer anything. The write stays parked and the launcher still says so; reopening brings the review back.
The reader and the model are told different things
Section titled “The reader and the model are told different things”A proposal has two before-values, and they are not the same value.
WriteProposal.before is what the MODEL is told. It is read at the agent’s
own addressing scope, through the same readable-column allowlist as
rows.read, because it travels: the session returns it, and an HTTP or MCP
continuation sends it back to the backend. A row outside that scope therefore
has no before-value here, and a column marked readable: false never appears
in one.
What the person approving sees is resolved separately, in the React binding, from the table already on their screen. That value never enters a proposal, a result, or any transport — so a row the current filter hides still reads correctly for them without widening what the model was given.
Being able to see the table is not entitlement to every cell in it. A column the host marked unreadable resolves to nothing on that side too, and nothing is reported as Unavailable rather than drawn as an empty cell:
Ada · ssn: Unavailable → redacted // nobody could look it upAda · notes: — → "call back" // the cell is genuinely emptybeforeUnavailable on the approval proposal is that distinction. A value is
never invented to fill the gap.
Whether a human is asked, and where
Section titled “Whether a human is asked, and where”Two questions, answered separately.
Policy is whether an agent has to wait for a person. Presentation is where that person is asked. Turning approval off does not move a surface, and choosing a surface authorizes nothing.
The table sets both once:
useTableAssistant({ approval: { policy: "writes", presentation: "widget" },});approval: "writes" still works and means the policy alone. The defaults are
writes and widget.
An action overrides either field on its own:
const actions: RowAction<Person>[] = [ { key: "email", label: "Email", onClick: email }, { key: "delete", label: "Delete", onClick: remove, // Always ask for this one, wherever the table reviews approvals. ai: { approval: { policy: "required" } }, },];Inheritance is per FIELD. Overriding the policy leaves the presentation as the
shared one, and vice versa — so a table can say “review in the widget” once and
then mark two sensitive actions as always-ask without repeating itself. An
absent ai object changes nothing: there is no key whose absence means
authorized.
policy: "automatic" skips the human confirmation and nothing else.
Permissions, schema validation, and the staging and save rules all still run,
and a column the table marked unwritable is still refused.
ai.approval is not confirm. confirm describes a person clicking the
action themselves and being asked whether they meant it. ai.approval
describes an agent asking to run it on their behalf. One agent execution
raises one prompt — the approval — not both.
The ai object carries overrides only. The action’s key, label, handler and
disabled rules stay where they are; it is plain data, so a table with no
assistant carries no agent code because one of its actions mentions it.
Deciding a bulk write row by row
Section titled “Deciding a bulk write row by row”A reader can approve some of a bulk write and refuse the rest, and the session
then narrows context.plan: proposals and payload both describe exactly
the approved rows, in plan order.
args is not narrowed. It is what the model asked for, and rewriting it would
misreport the request. So a handler that works from args rather than from
plan.payload would apply rows the reader refused — and the session cannot
read your handler to find out which kind it is.
So you say. partial: "supported" is a promise that execute applies
plan.payload:
{ key: "staff.raise", kind: "write", partial: "supported", plan: (context, args) => ({ proposals: rows.map((row) => ({ rowKey: row.id, column: "salary", after: row.next })), payload: rows, perItem: true, }), execute: (context) => save(context.plan?.payload as Row[]),}The default is "unsupported". An undeclared capability is offered to the
reader whole, and a row-by-row answer for it is refused with
approval-not-decomposable rather than quietly widened to “approve all” — so
args and plan always agree for handlers that never opted in.
Three things must all hold before a write is offered per item: the plan sets
perItem: true, the capability declares partial: "supported", and payload
is an array lined up with proposals index for index. rows.reorder is the
counter-example among the built-ins — two proposals describe one indivisible
move, so it is always offered whole.
A malformed decision fails the call without writing anything: a position
outside the plan, a repeated position, or a non-integer returns
approval-invalid. Nothing is filtered and run anyway.
A read or view capability skips all of it. Nothing about a view operation
asks for write approval.
Cancelling
Section titled “Cancelling”Pass an AbortSignal to execute and the session stops at every seam it
owns: before your handler runs, after planning, after approval, and before
each row of a bulk write. Cancellation is not an approval question — a table
with approval: "never" and no onApprove cancels exactly the same way.
A multi-step handler cooperates by calling context.throwIfCancelled()
immediately BEFORE each side effect, and by passing context.signal to
anything that accepts one:
execute: async (context, args) => { const rows = await fetchArchivable(args, { signal: context.signal }); context.throwIfCancelled(); await context.apply.deleteRows?.(rows.map((row) => row.id)); return { archived: rows.length };};Nothing here claims to undo a callback the host has already been given. That is what decides the retry rule:
- Cancelled before any write. Nothing ran, so the idempotency key is left free and the same key may be sent again.
- Cancelled part way through a bulk write. The rows already written stay
written and are reported in
results; the rows after them are never attempted. The key now belongs to that partial outcome, and sending it again replays the outcome rather than writing the first rows twice.