genui_gen
Annotate a Flutter widget you already have. The
genui CatalogItem an agent composes
against — JSON schema, widget builder and few-shot example — is derived from
the widget's constructor, so it cannot drift from the widget it describes.
See it end to end, with a recorded agent session you can step through →
Or see all of it in one app: Quincena is personal finance where an agent composes every answer from a catalog generated with this package. It runs in the browser, no key needed.
In one screen
You write this:
@GenUiWidget(description: 'A product card with price and image.')
class ProductCard extends StatelessWidget {
const ProductCard({super.key, required this.title, required this.price, this.onTap});
/// Product name.
final String title;
/// Price in USD.
final double price;
/// Fired when the card is tapped.
final VoidCallback? onTap;
// ...
}
build_runner derives the schema from the constructor — the property names
are the parameter names, the required list is the set of non-nullable
parameters without defaults, the descriptions come from the doc comments — and
writes the builder and the example too. The model may then send:
{
"id": "root",
"component": "ProductCard",
"title": "Noise-cancelling headphones",
"price": { "path": "/cart/0/price" },
"onTap": { "event": { "name": "onTap" } }
}
Every property takes a literal or a {"path": ...} binding, because the
generated builder composes genui's own BoundString, BoundNumber,
BoundBool, BoundList and BoundObject. Actions dispatch a
UserActionEvent exactly the way genui's core Button does.
Rename the parameter and the generated part changes in review, or the build fails. There is no second source of truth to keep in sync.
Install
dependencies:
genui: ^0.10.0
genui_gen: ^0.14.0
dev_dependencies:
build_runner: ^2.15.0
genui_gen_builder: ^0.13.0
This package is the runtime half of the pair: the annotations, and the helpers
the generated code calls. The generator itself lives in
genui_gen_builder and belongs in
dev_dependencies.
The generated code is a part of your file and builds its schema with
S.object(...), so it needs that name in scope. genui_gen re-exports S,
Schema and ObjectSchema from json_schema_builder for exactly that, which
is why you do not depend on it directly. If S collides with another
one-letter name in a file — a generated localization class, say — import
genui_gen there with hide S.
Annotate
lib/widgets/product_card.dart:
import 'package:flutter/material.dart';
import 'package:genui_gen/genui_gen.dart';
part 'product_card.genui.dart';
@GenUiWidget(description: 'A product card with price and image.')
class ProductCard extends StatelessWidget {
const ProductCard({
super.key,
required this.title,
required this.price,
this.imageUrl,
this.onTap,
});
/// Product name.
final String title;
/// Price in USD.
final double price;
/// Optional image URL.
final String? imageUrl;
/// Fired when the card is tapped.
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => /* ... */ const SizedBox();
}
dart run build_runner build
Register the catalog
The builder also writes lib/genui_catalog.g.dart, holding every annotated
item in the package, sorted by name. Registering a catalog stays one line
whatever the app grows into:
import 'genui_catalog.g.dart';
final catalog = genUiCatalog.copyWith(
newItems: BasicCatalogItems.asCatalog().items.toList(),
newFunctions: BasicCatalogItems.asCatalog().functions.toList(),
);
final controller = SurfaceController(catalogs: [catalog]);
The catalog's id comes from build.yaml, because it names your catalog to
everything outside the build — the agent that composes against it, the client
that renders it — which is not something a generator can invent:
targets:
$default:
builders:
genui_gen_builder:genui_catalog:
options:
catalog_id: com.example.app
Keep genui's basic catalog in the mix: the generated examples reference the
core Text component for child widgets.
What a property can be
| Dart parameter type | Schema emitted | Passed to the constructor as |
|---|---|---|
String, String? |
A2uiSchemas.stringReference |
the resolved String |
int, double, num (+?) |
A2uiSchemas.numberReference |
converted with toInt() / toDouble() |
bool, bool? |
A2uiSchemas.booleanReference |
the resolved bool |
any enum (+?) |
A2uiSchemas.stringReference(enumValues: ...) |
E.values.asNameMap()[value] |
List<String> (+?) |
A2uiSchemas.stringArrayReference |
List<String> |
List<int>, List<double>, List<num> (+?) |
A2uiSchemas.listOrReference(items: S.number()) |
entries converted per element |
List<E> (+?) for an enum E |
listOrReference carrying the enum's names |
one E per entry; an unknown name is dropped |
a @GenUiData class (+?) |
oneOf of its object schema, a data binding and a function call |
the decoded instance |
List<T> (+?) where T is @GenUiData |
A2uiSchemas.listOrReference(items: <T schema>) |
one decoded T per entry |
Map<String, V> (+?), V a scalar, an enum or Object? |
an object schema with additionalProperties |
the coerced Map |
Widget, Widget? |
A2uiSchemas.componentReference |
ctx.buildChild(id) |
List<Widget> (+?) |
list of component references, or a template — see below | one ctx.buildChild per id or per entry |
VoidCallback, void Function() (+?) |
A2uiSchemas.action |
a callback that dispatches a UserActionEvent |
void Function(T) marked @GenUiWrites |
nothing; it is not a property | a callback that writes the user's value into the data model |
Key? key, super.key |
skipped | not passed |
| anything else | build error naming the widget, parameter and type | — |
A required property that arrives missing or malformed does not throw during
build. The builder substitutes a fallback and reports the problem once per
component through ctx.reportError, as an A2uiValidationException, so the
model sees the widget and property names.
The full rules live in
genui_gen_builder's README.
Controls the user operates
A plain property is read-only: the model puts a value there and the widget
displays it. A control has to report the new value back, which in A2UI means
writing it into the surface's data model — what genui's own TextField,
Slider and CheckBox do. @GenUiWrites gives an annotated widget the same
ability:
@GenUiWidget(description: 'One preference the user can turn on or off.')
class PreferenceRow extends StatelessWidget {
const PreferenceRow({
super.key,
required this.label,
required this.enabled,
@GenUiWrites('enabled') this.onChanged,
});
final String label;
final bool enabled;
final ValueChanged<bool>? onChanged;
// ...
}
The callback is not a schema property — the model never supplies it. The model
binds enabled to a path and reads the user's answer back from the same path.
Validation the agent writes
A2UI lets the agent attach rules to an input component. A CheckRule is a
condition and the message to show when it fails, and both are required. The
rules belong to the agent, because what counts as valid depends on what it is
asking for. A widget only has to be willing to say so:
@GenUiWidget(description: 'A labelled text input.')
class LabeledField extends StatelessWidget {
const LabeledField({
super.key,
required this.label,
required this.value,
@GenUiWrites('value') this.onChanged,
@GenUiChecked() this.error,
});
final String label;
final String value;
final ValueChanged<String>? onChanged;
/// The message of the first failing rule, or null while all of them pass.
final String? error;
// ...
}
@GenUiChecked adds a checks property to the schema, so the model may send
rules, and hands that parameter the answer. A String? takes the message of
the first failing rule, a bool takes whether every rule passes, and a
GenUiCheckResult takes both.
Each rule is evaluated on its own rather than folded into one and, which is
what keeps the message: it is the only part of a rule a person ever reads.
Together with @GenUiWrites the loop closes without the agent in it, since the
user's value and the rule resolve against the same path.
The conditions call the basic catalog's functions, so pass newFunctions as
well as newItems when you compose your catalog.
Lists the data model fills
A list of children is normally written out by the agent, one id at a time.
That works until the list is the data: five tasks today, nine tomorrow, and
a new surface composed every time one is added. Mark the property
template: true and it accepts the other shape A2UI allows:
@GenUiProp(template: true) required this.rows, // List<Widget>
{
"id": "root", "component": "TaskList", "title": "Today",
"rows": { "componentId": "task_row", "path": "/tasks" }
}
One row is built per entry at /tasks, each reading its own entry, keyed by
entry rather than by position. A new entry adds a row with nobody asked. It is
off by default, and the property still accepts a plain list of ids.
Catalog functions
A catalog has two halves. Components are what the agent composes a surface out
of. Functions are what it computes a value with, through the {"call": ...}
form any bound property already accepts. genui ships fourteen (required,
regex, email, formatString and the rest), and adding one of your own
meant writing a ClientFunction by hand: the name, the description, an
argument schema spelled out in JSON schema, the return type, and an execute
that digs each argument back out of a map and casts it.
Annotate the function instead:
@GenUiFunction(description: 'Shortens a full name for display.')
String shortenName(
/// The name to shorten.
String name, {
/// How to shorten it.
NameStyle style = NameStyle.initials,
}) { ... }
The agent then names it, and never has to know the rule:
{
"id": "row", "component": "Text",
"text": {
"call": "shortenName",
"args": { "name": {"path": "name"}, "style": "lastFirst" }
}
}
The argument names are the parameter names, the required list is the set with no default, the enum values come from the enum, and the return type comes from the Dart return type. Arguments are coerced the way a widget property is, so a model that sends a string where a number was declared degrades instead of throwing inside the expression that called the function.
A Future<T> return becomes an async function and a Stream<T> a reactive
one, which is how a function with its own source of change (a clock, a
request, a path it watches) keeps answering.
The generated functions land in genUiCatalogFunctions and are handed to the
assembled Catalog, so adding one needs no other change, and they reach the
exported catalog.json under functions in the shape A2UI publishes for its
own.
Structured data
Scalars only get you so far. Faking a table with parallel arrays (labels,
values, trends) invites the model to emit three arrays of different
lengths. @GenUiData marks a plain Dart class as a shape the model may emit,
so a widget property can be that class or a List of it:
@GenUiData(description: 'One row of a comparison table.')
class ComparisonRow {
const ComparisonRow({required this.label, required this.value, this.trend});
/// Text shown in the first column.
final String label;
/// Numeric value shown in the second column.
final double value;
/// Direction of the change, if known.
final Trend? trend;
}
@GenUiWidget(description: 'A comparison table.')
class ComparisonTable extends StatelessWidget {
const ComparisonTable({super.key, required this.rows});
/// The rows to display.
final List<ComparisonRow> rows;
// ...
}
The object schema is inlined into the widget schema and a decoder is generated
per class. A data class holds data, not components: its fields may be the
scalar types above, other @GenUiData classes, or lists of them. Widget and
callback fields are a build error, and so is a data class that reaches itself,
because schemas are inlined rather than referenced.
Annotations
| Annotation | Target | Purpose |
|---|---|---|
@GenUiWidget(description:, name:, constructor:, isImplicitlyFlexible:) |
class | Marks a widget as a catalog component. description is required. |
@GenUiData(description:, constructor:) |
class | Marks a plain data class a widget property may take. |
@GenUiProp(description:, name:, ignore:, template:) |
parameter or field | Overrides the schema property; ignore: true excludes it, template: true lets a List<Widget> repeat over a data path. |
@GenUiAction(eventName:, description:) |
parameter or field | Customizes a VoidCallback action. |
@GenUiWrites('property') |
parameter or field | Makes a one-argument callback write the user's value back to that property's path. |
@GenUiChecked() |
parameter or field | Publishes a checks property and hands this parameter what the agent's rules say. |
@GenUiFunction(description:, name:) |
top-level function | Declares a catalog function the model calls with {"call": ...}. |
Descriptions default to the parameter's doc comment, then the field's doc comment.
Four libraries
| Import | What it is for |
|---|---|
package:genui_gen/genui_gen.dart |
the annotations, the runtime helpers the generated code calls, and genUiCatalogJson |
package:genui_gen/testing.dart |
record what a component exposes to a screen reader and fail when it changes; audit the catalog; diff it against what you published; weigh what it costs the prompt |
package:genui_gen/tracing.dart |
record a real agent session and replay it with no model and no network |
package:genui_gen/inspector.dart |
a debug panel over the running session: the tree, the data model, the semantics and the messages |
The catalog as a document
Inside the app genui puts the catalog in the prompt for you. Everything outside this Flutter process needs it as a document: an agent written in Python, a second client rendering the same surfaces in SwiftUI, a review that has to answer what the model was allowed to ask for last Tuesday.
genUiCatalogJson(catalog, {title, description}) returns the A2UI
catalog.json document — the shape A2UI publishes for its own basic catalog,
with catalogId, components, functions and the $defs a renderer resolves
a component against. It throws when the catalog has no catalogId, since a
surface names the catalog it was built against.
Generate it from a test, so the checked-in file cannot fall behind the widgets
and a reviewer sees what a new @GenUiWidget exposed to the model:
test('catalog.json describes the generated catalog', () {
final file = File('catalog.json');
final json = '${genUiCatalogJsonString(genUiCatalog)}\n';
if (autoUpdateGoldenFiles) file.writeAsStringSync(json);
expect(file.readAsStringSync(), json);
});
flutter test test/catalog_json_test.dart --update-goldens
What the component exposes
The schema half of a catalog is checked when it is generated. The other half — what the rendered component says to the person using it — has nothing checking it, and it is the half a Dart diff does not show.
recorded[item.name] = genUiRenderedSemantics();
// ...
expect(genUiSemanticsGolden(recorded, File('test/genui_semantics.json')), isNull);
GenUiExampleSurface renders an item's generated example through a real
SurfaceController, so the recording covers schema, bindings and actions
together. Re-record a deliberate change with GENUI_UPDATE_GOLDENS=1. Role,
name, value, state and actions in traversal order is the shape A2UI's rendering
cases use, so the same file also says what a renderer of your catalog on
another platform would have to reproduce.
Rendering what the schema allows
genUiFuzz renders everything the catalog's own schema permits and reports
what broke. Every widget test checks the input you had in mind; this checks the
ones a model can send and you did not.
final findings = await genUiFuzz(catalog: genUiCatalog, pump: tester.pumpWidget);
expect(findings, isEmpty, reason: genUiFuzzSummary(findings));
It mutates each item's own generated example: a required property left out, a string where a number was declared, an empty list, two hundred entries, a binding that never resolves. Each finding carries the exact component the renderer was handed, so it pastes into a test rather than needing to be rebuilt from a description. Pointed at genui's own basic catalog it currently reports 69 crashing cases across five components (a2ui#2872).
Components that lean on their app, a ThemeExtension from the app's theme for
instance, need that app around them or every one fails the same way before any
case runs. Give the fuzzer the app with host:
final findings = await genUiFuzz(
catalog: genUiCatalog,
pump: tester.pumpWidget,
host: (surface) => MaterialApp(
theme: appTheme,
home: Scaffold(body: SingleChildScrollView(child: surface)),
),
);
What the agent actually used
A catalog travels in every request whether the agent composes with it or not.
genUiCoverage reads the traces tracing.dart records and says which
components earned their place:
9 of 26 components used across 5 sessions
14639 43.4% in every request, for components the agent never asked for
It also names the properties never filled and the enum values never chosen. Not a rule to enforce, since a catalog is written before the conversations that use it, but the answer to "is this too big" with data instead of intuition.
Recording a session
Someone reports that the confirm button did nothing. You open the code and there is no confirm button: a model composed that screen, once, from a context that will not come back.
final recorder = GenUiTraceRecorder.attach(
controller,
catalogId: genUiCatalog.catalogId,
redact: const ['/user/email'],
);
// ...
await File('bug-4821.a2ui-trace').writeAsString(recorder.build().encode());
The trace keeps every message the agent sent, the data model each time it
changed — including writes the user made that never went back to the agent —
and every action the app reported. GenUiTracePlayer and GenUiTraceView put
it back on screen at any step, with no model and no network, because A2UI
describes interfaces as data. That is how last week's session becomes this
week's regression test. redact names the paths a recording must not keep.
The panel over the running session
A trace is for the session you already lost. The inspector is for the one in front of you.
GenUiInspector(
controller: controller,
recorder: recorder,
child: GenUiConversation(...),
)
Four tabs. Tree is what the model built, with the paths each component
binds, and it flags a component nothing reaches from the root or one that
contains itself. Data is every path, what it holds right now, and which
components read it; it reads both ways, so a path no component reads shows up
as payload the agent paid for and nobody saw, and a path a component binds with
nothing behind it shows up as the usual reason a field renders empty with no
error anywhere. Semantics is what a screen reader would announce, taken
live, which a generated screen can get wrong without looking any different.
Messages is the session, from a GenUiTraceRecorder.
It is gone from a release build: enabled defaults to kDebugMode, and when
it is false the child is returned untouched. GenUiSurfaceGraph is the same
reading with no widgets in it, for a test that wants to assert on the shape of
a surface rather than look at it.
Checking the contract and the cost
genUiCatalogDiff reports what changed for the model between two catalogs,
and which of those changes break a message the agent still knows how to write.
genUiSemanticsAudit reads the semantics recording and reports a control with
nothing to announce, a component that reaches assistive technology as nothing
at all, and two controls that announce themselves identically.
genUiCatalogWeight says how much of every prompt each component takes up.
Runtime helpers
Generated code uses these; you normally do not call them yourself.
GenUiBindingsresolves a map ofGenUiBindings against aDataContextand calls a builder once with aGenUiValues. It composes genui'sBoundString,BoundNumber,BoundBool,BoundListandBoundObject, so literals,{"path": ...}data bindings and{"call": ...}function calls behave exactly as in the core catalog and rebuild when the data model changes.GenUiValues.objectandGenUiValues.objectListexpose the resolved data objects; a value of the wrong shape reads asnull, and a list entry that is not a map is skipped rather than throwing.GenUiDecoder<T>is the signature of the generated function that rebuilds a@GenUiDataclass from one resolved map.genUiActionHandler(ctx, actionData)returns aVoidCallbackthat performs an A2UI action the way the coreButtondoes:eventactions dispatch aUserActionEventwithsourceComponentIdset to the component id, andfunctionCallactions resolve through theDataContext. Returnsnullwhen the action data isnull, and never throws. Malformed action data is reported as anA2uiValidationException, so the model receives the actual message.genUiValueWriterbacks@GenUiWrites: it writes the user's value to the path the model bound the property to, or to<componentId>.<property>when the model sent a literal. A write the data model refuses is reported throughctx.reportErrorrather than thrown out of a gesture handler.genUiTemplateChildrenandgenUiTemplatePathbacktemplate: true: one child per entry at the path, each with that entry as its own data context, keyed by entry rather than by position.genUiReportMissing(ctx, component, property)reports a required property the model omitted as anA2uiValidationException, once per component instance. It stays silent for{"path": ...}and{"call": ...}bindings that have not resolved yet, because those rebuild on their own once the data model is populated.genUiAsString,genUiAsNum,genUiAsBool,genUiAsStringList,genUiAsNumList,genUiAsObjectandgenUiAsObjectListcoerce one raw JSON value the way genui'sBound*widgets coerce a widget property. Generated decoders call them instead of casting, so a model that sends a number where a string was declared degrades exactly as it would for a widget property rather than throwing aTypeErrorinsidebuild.GenUiMissingFieldReporter,genUiMissingFieldandgenUiNestedFieldcarry the same reporting down into a data object: the generated widget builder hands the decoder a reporter, so a required field the model left out of a row reaches the model asrows.labelinstead of being silently replaced.
Compatibility
genui ^0.10.0 · Flutter >=3.35.0 · Dart >=3.10.0 <4.0.0
genui lives in flutter/genui and is
pre-1.0; its CatalogItem, A2uiSchemas and binding APIs still move between
minor versions. This package tracks genui and bumps its constraint when genui
breaks.
License
MIT. Copyright Diego Alejandro López Camacho.