restage_a2ui 0.1.5
restage_a2ui: ^0.1.5 copied to clipboard
Fail-closed pre-render capability checks and the Restage capability sidecar for apps rendering cached A2UI (Google genui) surfaces.
restage_a2ui #
The app-side half of Restage's A2UI emit target: a fail-closed, pre-render capability check plus a capability sidecar for cached A2UI payloads.
Restage's build-time toolchain can emit your widget catalog as an A2UI component catalog (a catalog of
widget schemas a generative-UI model renders against, via Google's genui
SDK). restage_a2ui is what an app uses on the other end: it checks an A2UI payload against the catalog
the app actually registered before handing it to genui, so a payload your build can't render faithfully
fails with a clean, actionable diagnostic instead of throwing mid-render.
What this is (and is not) #
- It is an app-side safety wrapper: a pre-render check + a capability sidecar you can wrap your cached payloads in.
- It is not an A2UI delivery mechanism. It does not fetch, host, or stream A2UI — your app (or your model session) owns that. It does not generate the catalog either; that is the build-time toolchain.
- It depends on
genui. Your app already depends ongenuito render A2UI, so this adds no new render stack — only the check.
Generate an A2UI catalog from your widgets — step by step #
This is the whole loop: write a normal Flutter widget, annotate it, run build_runner, and get a genui
A2UI catalog — no hand-written CatalogItems, no hand-authored JSON schemas. The emitter is the
build-time toolchain (restage_codegen); restage_a2ui (this package) is the optional production-safe
check you add at the end.
There are two paths — you pick at step 1:
| Path | What you add | Use it when |
|---|---|---|
| Minimal | restage_codegen (build-time) + genui — zero Restage runtime in your app |
you render your own surfaces and control the payloads |
| Production-safe | the above plus restage_a2ui — a fail-closed pre-render check + version stamp |
you render payloads you did not author (model- or server-generated) |
1. Add dependencies. The codegen is a build-time tool — it does not ship in your app; genui is the
renderer; rfw_catalog_schema holds the annotations.
dependencies:
genui: ^0.9.2 # the renderer the generated catalog targets
json_schema_builder: ^0.1.3 # the generated catalog's data schemas are built with this
rfw_catalog_schema: ^1.0.1 # the @RestageWidget / @RestageProperty / @RestageLibrary annotations
# Production-safe path only — the app-side pre-render check + capability sidecar (step 8):
# restage_a2ui: ^0.1.2
dev_dependencies:
restage_codegen: ^1.0.3 # the build-time A2UI emitter (not shipped in your app)
build_runner: ^2.4.0
2. Annotate a widget. A normal Flutter widget plus @RestageWidget; mark its inputs with
@RestageProperty. A value property paired with a matching-type ValueChanged callback wires the two-way
binding automatically — no pairing annotation needed.
import 'package:flutter/widgets.dart';
import 'package:rfw_catalog_schema/rfw_catalog_schema.dart';
@RestageWidget(
name: 'RatingPicker',
library: WidgetLibrary.custom('acme.widgets'),
category: WidgetCategory.input,
description: 'A 1–5 star rating control bound to an integer value.',
fires: [WidgetEventName.onChanged],
)
class RatingPicker extends StatelessWidget {
const RatingPicker({required this.rating, required this.onRatingChanged, super.key});
@RestageProperty(description: 'The selected rating, 1 through 5.')
final int rating;
@RestageProperty(description: 'Reports the newly selected rating.')
final ValueChanged<int> onRatingChanged;
@override
Widget build(BuildContext context) {
/* … your widget … */
return const SizedBox.shrink();
}
}
(A property typed as your own data class auto-generates a rich nested schema — see Rich data.)
3. Declare the library. A barrel that declares your custom library — its namespace and capability
version — and re-exports the widgets that belong to it. The build phase reads capabilityVersion off this
declaration and stamps it into the generated catalog.
// lib/restage_imports.dart
import 'package:rfw_catalog_schema/rfw_catalog_schema.dart';
export 'widgets/rating_picker.dart';
// … export your other @RestageWidget files
@RestageLibrary(
library: WidgetLibrary.custom('acme.widgets'),
capabilityVersion: 1, // bump when you add a widget or make a render-affecting change
)
const restageLibrary = 0;
4. Enable the A2UI builder. It is opt-in (auto_apply: none) because the generated catalog imports
genui. If you target A2UI only (not RFW delivery), also turn the two RFW builders off — otherwise
they emit unused .g.dart files and a spurious "not mechanically generatable for RFW" warning.
# build.yaml in the package that declares your @RestageWidget libraries
targets:
$default:
builders:
restage_codegen:user_a2ui_catalog:
enabled: true
restage_codegen:user_catalog: # RFW builder — off for an A2UI-only target
enabled: false
restage_codegen:user_factories: # RFW builder — off for an A2UI-only target
enabled: false
5. Generate:
dart run build_runner build
6. Your two outputs appear (under lib/):
…catalog.g.dart—buildRestageCatalogItems(): the genuiCatalogItems (each with its data schema and widget builder) that genui renders against.…catalog.a2ui.json— the A2UI-standard catalog document ({ restageCapability, a2uiCatalog }). Eacha2uiCatalog.components.<Name>carries that component's full data schema — the same schema genui's ownCatalog.toCapabilitiesJson()would emit (the data fields plus the injectedcomponentdiscriminator) — so a producer can generate payloads against this document alone. Two notes that follow genui's conventions: each component schema is a standalone schema resource (a recursive component keeps genui's component-root-relative#/$defs/…pointers, so resolve$refwithin the component schema, not against the whole file); andid+componentare reserved A2UI envelope keys — genui strips them from a component's data, so don't name a top-level@RestageWidgetfield either (nest it in a data class if you need that name).
7. Render with genui:
import 'package:genui/genui.dart';
final catalog = buildRestageCatalog();
// hand `catalog` to your genui surface — it renders your widgets from an A2UI payload.
(buildRestageCatalog() wraps buildRestageCatalogItems() with the composed
systemPromptFragments described in Guiding the model with description
and usage below. If you only
need the raw CatalogItems, buildRestageCatalogItems() alone still works.)
That's the whole loop. On the minimal path you now depend on genui at runtime and on no Restage
package — restage_codegen is build-time only. You've replaced hand-written CatalogItems with
auto-generated ones, with zero runtime lock-in.
8. (Production-safe) Add the fail-closed pre-render check. If you render payloads you did not author,
add restage_a2ui (uncomment it in step 1) and gate each payload before handing it to genui — a bad
payload then fails closed with a diagnostic instead of throwing mid-render, and you get the
capability-version check. That is the Quickstart below.
A worked version of steps 1–7 lives in example/ — the annotated widgets, the library barrel,
the build.yaml, the committed generated catalog, and a test that renders them against genui 0.9.2. It has
no app entrypoint: run dart run build_runner build to regenerate, then flutter test to see it render.
(example/'s own README shows the step-8 pre-render check.)
Quickstart #
Add the dependency:
dependencies:
restage_a2ui: ^0.1.2
genui: ^0.9.2
Build the check once (it is immutable — reuse it for every payload), then gate each cached payload before handing it to genui:
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:genui/genui.dart';
import 'package:restage_a2ui/restage_a2ui.dart';
// 1. The genui catalog your build emitted — the `CatalogItem` set genui renders
// against, with its system-prompt fragments already attached.
// `buildRestageCatalog()` is generated by the toolchain (see "Producing the
// catalog and stamp" below); use `Catalog(buildRestageCatalogItems())`
// instead if you only need the raw `CatalogItem`s.
final catalog = buildRestageCatalog();
// 2. What that catalog PROVIDES, parsed from the `restageCapability` block the
// toolchain emits next to the catalog. This is the available side of the
// version check; supply it so Restage-stamped payloads can be verified.
final installed = A2uiInstalledCapability.fromStampJson(restageCapability);
// 3. The check — one instance, reused.
final check = RestageA2uiPreRenderCheck(catalog: catalog, installed: installed);
// 4. Gate every payload before render. `check.check` accepts either a raw A2UI
// payload or a Restage sidecar wrapping one.
Widget? renderCached(String cachedJson) {
final cached = jsonDecode(cachedJson) as Map<String, Object?>;
switch (check.check(cached)) {
case A2uiRenderable():
// Safe to render. If you cached the sidecar (recommended — it carries the
// version stamp), unwrap it and hand the inner payload to genui:
final payload = RestageA2uiSidecar.isRestageSidecar(cached)
? RestageA2uiSidecar.fromJson(cached).a2ui
: cached;
return renderWithGenui(payload); // your genui render call
case A2uiRejected(:final diagnostic, :final gap):
// Do NOT render. Fall back to a built-in surface and log why.
debugPrint('A2UI rejected: $diagnostic${gap == null ? '' : ' ($gap)'}');
return null;
}
}
The check fails closed at every path: a malformed envelope, an unknown component, an unmet version, or
a stamped payload with no installed descriptor all yield an A2uiRejected — never a throw at the render
seam.
If you do not cache the sidecar and only have raw A2UI payloads, you can omit installed; then only the
existence walk runs and any Restage-stamped payload is rejected as unverifiable (fail-closed). Caching the
sidecar is what enables the version check.
Producing the catalog and stamp #
The two inputs above — buildRestageCatalogItems() and the restageCapability block — are emitted by
Restage's build-time toolchain (restage_codegen), which projects your @RestageWidget libraries
into two artifacts:
| Artifact | What it is | Who consumes it |
|---|---|---|
The generated CatalogItem Dart (buildRestageCatalogItems()) |
the functional contract genui renders against — one CatalogItem per widget, with its data schema and widget builder |
genui, at render time |
The stamped catalog document ({ restageCapability, a2uiCatalog }) |
the A2UI catalog JSON plus the two-axis capability stamp (built-in floor + custom-library versions) | this package's check (the restageCapability block → A2uiInstalledCapability.fromStampJson) |
Both are emitted from the same set of your @RestageWidget libraries, so they agree by construction — a
widget the emitter scopes out is absent from both. The generated A2UI catalog contains only your own widgets;
projecting Flutter's built-in widgets into an A2UI catalog is a separate fidelity effort, not part of this path.
The build wiring that produces them — the opt-in build_runner builder, the build.yaml settings, and the
generate command — is the step-by-step walkthrough
above. (The lower-level emit entrypoints emitA2uiCatalogDart(catalog) and emitA2uiCatalog(catalog).toJson()
are also available for custom pipelines.)
RFW remains Restage's native delivery path; A2UI emission is additive.
Guiding the model with description and usage #
Two @RestageWidget / @RestageProperty fields feed the generative-UI model directly — both are your
authored guidance, never inferred:
- Every
descriptionyou write on a widget or property is emitted as that field's schemadescriptionin the generated A2UI catalog. A model reading the catalog's JSON schema sees exactly the words you wrote. - An optional
@RestageWidget(usage: '...')note becomes that widget's line in genui'sCatalog.systemPromptFragments— steering guidance the model reads when deciding when to reach for the widget, separate from the schema it fills in. A widget with nousagefalls back to itsdescription; a widget with neither contributes no line.
@RestageWidget(
name: 'Callout',
library: WidgetLibrary.custom('acme.widgets'),
category: WidgetCategory.decoration,
description: 'A message callout that wraps an optional child.',
usage: 'Use for a short highlighted aside around optional content.',
)
class Callout extends StatelessWidget { /* … */ }
buildRestageCatalog() returns the genui Catalog with these fragments already attached — use it in place
of Catalog(buildRestageCatalogItems()) when you want your description/usage notes to reach the model's
system prompt.
Rich data — structured @RestageWidget properties #
A @RestageWidget property typed as your own data class generates a rich A2UI schema automatically — no
shim types, no hand-authored schema. The emitter walks the data shape and emits a genui schema that
reconstructs the value at render. The supported rich shapes are:
- nested data classes (a data class whose fields are themselves data classes),
- lists of objects (
List<YourType>), - String-keyed maps (
Map<String, V>), - named records (
({double width, double height})), - alongside scalars, enums, scalar lists, and the two-way value/event interactivity.
class Money {
const Money({required this.amount, required this.currency});
final double amount;
final String currency;
}
@RestageWidget(name: 'PriceTag', library: WidgetLibrary.custom('acme.widgets'), /* … */)
class PriceTag extends StatelessWidget {
const PriceTag({required this.price, super.key});
// The whole nested value arrives as one property; the generated catalog
// reconstructs it from the payload and renders it.
@RestageProperty(description: 'The price to render.')
final Money price;
// …
}
A required value that is missing from the payload fails the widget safe — the surface degrades, never renders a fabricated value. Sealed-class unions are not yet recognized; a union-typed property scopes out with a clear diagnostic rather than rendering wrong (recognition is a tracked follow-up).
Note: rich structured properties target the A2UI catalog. Native (RFW) paywall delivery of a custom data class is planned, not yet available.
Why a pre-render check #
genui resolves a payload's component types against the catalog you registered at render time. A
component the catalog lacks throws CatalogItemNotFoundException part-way through building the surface. And
a payload your app cached against an older catalog can reference a component whose shape has since
changed — a drift the app, not genui, owns (genui has no built-in payload cache; you cache the serializable
A2UI JSON yourself).
restage_a2ui moves that check before render and makes it explicit:
- Existence walk (any payload). Every component type the payload references must exist in the catalog you registered. Works for any A2UI payload — including one generated live by a model — and catches a missing component before genui would throw.
- Version satisfaction (Restage-stamped payloads). A payload wrapped in a Restage sidecar carries the catalog version it was generated against. The check verifies your installed catalog meets that version — across both the built-in content floor and any custom widget libraries — so a payload that needs more than your build provides is rejected up front rather than rendered wrong.
Both fail closed: a malformed payload, an unknown component, or an unmet version yields a Rejected
result with a diagnostic. The check never throws at the render seam.
The capability sidecar #
A2UI's envelope has no place for a per-payload version stamp, so Restage wraps a cached payload:
{
"restageCapability": {
"builtInFloor": 1,
"requiredLibraries": [{ "namespace": "acme.widgets", "minVersion": 3 }],
"perItemSinceVersion": { "RatingPicker": 1, "AcmeBanner": 3 }
},
"a2ui": { "...": "the A2UI payload" }
}
builtInFloor is the built-in content floor the payload needs; for a catalog built only from your own
widgets it stays at the baseline, and requiredLibraries carries your custom-library versions.
Cache the wrapper; run the check on it before rendering a2ui. The version comparison rests on the
catalog's cumulative-render-support invariant: an incompatible change to a component forks a new identity, so
an existence walk plus a version compare together are sound. (Without the stamp, name-existence alone is
not — a same-name shape change would slip through.)
App Review #
A2UI emission is declarative-data-only — a catalog of widget schemas plus version metadata, no server-shipped executable code. This package adds only a pre-render check over that data.
Status #
Pre-1.0, tracking genui ^0.9.2 (A2UI protocol v0.9). genui is alpha and its API is expected to change;
this package isolates the integration so a churn moves one place.