Tasks
Why Ask and Fossil's assistant become one pattern — a small model fills a small artefact under contract, a deterministic oracle checks it, and a bounded loop repairs it or asks the person — and what would reverse each part.
This page is a direction, and most of it is built. The loop is @kanzo-tech/ai/task
(defineTask, runTask, propose, verify), a screen's list is useTask, the model is
localModel — in the page, served from the host's origin, held to a
schema or a grammar — and Ask is a Task behind the chat, with its offers and
its chips. The model writes every field as a path from the type it measures, and @kanzo-tech/mosaic's
JoinResolver resolves the paths. What is still to come is keasy's assistant on these parts, and a model of our own.
The decision record this page was drawn from is the Kanzo Tasks design page of 12 October 2026, and
where the two disagree this page is the one that gets fixed.
Two things in the platform asked a model to decide something. Ask (@kanzo-tech/ai/data)
let an agent pick a relation, its fields, an aggregate and a filter in one call, among every
relation answerRelationsOf enumerated. Fossil's assistant (keasy's new-graph wizard) asks one
call, writeProgram, for a whole program, which the checker reads only afterwards. Both are an
agent with a free hand and an oracle that runs too late.
They become one pattern, a Task: the model fills a small artefact whose shape is a JSON Schema,
an oracle that is a fact of the data or of the program checks it, and a bounded loop sends the typed
failures back or turns them into a question for the person. Keasy's side — the assistant, a guided
chat, and time to smile — and fossil's side — fossil-profile and the mapping grammar — are on their own
sites and link here.
The premise: a small model, in the browser
The system is designed to work with a small local model. A large or remote model is an optional
improvement, never a requirement. It is the premise that makes the rest make sense — bounded
Tasks, compute steps, constrained decoding — and it changes what the evaluation set is for: it
does not decide whether a small model is good enough, it points at which Task is not yet bounded
well enough. Every failure is a lead to move one more fact into a deterministic step.
The model runs in the page through WebLLM, behind the AI SDK's
LanguageModel by way of @browser-ai/web-llm. What went with it: agentgateway, its Postgres and
the local-model profile (services/ai), createGateway, the realm's ai-gateway API and the
per-alias context variables — and the exception to your data does not leave the browser, a
question that travelled to the gateway. A product's BFF forward to it goes when the product moves.
What would reverse it: a Task that, after everything computable has been moved into compute
steps, still fails the evaluation set with a small model. Then that Task, and only that Task, takes
an optional remote model behind an authenticated proxy.
A Task has four ports
Context ──limits──▶ Model ──proposes──▶ Verify ──diagnostics──▶ Repair
closed vocabulary local by default deterministic oracle bounded policy
Contract (a JSON Schema) ──constrains──▶ Model- Context is a closed vocabulary computed from the data: the linker's candidates for Ask, the Profile's columns, keys and inclusions for the assistant. Nothing is described to the model that the model is not allowed to name.
- Contract is the artefact's JSON Schema, with enums from the context. The provider decodes against it, so a name that does not exist cannot be written.
- Verify is a list of oracles, each a fact: reachable, compiles, non-empty, conforms to the shape, answers the competency question.
- Repair reads the oracles' typed diagnostics. A
fixableone goes back to the model with its location, a bounded number of times. Adecision— two candidate keys, a property with no column — goes to the person as options, never as free text; a decision that declares a safe default is taken, recorded as an assumption, and can be changed later.
The states — linking, drafting, verifying, repairing, needs-input, done, failed —
travel as data parts of the message, the AI SDK's standard form for application data, so useChat
does not change. Presenting leaves the Task and becomes the UI layer's: a Task is tested without
React and runs the same on a server or in a browser.
What would reverse it: a free agent with the same small model that matches the Task's execution accuracy across the whole evaluation set.
Three kinds of step
A step is deterministic when its answer is a fact of the data or the program — columns, keys, paths in the join graph, whether it compiles, whether a competency question returns rows. Interpreting what someone meant is not a fact, and turning it into hand-written rules (written synonyms, heuristics over the question) only moves the guess into code, where it is more fragile and nobody evaluates it.
| Kind | When | Examples |
|---|---|---|
compute | The answer is a computable fact | Profile, the value linker, the resolver, the checker, a dry run |
propose | Intent must be interpreted or a design chosen | Ask's planner, competency questions, shapes, the mapping |
decide | More than one answer is valid, and it is the person's | The key of an award; counting universities or awards |
The working rule: every time a propose fails on the evaluation set, look at whether part of what it
decided was a fact. If it was, it moves into a compute and the propose shrinks.
What would reverse it: nothing found. The rule is a definition of where a step belongs, and it is reopened per step, by the evaluation set, not as a whole.
Ask: from agent to form
The model fills the spec, and the spec says its joins. It writes every field as a dotted path
from the type it measures, as Malloy writes one from its source — affiliation.university.name,
category.name — and JoinResolver turns the paths into the relation the answer shows and the
semi-joins it is narrowed by. Nothing is chosen: a path either names steps the join graph has, or it
is a diagnostic at its pointer before anything runs. A follow-up such as and in Germany? is still a
patch to the previous spec, one more where.
Question ─▶ Linker ─▶ Planner ─▶ JoinResolver ─▶ tileQuery ─▶ Check ─▶ Chips
words→ fills paths → relation the tile's empty, editable
types paths filters → crossing SQL reachThe model touches only the planner, which writes paths from a closed enum. Every chip opens its computed alternatives, and editing one re-checks without the model. The sentence in the reply is written from the rows, not by the model.
This replaced the agent's relations on Asking data
and its token budget on Asking within a context:
with a linker the context is small and closed, and there is no budget to fit into. It replaced,
too, a resolver that chose the relation — resolveRelation, the shortest path from the measured
type covering every type a field named — which the owner rejected on 13 October 2026 as a heuristic:
base it on existing things, don't invent.
A field is a path from what is measured
The reference is Malloy. A Malloy source declares its joins, and a query names a joined field by
its dotted path from the source — group_by: carriers.nickname, aircraft.aircraft_models.manufacturer
— in group_by, aggregate and where alike; a join that no field names is not in the SQL. Here the
join graph is the source's declarations, already: JoinGraph's edges are the joins, and the measured
type, the spec's of, is the source. So a spec's every field — show.x, a measure's field,
color, facet, trend, a table's columns, each where[].field — is a path:
| Segment | Means | Reference |
|---|---|---|
category | the out-edge labelled category that leaves the type reached so far | Malloy's join name |
^laureate | the in-edge labelled laureate, walked backwards: the awards of a laureate | SPARQL 1.1's inverse path ^iri (§9.1); ShExJ's inverse: true, ^ in ShExC |
Award_category_Category | the edge by its name, where two edges of one label leave the same type the same way | Malloy's join names are unique in a source; fossil's edge name is unique because the label repeats |
name (last) | the column of the type reached | Malloy's field |
The empty path is the measured type: gender on of: "Laureate" is the laureate's own. Paths are
walked from the root, never back along the edge just taken, within three hops.
A relation's column is named by its path, everywhere. relationQuery names a column by the path
that reaches it from the root — year, affiliation.name, affiliation.university.name, and on a
self-join knows.firstName — so the name Ask's model writes is the name the SQL has, the name a
dashboard's tile stores, and the name a chip publishes a clause on. The root's key is on the empty
path, so it keeps its own bare name, which every crossing depends on, for the reason
the graph's page
gives. It replaced <alias>.<column> — University.name, and Person2.firstName for a type met
twice — which gave a field two spellings, one for the model and one for the data, and gave a self-join
a name that is not a type.
The schema offers only paths the graph has. The spec is the one property, spec, of an object,
and its schema is an anyOf with one branch per type the linker offers, each of: { const } beside
its own enum of paths under $defs — the same discriminated anyOf the schema already writes for
show's three kinds — so a provider that decodes against it cannot write a path from one type on an
answer about another. The object is there because a tool's input schema must be one: Anthropic's and
OpenAI's APIs refuse a root anyOf, and the branches were once the root. The model writes
{ spec }; the schema's validate hands the Task the spec, so nothing downstream sees the wrapper. A provider that does not
constrain is checked by JoinResolver.relation and field, whose misses — join/no-step,
join/no-field, join/branches — are diagnostics at the field's pointer, sent back for a repair.
A count may name a path too, { op: "count", path: "affiliation" }, from the steps the enum's paths
pass through (Analytics).
What the path removes. Two questions the resolver answered for the model are now written by it, and so stop being anybody's guess:
- Which way. Prizes by country on the Nobel corpus has two countries two hops from a prize. The
model writes
laureate.birthPlace.nameoruniversity.addressCountry.name, and that is the answer; the chip on that field offers the other first, because both are in the enum and both are checked. Under the resolver this was a tie the reader was asked about before anything ran. - Which step, when a type repeats. On
Person>knows>Person, a condition on the typePersoncould not say whether it meant the person or the one they know: the linker offeredPerson.firstNamealone, so people who know an Ann was not expressible. As paths,firstNameandknows.firstNameare two strings.
What would reverse it: an evaluation set on which the small model, writing paths under the enum,
answers a shape of question worse than the resolver's choice did — then that shape, and only it, gets
a compute step back, written as a rule with an evidence line, not as a search. Or an enum too large
to compile at the linker's widest context: then the field becomes a grammar —
segment ("." segment)* "." column, its segments closed — which a Task may already declare instead of
a schema. Or a provider Ask must run on that refuses an anyOf anywhere in a schema, not only at its root:
then the branches fold into one object with every type's paths, and the oracles hold each path to
its of, as the retired answer schema did.
Held by packages/ai/src/data/ask.test.ts, "is one branch per type offered, each its own enum of
paths from it"; "hands the model an object at the schema's root, and reads the spec out of it"; "says what the joins refuse: no such step, no such field, two branches, a chip out of
reach"; "asks nothing when two paths reach one field: the model's path is the answer's".
Physics awards by university, as the model writes it
{
"of": "LaureateAward",
"where": [{ "field": "category.name", "in": ["Physics"] }],
"show": { "kind": "chart", "type": "bar", "x": "university.name", "y": { "op": "count" } }
}In Malloy, the same question:
run: laureate_award -> {
where: category.name = 'Physics'
group_by: university.name
aggregate: award_count is count()
}JoinResolver.relation("LaureateAward", ["university.name"]) is the relation the answer shows,
LaureateAward>university>University, whose column is the path itself. JoinResolver.field(relation, "category.name") finds that category is not a step of it: the path leaves it at the root, so it is
read through the branch LaureateAward>category>Category, column category.name, crossed at the
root's key — dense_id IN (SELECT dense_id FROM (…) WHERE "category.name" IN ('Physics')) — and the
bars count count(DISTINCT dense_id), each prize once per university.
The inverse, and a filter on a many branch. Laureates with a Physics prize, by gender:
{ "of": "Laureate", "where": [{ "field": "^laureate.category.name", "in": ["Physics"] }], … }. The
shown relation is Laureate alone; the condition is read through Laureate<laureate<LaureateAward>category>Category
and crossed at the laureate's key, so a laureate with two Physics prizes is counted once.
A filter off the shown path is crossed in, not joined
JoinResolver.crossing, the semi-join the page already uses. A condition whose path leaves the
shown relation is read through the branch from the step it leaves at, and narrows the answer as a chip
on another relation narrows a tile: step key IN (SELECT branch root key FROM branch WHERE …). A
condition on the shown relation is its clause, as it is.
Malloy's way, and what it costs here. Malloy joins every path a query names into one query —
left outer joins, join_many declared on the join — and keeps every aggregate right over the fan-out
by computing it symmetrically: count() counts the source's rows, and sum and avg over a joined
row are deduplicated by key. Its DuckDB dialect writes a symmetric sum as
(SELECT sum(a.val) FROM (SELECT UNNEST(list(distinct {key: k, val: v})) a)) per group (verified in
packages/malloy/src/dialect/duckdb/duckdb.ts, 13 October 2026).
Semi-join (crossing) | Joins and symmetric aggregates | |
|---|---|---|
| Rows the measure sees | the shown relation's, never multiplied by a filter | multiplied by every many-branch, then deduplicated |
| Measures | measureExpr as it is, all eight | a symmetric variant of each; Malloy lists only count, sum and avg as having one |
| mosaic-sql | InOpNode over a ScalarSubqueryNode, what crossing builds | no node for list(distinct {…}): raw sql in every measure |
| DuckDB-WASM | a hash semi-join on keys, one per branch | the fanned rows held per group in lists, inside the page's memory |
| The page | one way to narrow by another relation: a chip and a condition are the same predicate | a second way beside the crossfilter's |
Where the two agree is every question a filter asks — awards with a Physics category is the same set of awards either way, and a count of them is the same number. Where they differ is a measure on the branch, which is no longer a filter: it is a second shown path.
Two shown paths must be one path. The relation an answer shows is one path — what a tile is keyed
by, what a clause is published on, what relationQuery compiles. Paths that diverge
(x: category.name, color: university.name) are a join/branches miss, said before anything runs.
What would reverse it: a condition whose meaning needs the branch row and the shown row to be one
row — awards by university where that university's laureate is a woman — which a semi-join on the
branch's root cannot say; or a question that shows two branches. Either makes the relation a tree,
and a tree is Malloy's: joins, join_one/join_many declared from the shape's cardinality, and
symmetric aggregates.
Held by packages/ai/src/data/answer.test.ts, "crosses a condition off the shown path in at the
root's key, counting each person once"; packages/mosaic/src/crossfilter.test.ts, "crosses a
condition off the relation in at the step its branch leaves, not the first of its type".
JoinResolver is the one door to the joins
Resolving a path and crossing a clause are one thing: resolving joins over the graph. So they are
one class, Mosaic's own resolver extended (crossfilter.ts), sharing the graph and one notion of a
step and its key: steps(relation) — each step's path, type and key, which folded
relationSteps, relationIdentities and relationRootKey — paths(root, hops), relation(root, shown), field(on, path), at(to, from) and crossing(from, predicates, to, at?). The dashboard
reaches it through the page's selection (crossfilterOver); Ask reaches the same instance through the
same selection, or one over its graph where there is no page. resolve.ts is deleted, and nothing
of the shortest-path search survives — the walk that enumerates paths for the schema is a walk; it
chooses nothing. crossing gained at, because a branch says which step it leaves from: on
Person>knows>Person a condition on knows.knows.firstName is crossed at the knows step's key,
where the type-matching rule would have taken the root. The page's crossfilter passes none, and is
unchanged.
What would reverse it: a caller of the joins that is neither a page nor an answer — a second resolver with no selection — which splits the path half into a module of its own beside the crossing half, not a second concept.
Held by packages/mosaic/src/crossfilter.test.ts, "names each step of a relation by its path from
the root, with its key: the root's bare"; "finds the one relation the shown paths lie on, each path
its own column there"; "says why a path is not the graph's, at the segment that is not".
Two paths of one length are the model's, with the other on the chip
The model's path is the answer, and the chip on that field offers the other path of the same
length to the same column of the same type first — laureate.birthPlace.name beside
university.addressCountry.name. Nothing is asked before the answer is read. What would reverse
it: the evaluation set showing readers take the chip's alternative more often than they keep the
model's path — then that shape asks, as a decision with the two paths as its options, through the
question part (Asking data).
Held by packages/ai/src/data/ask.test.ts, "offers the other of two paths of one length to the same
field first".
Titles name no plural
A tile's title is {measure} by {field} — Count by university.name, Mean age — and Ask's
words are the paths it wrote. No title in any language then asks for a plural, and no labelling
vocabulary carries one: a plural is always derived, and derived English is wrong outside English and
for every irregular noun. The words a field is read by — its rdfs:label, in the page's language —
come with What a field is; until then a field is read by its path.
What would reverse it: a labelling vocabulary that carries a plural — then it is read, not derived.
The sentence after a chart is a minimal template
A template built only from Intl and the data's own words, with its punctuation the host's to
translate. The figure for a stat; for a chart of a measure by x, the leading two groups of bars and
their value, ties named together through Intl.ListFormat, numbers through Intl.NumberFormat:
Univ. of California: 12; Harvard University and MIT: 9. No verb that needs agreement, no plural
noun, so nothing in it is English but its two separators — the one between a label and its value, the
one between groups — and those are entries of useAskChat's translations, as every other word the
library writes is (Naming). A table or a plot
of rows gets none: the card says it. It is written once, by Ask's transport, from the rows the
answer read; it is never stored in the tile, so a tile added to a dashboard carries no sentence.
No reference writes one: Looker's Conversational Analytics and Power BI's Q&A word a reply with a model, which is the free hand Tasks take away. So the template is the least that says the answer without the chart.
What would reverse it: readers, in the evaluation set or in keasy, reading the sentence as wrong where the chart is right — a tie named as a lead, a figure without its unit. Then the sentence goes, and the chart and its title say it alone; a model's wording comes back only as a Task with an oracle that checks every figure it writes against the rows.
Held by packages/ai/src/data/ask-chat.test.ts, "words a bar chart's leading groups with Intl, ties
named together, through its translations".
Discarded, and what would bring each back
resolveRelation's choice of the shortest path covering the spec's types and the page's clauses, and #199's branches from the nearest step. A search that guessed what a question meant wherever two ways existed, and could not name a step of a repeated type. Back if the evaluation set shows the model writing paths worse than the search chose them, for a shape of question — then that shape gets acomputestep, insideJoinResolver, not a module beside it.- Joins and symmetric aggregates for a filter off the path (Malloy's, LookML's). Back if a condition needs the branch row and the shown row to be one, or an answer shows two branches.
<alias>.<column>asrelationQuery's column names, with Ask translating its paths into them. Two spellings of one field, and a name on a self-join that is no type. It was the cheaper change — no saved dashboard's fields renamed — and the owner took the rename. Back if a column name must be a valid identifier somewhere a path cannot go; none has been found, since every reader quotes it.- A choice put to the person between two paths of one length. It gives back the search, as a question. Back as the section above says.
- English pluralisation (
+s,-ies) of a word, and a column namedname,labelortitlestanding for its type. Back if a labelling vocabulary carries a plural, or corpora without IRIs are the common case — and then as a host's labels first. toLocaleString("en")and a list joined by hand. The platform has both, in every locale. Nothing found that would bring them back.
Suggest and complete stop being the split
Today chat and complete mix three things: a verb, a model size and a context window. The unit
is the proposal. Proposal already has the shape of the LSP's InlineCompletionItem: with a range
at the cursor it is a continuation, with the whole value a replacement, with no range a new item.
What changes between surfaces is how a proposal is shown and which oracle backs it, not what is
generated.
| Form | Target | Where |
|---|---|---|
| Ghost text | a range at the cursor | free fields; Tab accepts, word by word with ⌘→ |
| Card | no range, a list | competency questions, shapes, mapping rows |
| Chip | a replacement of one field of a spec | Ask's rephrasing; each chip opens its alternatives |
| Choice | a decision diagnostic | an ambiguity the oracle found; never free text |
There is one Proposal, the Task's (decided 13 October 2026). Assist's { text, range } was
the Task's proposal of a string with its Target under another name: a continuation inserts at the
cursor, a candidate replaces the value, a list's new item is added — the LSP shapes Target already
names, and the first reader Target has had. So Assist has no proposal of its own. It asks for its
candidates through propose, the candidates Task, and each is a Proposal<string> whose target
is { replace } over the value or { add } in a list; its continuation is the same Proposal, whose
target is { insert } at the caret, and it still streams, because ghost text is drawn as it
arrives — Assist stays the presentation of a field, and its ghost text logic stays as it is. A
proposal may carry the model's rationale, which is what a chip's or a pill's tooltip reads.
A list's offers were already a Task's — propose, and the pills Chat draws as proposals — and
Chat takes them whole: pressing one sends its value, never its words, so a starter question is
not re-read by the model. A string is the message's text; any other value is sent as the message the
host's message makes of it, and Ask's offers send their spec with askEdit, which the chat asks as
it is. A Task's model is resolved by the Task's name in one place. AssistEvent covers every form, with the Task and two more kinds: edited (a chip or a
card corrected, with the final value) and chosen (a decision).
The API
A small declarative surface on top; underneath, only parts that already exist. Whoever writes a Task declares what the model knows, what it may return and how it is checked. Whoever writes a screen asks for proposals and presents them. Nobody writes a loop or deduplicates text.
import { defineTask, runTask, propose, verify } from "@kanzo-tech/ai/task";
import { useTask } from "@kanzo-tech/ai";
import { localModel, loadLocalModel, localProvider, setLocalProvider } from "@kanzo-tech/llm/local";
// A Task: what the model knows, what it may answer, and which facts check it.
export const requirements = defineTask({
name: "requirements",
context: (input) => vocabulary(input.profile), // compute: the closed list it may name
instructions: REQUIREMENTS,
prompt: (input, names) => `${input.domain}\n${names.join(", ")}`,
output: (_, names) => questionSchema(names), // the schema, its enums from the context
verify: [answerable], // an oracle: a fact of the data, never the model's opinion
});
// An artefact that is text in a language declares its grammar instead of a schema.
export const mapping = defineTask({ name: "mapping", grammar: (input) => mappingGrammar(input).grammar, verify: [compiles] /* … */ });
// Once, at the composition root: the models the host's origin serves under /models.
setLocalProvider(localProvider({ baseURL: "/models", worker }));
// The model — the first one served — loaded when the screen opens.
const model = localModel();
void loadLocalModel();
// A screen: a list of checked proposals, and the person's edits checked with no model.
const { proposals, state, propose: ask, edit, dismiss } = useTask(requirements, input, { model });
// One artefact, repaired against the oracles until it holds or the attempts run out.
const proposal = await runTask(mapping, input, { model, onState });A Proposal carries its value, the Target it lands at — an LSP TextEdit or a JSON Pointer —
and a Verdict: accepted (with the decisions it assumed), its Diagnostics, or a Decision in
the shape of MCP's elicitation, options and never free text.
Under the hood
Every name above is a thin layer over something that exists. If one of those pieces changes tomorrow, its row changes, not the API.
| Ours | Over | Dependency |
|---|---|---|
defineTask, runTask | @kanzo-tech/llm's generate — generateText with maxRetries: 0 — and Output.object, which WebLLM constrains with XGrammar, inside a loop of our own: generate, verify, retry with the diagnostics until the attempts run out — Instructor's pattern | ai, already a peer |
a Task's grammar | the same call with providerOptions.kanzo.grammar, which localModel hands WebLLM's engine as response_format: { type: "grammar" } | — |
propose | Output.array of the same schema, each element checked on its own | — |
useTask | React state over propose and verify; never asks on mount | — |
useAskChat | the AI SDK's useChat with a ChatTransport that writes through createUIMessageStream, the states as its data parts | — |
Verdict | the LSP's Diagnostic; Decision in the shape of MCP's elicitation | types only |
localModel | @browser-ai/web-llm over WebLLM, one engine per model per page, resolved by id through one registry in the AI SDK's customProvider shape; what is served is WebLLM's own AppConfig | optional peer of @kanzo-tech/llm/local |
Why no submit tool. @browser-ai/web-llm 3.0.4 constrains JSON and not tools: structured
output sends response_format: json_object with the schema and WebLLM decodes against it, while
tools are described in the prompt and parsed out of the text. So defineTask generates with
Output.object and loops itself, and a small model cannot return a malformed call. The one artefact
that is not JSON is a Fossil mapping, and it is held by a grammar fossil projects from grammar.bnf
with the names closed — so it cannot be malformed either.
Not built, because nothing has needed them. The direction named four more: defineFlow, to
chain the assistant's steps; defineModels, to resolve a model per Task; useTaskChat, a chat
over any Task; and evaluate, an evaluation harness in @kanzo-tech/testing. A screen chains its
Tasks as an async function does, the page has one model, Ask is the one Task in a chat, and Ask's
evaluation set runs in its own runner, packages/ai/eval (Testing). What would reverse it: a second caller for any one of them —
two flows written the same way, two Tasks that want different models, a second Task in a chat, a
second product's evaluation set.
Where each dependency lives
The third party is a peer, and ours is only the door it lacks. Mosaic's surface is re-exported by
@kanzo-tech/mosaic, because one copy of Mosaic on a page is what makes one coordinator, and no host
imports @uwdata/* for anything else. The AI SDK's is not re-exported by @kanzo-tech/llm: ai
is a required peer every host and every package already imports by name, so a re-export was a second
way to write one import, and nobody used it — what the house's own code reached for through it, a
host reached for from ai. What llm carries is the two doors through which every model call the
library makes passes, stream and generate, each asking once
(the failure rule). Two
copies of WebLLM would be two engines and two models in GPU memory.
What would reverse it: a name of the SDK's that a host must not import from ai — one whose version
must be the library's, as Mosaic's coordinator is. Then that name is passed through, with the reason
beside it, and the rest still is not.
Held by packages/llm/src/index.test.ts, "carries the two doors and the failure, and none of the AI
SDK's names".
| Package | Third-party peer | What it carries |
|---|---|---|
@kanzo-tech/llm | ai, required | stream, generate, LlmError, ModelLoad |
@kanzo-tech/llm/testing | ai's own ai/test; @kanzo-tech/testing, optional, for the script and its answering | the one scripted model double (Testing) |
@kanzo-tech/llm/local | @browser-ai/web-llm, @mlc-ai/web-llm, optional | localModel (resolved through one registry, a grammar per call), loadLocalModel, localProvider and setLocalProvider — every model the host's app-config.json lists — serveLocalModel for its worker; llm/unavailable without WebGPU or with nothing served. It names no test: a test registers its provider in the registry (Testing) |
@kanzo-tech/ai/task | — | the types, defineTask, runTask, propose, verify |
@kanzo-tech/ai | — | useTask, Assist, Chat |
@kanzo-tech/ai/data | @kanzo-tech/mosaic, optional | ask, useAskChat, askOffers, AskChips, AnswerCard |
/local is a subpath for the reason the optional-peer door exists: a static
import of WebLLM from a root barrel breaks import { Assist } for every host that did not install it.
Everything in the browser, the model too
The deterministic parts — linker, resolver, Profile, checker, executor, DuckDB — and the model run in the client. Outside it stay two things, and the AI depends on neither: file storage, with credentials keasy vends as today; and evaluation in CI, with the same weights on native llama.cpp.
| Engine | Today | Target |
|---|---|---|
| DuckDB-WASM | its own worker (mosaic), no threads | unchanged |
fossil-wasm: compiler, checker | the page | unchanged, plus Profile and the mapping grammar in the same module |
fossil-df-wasm: executor | the page's main thread — confirmed 12 October 2026, below | a worker of its own |
| WebLLM | — | its worker (WebWorkerMLCEngine), memory on the GPU |
| A fallback without WebGPU | — | none at first, below |
No fallback without WebGPU, at first. @browser-ai/transformers-js 3.0.4 warns that
responseFormat is unsupported, so a CPU fallback through it generates unconstrained JSON that only
the oracles and retries catch; llama.cpp in WASM (wllama) constrains with grammars but has no AI SDK
provider. Without WebGPU there is no assistant and no Ask, said plainly; the rest of keasy works the
same. WebGPU is in Chrome and Edge (Windows, macOS, ChromeOS), Safari 26 and Firefox on Windows and
ARM Macs; the gaps are mostly Linux and Android outside Chrome on Qualcomm or ARM GPUs.
What would reverse it: the evaluation set showing that unconstrained output passes the oracles nearly as often, or a CPU provider that constrains. A threaded WASM fallback also needs cross-origin isolation, which keasy can afford — below.
Decisions, and what would reverse each
| Decision | Why | What would reverse it |
|---|---|---|
| Ask's set is small and by shape: Nobel, plus SNB for what Nobel lacks | What it must cover is the shapes of a question, not a count; two corpora from two domains are enough to catch a linker fitted to one vocabulary, and the rest is the readers' failures | A Task that passes the set and fails a third corpus's questions of a shape the set has; then that corpus joins the set |
| No separate phase of measuring today's system | It is known to fail and is being replaced; the evaluation set is needed anyway, as tests, gate and distillation data | That the set cannot be built without measuring today's system first; then one more pass, not a phase |
| Every piece on a standard or a reference library | Less code of our own, known behaviour, someone else's documentation | A reference that requires a server runtime or persistent state not needed here; then we take its shape, not its package |
| Generate, verify, repair instead of a free agent | The model decides little and every decision has an oracle | A free agent with the same small model matching execution accuracy across the set |
| The relation is the paths the model wrote, resolved | A path is checked before anything runs, and two ways to one field are the model's to write, not the code's to guess | The set showing the model writing paths worse than a search chose relations, for a shape of question |
| The mapping is Fossil, written under a grammar with the names closed | The language is statically typed: the grammar stops a name that does not exist and the checker judges the rest, so a format of our own in between would only be a second language to keep — it was built, and reverted | A small model under the grammar failing the competency questions where a spec it filled and an emitter wrote passes them |
| Profile replaces introspect | One way to read sources, for the compiler, the assistant and the linker | A host that cannot afford the statistics; the answer is a mode of Profile, not a second package |
| Profile is sans-IO, inside the wasm closure | One implementation for browser and LSP, watched by the wasm gate | A query only one host can run; that query is marked per host, the module is not duplicated |
| No fallback without WebGPU, at first | The one CPU provider with an AI SDK binding does not constrain JSON | The set showing unconstrained output passes nearly as often, or a CPU provider with constraints |
| Everything in the browser, the model too | The data stays, and the AI depends on no server | An oracle with no wasm32 build at reasonable cost |
| Designed for a small local model | No API dependency, the data stays, and it forces each Task to be bounded | A Task that, with everything computable moved out, still fails the set with a small model |
| No remote model in the API | One way to reach a model: no keys in the browser, no forward on the server | A customer who cannot run the local model and accepts that the question leaves the browser; then a LanguageModel behind an authenticated proxy |
One Proposal, the Task's, for a field, a list and a chat | A value, where it lands and what backs it is one idea; Assist's { text, range } was the same thing under a second name, and an offer sent by its words was checked by the oracles and then asked again of the model | A field whose proposals need what a Target cannot say — a command to run on accept, a snippet with tab stops; LSP's own extension of the item is the place to look first |
One local multi-task model, named per Task in customProvider | One download; changing a Task's model is configuration | A Task whose quality needs a model of its own; then that Task gets its entry |
What was checked before any code
Three things that could have sunk the design, checked on 12 October 2026:
fossil-profilesits inside the wasm closure. A sans-IO skeleton —Profiler::new,next_query,feed,finish, depending onsmol_stralone — wired as a dependency offossil-wasm:cargo xtask wasm-checkderived 18 crates withfossil-profileamong them and passed.fossil-introspectis not in the closure (it isfossil-lsp's, and a dev-dependency offossil-df), so replacing it moves logic into the gate on purpose. The skeleton was not committed: the crate lands with its first query.- The executor runs on the page's main thread. keasy's
startRunimports@fossil-lang/executorand itsrunconstructsFossilExecutorin the calling realm; nothing in keasy'sweb/srccreates aWorker. Hence a worker of its own in phase 2. - Cross-origin isolation is cheap for keasy. With
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corpadded to keasy'snext.config.ts, its e2e ran against the dev stack: sign-in through Keycloak, a graph run to completion in the browser over the dev S3 store, and Discover over its corpus — 14 ofauth.spec.ts' anddiscover.spec.ts' 15 passed, and the one that did not fails the same way with the headers off (a dev-only overlay takes its click). A test that opened Discover readcrossOriginIsolatedtrue andSharedArrayBufferdefined, and saw no request blocked by COEP; a rangedfetchof a presigned object on the S3 store answered 206 withContent-Rangeexposed — CORS, which COEP admits. Keasy's CSP already keeps every script, worker,.wasm, image and font on its own origin. Not proved: a read from Azure, which the dev stack has no emulator for.
Phases
There is no separate phase of measuring today's system: it is known to fail and it is being replaced. The evaluation set is built anyway, because the Tasks need it as tests, as the model's release gate and as distillation data.
- Ask as a Task, with its evaluation set (≈ 42 % of the lines touched). First what measures: the evaluation set — for Ask, one or two cases per shape of question (a figure, a breakdown, a ranking, a where, a date range, one and two hops, a page filter on another relation, two paths of one length to one field, a question with no path, a follow-up, a synonym), each with its spec and the rows that spec reads: about 25 on Nobel, and about 10 on LDBC SNB for what Nobel lacks — self-joins and a large graph; for the assistant, the four examples as they are — one pass of today's Ask with one model, as a reference number, and the WebLLM provider measured on real devices. The set grows with the failures readers hit, not ahead of them; the thousands of pairs a distillation needs are generated and kept only when they pass the oracles. Then the linker, the resolver in mosaic, the Task layer, Ask as a Task and its chips; the agent goes. The base model is chosen by measuring these Tasks, not today's system.
- Profile and the mapping spec (≈ 28 %).
fossil-profilesans-IO in the wasm closure with its two hosts, replacing introspect; the mapping written in Fossil under a grammar whose names are the sources' and the shapes' (a spec of our own was built and reverted — fossil'sdesign/discardedsays why); the executor in a worker. - The assistant, and the gateway retired (≈ 30 %). A guided chat over the
requirements,modelandmappingTasks: the assistant speaks in turns — cards, choices, chips — the person answers by picking or typing, and the conversation carries on into Ask, whose questions are the competency questions. The current wizard goes, and with it the gateway's last consumer.services/ai,createGatewayand the realm'sai-gatewayare already gone from this repository; a product's/api/aiforward goes with its wizard. - A model of our own. One small multi-task model distilled for every Task, the evaluation set as
its gate, versioned and served as shards — in
kanzo-models, outside these repositories.
References
Generate, verify, repair: Self-Debugging and the compiler-in-the-loop line, as Cortex Analyst and Genie do it. Constrained decoding: PICARD for SQL; today XGrammar, llguidance and Outlines. A semantic layer with a spec: Power BI Q&A, ThoughtSpot, NL4DV. Linking: DIN-SQL and CHESS. Competency questions as tests: NeOn and test-driven ontology development. Profiling: unique column combinations (HyUCC) and inclusion dependencies (SPIDER, Metanome). Declaring Tasks: DSPy's signatures and assertions, BAML's typed functions — their shape, not their runtime, since DSPy is Python and BAML brings its own.
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.
The services
Why a capability's server half ships beside its client half, in one repository under one tag; why Compose is the whole of it; and why the service names no application and carries no production configuration.