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".
What a field is
Why a field's facts come from the corpus — what it is from fossil's manifest, what it holds from the counts fossil's writer stores beside it — and reach every reader through JoinGraph, so nothing in the library guesses them from a column's name or a threshold; and what would reverse each part.
Asking within a context
Why @kanzo-tech/ai/data describes each type once, why the answer's schema names a field list once, and why the linker, not a token count, keeps a question inside a small model's context — measured on five graphs, and what would reverse each.