Kanzo UI
The design of Kanzo UI

Asking data

Why an answer in @kanzo-tech/ai/data is a tile over a relation the model fills from typed names, not SQL it writes — and what would reverse it.

Ask your data is what @kanzo-tech/ai/data ships. This page is why an answer is shaped the way it is.

An answer is a relation and a tile, and the tile is the whole question

The model does not write SQL. It fills a spec whose every name is an enum the page generated from the data it shows, and the answer that spec becomes is the dashboard's own vocabulary: a relation, and a tile on it.

interface Answer {
  /** What is asked about: the type measured, and the hops the tile's paths take from it. */
  relation: Relation;
  /** How it is shown, and what it is narrowed to (`where`) and ranked by (`top`): the dashboard's `Tile`. */
  tile: Tile;
}

The tile carries the whole question because a dashboard's tile carries its own. So Physics prizes by university, added to the dashboard, is still Physics prizes by university, where before a tile could hold neither condition nor ranking and the dashboard stored prizes by university. The spec the model writes is the tile without what only the page gives it — its id and its width — with of, the type it measures, and every field a path from it (Tasks); JoinResolver makes the relation of the paths, and the oracles are its misses and parseTile. The prompt lists each type's fields once, with their kind and most common values, gender: category (female, male), read from the graph — fieldStats over its descriptions of the columns, the commonest values the corpus's writer counted (What a field is) — and the edges a path steps across, rather than DDL; Asking within a context is why once.

It replaced answerSchema, checkAnswer and AnswerInput — the old answer tool's schema, its validator, and an answer whose relation was a key — and answerQuery, a second compiler of a tile to SQL beside the dashboard's.

It compiles with what the dashboard compiles with, and only that. relationQuery makes the relation, JoinResolver.crossing brings in a condition off its path, and tileQuery — the dashboard's one compiler of a tile — makes the query: grouping, measure, order, top. The rows Ask reads and the marks the card draws come from one function, so the sentence and the chart cannot describe two different queries. All of it runs under the page's crossfilter at the moment of asking. The page's filter is not something the model names: it is the predicate the answer is read under, as every tile's is. What the answer read is what was true then; the card is a tile, and follows the page afterwards like any other.

A page's clause reaches the answer as it reaches a tile. A chip published on a relation is read by the page's own resolver (JoinResolver.crossing): as it is on that relation, and on the answer's through a semi-join on the key of a type both reach — a chip on Physics, published on LaureateAward>category>Category, narrows LaureateAward>university>University by the prize, so the University of California reads 16 and not its 43 in every category. It replaced matching a clause by its columns alone, under which that answer kept none of its rows while the dashboard's tiles beside it were narrowed: keasy's first evaluation pass showed a model that picked the right relation and an answer that read nothing. A clause the answer's relation shares no type with keeps none of its rows, as a tile's would (decided 11 October 2026), and is said, with the columns it names; the card prints it under the tile, so an empty answer says why it is empty. A clause published on no relation is still answered by column. What would reverse it is a resolver that crosses differently for tiles, and then the answer follows it — the two never disagree.

Held by packages/ai/src/data/page-clauses.test.ts, "crosses a chip on another relation by the key both reach, as the page's tiles are crossed"; "keeps none of a relation's rows when it shares no type with the chip's".

The read goes through the engine's door, not the coordinator's queue. The queue batches behind requestAnimationFrame for the charts, and a hidden tab never fires one: a question asked before switching tabs waited forever, and Stop only dropped the late answer. Engine.query takes the chat's signal, so an abort ends the wait at once. An answer is one statement, which the queue had nothing to consolidate with. What would reverse it is a coordinator whose queue takes a signal and does not wait for a frame: then Ask goes back to reading where the charts read.

Under the read door it stays the engine's: the read is a query on the host's QueryClient whose function is handed the query's signal and hands it to Engine.query, and the chat's signal cancels the query. The cache keys the read by the engine and the statement, which holds the page's clauses, so an answer asked again under the same chips reads nothing again.

The card is a dashboard tile, and it takes the answer. AnswerCard takes answer — what the data-answer part holds — not an AI SDK tool part, so an answer can be drawn outside a transcript. It draws the dashboard's own TileView over the tile, with two verbs that fall out of the shape: Filter the page to these rows publishes one semi-join on the relation's root key over the rows the tile reads — tileQuery's narrowed relation, its conditions and its ranking in it, and for a bar chart the categories it answered — and Add to the dashboard puts the tile, its conditions and its ranking with it, in the relation's spec. A kept answer is names, not SQL, so a restored transcript draws it again over the host's graph, and nothing a transcript carried reaches the engine as a statement.

The references are the semantic layer's: Looker's Conversational Analytics and ThoughtSpot Sage, where a question is answered by picking fields of an explore or a worksheet, and Cube, LookML and Malloy, where SQL is compiled from a model of the data rather than written against tables. relationQuery's own page names Looker's explore and Malloy's source as its reference, so the answer and the dashboard share a model.

It replaced Databricks Genie's and Hex Magic's loop: one SQL query per answer, written by the model against DDL, and gateStatement between that text and the page's engine. The gate kept the engine safe, but it could not make the model right. A local model shown "cac8fcfb-…"."Person" wrote "Person" in one take and "cac8fcfb-…"."scope" in the next, and both were refused. Making the gate tolerant guessed what the model meant, and showing plain names moved the same mistake one step along. Every mistake of that kind is a name, and an enum removes names from what the model writes. Genie and Hex run frontier models against warehouses whose names are the reader's own. A page that runs whatever model its host points it at, over names a corpus generated, cannot count on either.

What it costs: a question the spec cannot say, such as a window function, a ratio of two aggregates or a join the JoinGraph does not have, gets no answer: the oracles refuse every spec the model tries for it, and the reader is told so. There is no SQL tool kept beside it as a fallback: two doors means two things to secure and two answers that look different, and the spec is where the missing words get added.

What would reverse it: questions readers ask and the spec cannot say, recorded often enough that growing the spec costs more than a second door. Then a SQL tool returns beside the spec, behind gateStatement as it was — with @kanzo-tech/mosaic's engine loading DuckDB's json again, whose json_serialize_sql was the gate's parser — and the card says which one answered.

Held by packages/ai/src/data/answer.test.ts, "reads its tile's rows with the one compiler the dashboard's tile reads"; packages/ai/src/data/answer-card.test.tsx, "Add to the dashboard hands the host its dashboards with the tile in the relation's spec, and where it landed".

An answer is a data part, not a tool call

Ask's transport writes the answer as a data-answer part of the assistant's message, and a decision it needs from the person as a data-ask part; it writes no tool call. No model called a tool: the Task's spec was generated with Output.object and checked by oracles, and the transport then wrote a tool-input-available as the model would have written it, so that Chat's tools renderer and an AnswerCard taking a tool part could draw it. A data part is the AI SDK's form for exactly this — application data a stream writes beside the text, typed per name, reconciled by id when it is written twice — and Chat draws it through a data renderer map beside tools. An answer is one part written twice under one id: the answer as asked, then the answer read.

The reference is the AI SDK's Streaming Custom Data: createUIMessageStream's writer writes { type: "data-answer", id, data }, and a UIMessage typed with its data parts carries them to useChat.

What would reverse it: a model that answers by calling a tool — a remote model with native tool calling, given Ask as a tool. Then the call is real, and a tool part is what it is.

Held by packages/ai/src/data/ask-chat.test.ts, "answers with the tile the Task's spec is, read on the page's engine, and words written from its rows"; packages/ai/src/chat.test.tsx, "draws a data part through the host's renderer by its name, and says when its message is written no more".

A question to the person is the question part

When Ask must ask — an oracle's Decision, options and never free text — it writes the decision as a data-ask part, the host draws it with @kanzo-tech/ui's question part, and the person's pick comes back as the option's id in the reply's own data-ask part, askEdit(spec, { [decision]: option }), which settles the decision as the Task's decide does. It was written as numbered text and the reply matched back against 1, the option's id or its label — a second way to ask the reader beside the question component keasy's assistant already draws its decisions with. There is one question part, in @kanzo-tech/ui, and Admission is why it lives there.

Since Ask's fields became paths, none of its own oracles raises a decision: the tie between two paths of one length that was its one question is the model's to write (Tasks). The door stays because a Task's oracle may ask, and the shape of the asking is the Task's, not Ask's.

The reference is MCP's elicitation, which Decision already follows: a request with a schema of options, answered by a structured reply, never parsed out of prose.

What would reverse it: a decision whose answer cannot be a choice — a value the person types, such as a threshold. Then the question part takes an input, as Questionnaire's question already does, and the reply carries the value; it is still not parsed out of text.

Held by packages/ai/src/data/ask-chat.test.ts, "asks a decision as a data part, and settles it by the option the reply picks, not by its words".

Whether a tile is on the dashboard is the host's spec, read

AnswerCard takes the host's Dashboards and hands onAdd the whole next document, and On the dashboard is the tile's id found in its relation's spec. It is Dashboard's own contract — a value and the whole next value — and the controlled answer to a flag a card kept: that flag said added the moment onAdd returned, before the host's save had succeeded, and a remount forgot it. The tile goes on under the answer's id, the call's, which is what lets the spec be asked. A write the host answers with a promise is waited on: pending until it settles, whatever the spec shows, and a rejection drawn as a Problem under the tile, the thrown value whole.

onAdd is told the relation as well as its key, because a host's dashboard view is driven by a Relation and a key cannot be read back into one.

What would reverse it: a host that keeps its dashboards in some shape other than Dashboards, asking for the card. Then the card takes a predicate and a callback that build the next spec, and the document is the host's again.

Held by packages/ai/src/data/answer-card.test.tsx, "is on the dashboard while the host's spec holds the tile, after a remount, and offered again once it is removed"; "is pending while the host's write is, never on the dashboard before it settles, and draws a rejection as a Problem".

An answer's verbs are on its tile, and Filter is there only when it filters

Filter the page to these rows and Add to the dashboard are icon buttons in the tile's own header, beside its title, each named by aria-label and a tooltip; once the tile is on the dashboard the second becomes Open on the dashboard, the host's onOpen. Grafana's Explore adds a query to a dashboard from the panel it is about, and its dialog ends in Open dashboard; Metabase adds a question to a dashboard and then opens it there. The verbs used to be two text buttons above the card, belonging to nothing on screen. A tile already has a place for its controls — the pencil in ChartCard's and DashboardStat's header, in CardAction — so the verbs go there, through actions, a node the host puts in the header before the pencil. Opening is the host's because the view is: the card cannot switch it, so onOpen is told { relation, key, id }, the add's own answer and the tile's id. A host without it gets On the dashboard, disabled, rather than a verb that does nothing.

actions sits beside onEdit rather than replacing it: the pencil's icon, label per kind and place are the library's, and Dashboard is its only caller. What would reverse it: a host that wants a pencil of its own; then onEdit goes and Dashboard passes its pencil through actions.

Filter is disabled, and says This answer keeps everything in view, when its clause would keep every row the page keeps. In keasy's Ask over LDBC's social network, the model answered browsers used, as bars, over Person with no conditions: the bars covered every browser, the clause kept every person, and pressing Filter changed nothing a reader could see. The rule reads only what the card already holds, so it costs no query: no conditions, a relation that is a type alone (a hop keeps only the roots that have one), and bars over every category of their column — as many as the field's distinct, none of them null, which the clause would drop. Disabled with its reason, not hidden, as the timeline's Play is: the reader learns why the verb is not there. Its chip names what it filters, each part read by clauseLabel as the filter bar reads a clause — Ask: browserUsed · 3 selected, Ask: Person.gender female — where Ask alone said who had filtered and not to what.

What it costs: distinct is the corpus writer's exact count of the type's column (DuckDB's approx_unique for a relation nothing counted, exact at the few dozen values a bar chart answers), and it counts the whole type. An answer read under a page that already narrowed its column answers fewer categories than the field has, so its Filter stays enabled even when it would keep exactly what the page keeps.

What would reverse it: readers pressing an enabled Filter that changes nothing, under a page that had narrowed the column. Then the card counts the page's rows with and without the clause — one query per answer — and disables on equal counts.

Held by packages/ai/src/data/answer-card.test.tsx, "draws its verbs in the tile's header, beside its title"; "opens the tile on the dashboard once it is there, when the host says how"; "is disabled, and says why, when its clause would keep everything in view"; "filters when the bars are ranked down to some categories, or cross a hop"; "names the chip after what it filters, as the filter bar reads each clause".

Which relations are offered

Ask is offered the types alone, as the graph describes them, and the relation is the paths the model writes from them. An answer across a hop is still answered: a path steps across it, and a step's fields are the type's. A multi-valued property is reached the same way — a path that ends on it reads its value — and is never a type Ask measures on its own. Nothing is read from the engine to offer them: each type's fields, their kinds and their commonest values are on the join graph, counted by the corpus's writer, so offering them costs no query at all (What a field is).

It replaced readAnswerRelations — a SUMMARIZE per relation and a count per category, queued on the page's one connection ahead of its charts — and answerRelationsOf, which enumerated the relations to read. Measured 2026-10 in DuckDB-WASM's Node build over a graph shaped like LDBC's social network benchmark at scale 1 (3.2 M vertices, 17 M edges, 8 types, 23 edges), that read took 1.2 s for the types alone and 41.5 s with every hop; the model's prompt was 3,581 characters for the types alone and 39,018 with every hop. Offering every hop had already gone before the read did: it left the cost to the host and the choice of a relation to the model, and keasy's first evaluation pass found the model choosing it wrongly. A type's own values are what a step of it reads; the values a hop narrows to are the answer's rows, not the prompt.

What would reverse it: a spec whose fields the type alone cannot give — a field a hop alone reads, such as an edge's own property. Then that edge's columns are described on the graph beside the types', and its fields join the spec's enum under its path. And a graph whose corpus counted nothing offers its fields without values; if that became the common case, the counts would be read once per corpus through the read door, never once per prompt.

Held by packages/ai/src/data/answer.test.ts, "describes a relation's fields from the graph, without its steps' keys, each with the commonest values its writer counted"; "reads nothing from the engine: what a field is and holds is the graph's"; packages/ai/src/data/ask.test.ts, "is a path Ask may write and read its value by, and never a type it measures".

An answer's rows are on the page, as a table

AnswerCard holds the rows the answer was read from as a visually hidden table, captioned The rows the answer was read from. A chart's long description is its data, best structured as a table on the same page — the W3C WAI Images Tutorial's Complex Images — and Highcharts' accessibility module keeps such a table for screen readers in the same way. The rows are the read's as the engine returned them under the page's filter when the call ran, capped as the model's are; the tile above follows the page afterwards and may no longer show them, which the caption's words make plain.

The same table is what a test proves an answer by: AnswerHarness.rows() reads it by its caption, cell by cell. A figure asserted there is the engine's; asserted in the chart it is only drawn, and in the reply it is only the model's quotation of it — on a replayed take, a recording's.

A chat is kept in the AI SDK's shape

@kanzo-tech/ai keeps a chat as the AI SDK keeps one — the UIMessage[] of a chat id — and the host gives two functions, load(id) and save(id, messages), and may give a third, keep, which decides what of each part is stored. A chat hook given them (useAgentChat, useAskChat) calls load once before the first turn, validates what it answers with the AI SDK's validateUIMessages against the chat's own dataPartSchemas and messageMetadataSchema — the ones useChat already takes — and, for an agent, its tools, and makes the useChat of that id from them; at the end of every turn it saves every message, one save at a time. keep filters or rewrites the parts — a host drops reasoning, or keeps an answer's spec with its rows emptied — and what it keeps is validated before save is called, so a keep that writes what cannot be read back is refused when it writes, not when the person comes back.

The load is a plain call, not a query: the chat hooks are @kanzo-tech/ai's root, which takes no TanStack peer (One read per datum), and a kept chat is read once, into useChat, which holds it from then on — there is nothing for a cache to share.

The reference is the AI SDK's Chatbot Message Persistence: a chat is its id and its UIMessage[], loaded into useChat({ id, messages }) and saved when a response finishes, validated on the way back in because what was stored is untrusted input. Two things are its rules rather than ours: a list with no message, or a message with no part, does not validate — so [] from load is a chat never saved, and a message keep leaves no part of is not stored.

Ask's parts have schemas of their own, which useAskChat passes as its dataPartSchemas: an answer and an ask part are each rebuilt from the fields they may hold, their relation read as { root, path } and their tile through parseTile, the dashboard's own reading of a stored tile — so what a transcript carried back is names over the host's graph and never reaches the engine as a statement. They are written by hand as Standard Schema, which the AI SDK takes, because @kanzo-tech/ai has no schema library of its own and parseTile already is the tile's. What would reverse it: a schema library entering @kanzo-tech/ai for another reason; then the parts are written in it.

Held by packages/ai/src/data/ask-parts.test.ts, "reads back an answer and its turn as Ask wrote them, and nothing they do not hold"; "refuses an answer whose tile a dashboard would not read, or whose relation is not one".

useKeptChat is the same thing for a transcript whose messages are not useChat's. Keasy's assistant, which wrote the store, queue and restore this replaces, is a guided conversation whose turns the product decides and whose messages it writes itself; the chat hooks are this one with useChat around it, so there is one mechanism and two ways in only where there are two kinds of transcript. What would reverse it: the guided conversation moving onto useChat; then useKeptChat has no caller and leaves the barrel.

A stored chat that does not validate is a failure, handed to the host whole, as every other (failure): chat.kept is failed with what was thrown, Chat says so with its composer inert, and nothing is saved over it. The library never drops the messages it cannot read and opens an empty chat in their place, which would lose a person's conversation without saying so. A save refused is unsaved, said under the transcript, and the next turn's save is tried all the same. While the chat is read, Chat is its own skeleton: a question sent then would be lost under the messages that arrive.

What would reverse it: a host that stores something other than the messages — an event log, a server-side thread — and rebuilds messages from it. Then load and save still have this shape, and the conversion is the host's.

Held by packages/ai/src/kept-chat.test.tsx, "opens where it stood: the chat of its id is read once, validated, and drawn"; "never opens empty in place of a chat it cannot read, and never saves over it"; "saves one at a time, in order, and refuses what keep left unreadable".

On this page