Kanzo UI
The design of Kanzo UI

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 fixable one goes back to the model with its location, a bounded number of times. A decision — 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.

KindWhenExamples
computeThe answer is a computable factProfile, the value linker, the resolver, the checker, a dry run
proposeIntent must be interpreted or a design chosenAsk's planner, competency questions, shapes, the mapping
decideMore than one answer is valid, and it is the person'sThe 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        reach

The 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:

SegmentMeansReference
categorythe out-edge labelled category that leaves the type reached so farMalloy's join name
^laureatethe in-edge labelled laureate, walked backwards: the awards of a laureateSPARQL 1.1's inverse path ^iri (§9.1); ShExJ's inverse: true, ^ in ShExC
Award_category_Categorythe edge by its name, where two edges of one label leave the same type the same wayMalloy'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 reachedMalloy'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.name or university.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 type Person could not say whether it meant the person or the one they know: the linker offered Person.firstName alone, so people who know an Ann was not expressible. As paths, firstName and knows.firstName are 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 seesthe shown relation's, never multiplied by a filtermultiplied by every many-branch, then deduplicated
MeasuresmeasureExpr as it is, all eighta symmetric variant of each; Malloy lists only count, sum and avg as having one
mosaic-sqlInOpNode over a ScalarSubqueryNode, what crossing buildsno node for list(distinct {…}): raw sql in every measure
DuckDB-WASMa hash semi-join on keys, one per branchthe fanned rows held per group in lists, inside the page's memory
The pageone way to narrow by another relation: a chip and a condition are the same predicatea 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 a compute step, inside JoinResolver, 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> as relationQuery'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 named name, label or title standing 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.

FormTargetWhere
Ghost texta range at the cursorfree fields; Tab accepts, word by word with ⌘→
Cardno range, a listcompetency questions, shapes, mapping rows
Chipa replacement of one field of a specAsk's rephrasing; each chip opens its alternatives
Choicea decision diagnostican 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.

OursOverDependency
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 patternai, already a peer
a Task's grammarthe same call with providerOptions.kanzo.grammar, which localModel hands WebLLM's engine as response_format: { type: "grammar" }—
proposeOutput.array of the same schema, each element checked on its own—
useTaskReact state over propose and verify; never asks on mount—
useAskChatthe AI SDK's useChat with a ChatTransport that writes through createUIMessageStream, the states as its data parts—
Verdictthe LSP's Diagnostic; Decision in the shape of MCP's elicitationtypes 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 AppConfigoptional 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".

PackageThird-party peerWhat it carries
@kanzo-tech/llmai, requiredstream, generate, LlmError, ModelLoad
@kanzo-tech/llm/testingai's own ai/test; @kanzo-tech/testing, optional, for the script and its answeringthe one scripted model double (Testing)
@kanzo-tech/llm/local@browser-ai/web-llm, @mlc-ai/web-llm, optionallocalModel (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, optionalask, 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.

EngineTodayTarget
DuckDB-WASMits own worker (mosaic), no threadsunchanged
fossil-wasm: compiler, checkerthe pageunchanged, plus Profile and the mapping grammar in the same module
fossil-df-wasm: executorthe page's main thread — confirmed 12 October 2026, belowa 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

DecisionWhyWhat would reverse it
Ask's set is small and by shape: Nobel, plus SNB for what Nobel lacksWhat 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' failuresA 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 systemIt is known to fail and is being replaced; the evaluation set is needed anyway, as tests, gate and distillation dataThat 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 libraryLess code of our own, known behaviour, someone else's documentationA 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 agentThe model decides little and every decision has an oracleA free agent with the same small model matching execution accuracy across the set
The relation is the paths the model wrote, resolvedA path is checked before anything runs, and two ways to one field are the model's to write, not the code's to guessThe 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 closedThe 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 revertedA small model under the grammar failing the competency questions where a spec it filled and an emitter wrote passes them
Profile replaces introspectOne way to read sources, for the compiler, the assistant and the linkerA host that cannot afford the statistics; the answer is a mode of Profile, not a second package
Profile is sans-IO, inside the wasm closureOne implementation for browser and LSP, watched by the wasm gateA query only one host can run; that query is marked per host, the module is not duplicated
No fallback without WebGPU, at firstThe one CPU provider with an AI SDK binding does not constrain JSONThe set showing unconstrained output passes nearly as often, or a CPU provider with constraints
Everything in the browser, the model tooThe data stays, and the AI depends on no serverAn oracle with no wasm32 build at reasonable cost
Designed for a small local modelNo API dependency, the data stays, and it forces each Task to be boundedA Task that, with everything computable moved out, still fails the set with a small model
No remote model in the APIOne way to reach a model: no keys in the browser, no forward on the serverA 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 chatA 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 modelA 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 customProviderOne download; changing a Task's model is configurationA 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-profile sits inside the wasm closure. A sans-IO skeleton — Profiler::new, next_query, feed, finish, depending on smol_str alone — wired as a dependency of fossil-wasm: cargo xtask wasm-check derived 18 crates with fossil-profile among them and passed. fossil-introspect is not in the closure (it is fossil-lsp's, and a dev-dependency of fossil-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 startRun imports @fossil-lang/executor and its run constructs FossilExecutor in the calling realm; nothing in keasy's web/src creates a Worker. Hence a worker of its own in phase 2.
  • Cross-origin isolation is cheap for keasy. With Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp added to keasy's next.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 of auth.spec.ts' and discover.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 read crossOriginIsolated true and SharedArrayBuffer defined, and saw no request blocked by COEP; a ranged fetch of a presigned object on the S3 store answered 206 with Content-Range exposed — 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.

  1. 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.
  2. Profile and the mapping spec (≈ 28 %). fossil-profile sans-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's design/discarded says why); the executor in a worker.
  3. The assistant, and the gateway retired (≈ 30 %). A guided chat over the requirements, model and mapping Tasks: 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, createGateway and the realm's ai-gateway are already gone from this repository; a product's /api/ai forward goes with its wizard.
  4. 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.

On this page