Generated UI that follows its data
A generated interface can hold data of its own. When a person changes it with one of your controls, the parts of the screen that show it update as they do. The model sees what they set when the next turn goes out.
What it is
Section titled “What it is”Every surface the platform serves has a data model, a small JSON document that belongs to that surface. The model fills it in when it builds the surface, and it can point any value in the interface at a place in it instead of writing the value out. {"path": "/budget"} in place of 250 means “whatever is at /budget right now”.
That pointer is a binding, and it works in both directions.
- Data to screen. A component bound to
/budgetshows the current value there. When the value changes, that component rebuilds on its own. Nothing else on the screen does, and no turn is sent. - Screen to data. A control of yours, such as a slider or a text field, can write back to the place it is bound to. Every other component bound to the same place follows along as the person drags or types.
Take a pricing step with a heading that shows the chosen budget, a slider under it bound to the same /budget, and a Next button. As the person drags the slider, the heading’s number moves with it. Nothing reaches the platform until they press Next.
When they do, the model needs to know where the slider ended up. There are two ways that happens, and they work together.
- The whole data model, on every turn. When the model builds a surface with controls a person can change, it marks that surface to send its data back. From then on, every turn your app sends, typed or tapped, carries that surface’s current data model. A person who moves the slider and then types “something cheaper” instead of pressing Next is still understood.
- Values named in a tap. A tap’s action can bind values of its own, such as
{"budget": {"path": "/budget"}}, and the SDK fills them in when the tap happens. See Letting a tap send the next turn.
A list can come from data too. A Row, Column or List whose children are a template over a list in the data model shows one child per entry, each reading its own entry. A product grid can be one ProductCard template over /products.
Everything a person could have changed is treated as something they said. The data model your app sends back goes through the same content checks as a typed message before the model sees it.
How to use it
Section titled “How to use it”-
Declare every value that can be bound as dynamic in your catalog item’s schema:
CommonSchemas.dynamicString,CommonSchemas.dynamicBoolean,GenUiPortalSchemas.dynamicNumberorGenUiPortalSchemas.dynamicStringList. That declaration is the whole opt-in. The SDK works the binding out, keeps the component listening to the places it reads, and handsbuildthe current value. Yourbuildnever sees a binding, and you write no code to listen for changes. -
Read values as usual. A heading reads
scope.string('value'),scope.number('value')orscope.boolean('value'), each null when the value is missing.stringshows any other value as text.numberalso reads a number the model wrote as text, such as"250", and is null for anything else.booleanis null for anything that is nottrueorfalse. When the model bound that property to/budget, the heading rebuilds whenever/budgetchanges, because the SDK rebuilds any component whose values changed.scope.numbercan also keep a value inside a range, because data a person or the model set can hold anything. Holding the value is only half of what a slider needs, since it also fails on an inverted range, which is whatscope.rangein the next step is for. A debug build reports a value it had to hold in, so you can see when the model bound something you did not expect. -
Give a control its range as properties. Add
minandmaxto the catalog item in the dashboard as required number properties, beside itsvalue. The model then sees the range as part of the component and picks one for each slider it builds, and the platform refuses a slider served without one. Read them withscope.range('min', 'max'), which returns the two ends in order, or null when the model left one out or served them inverted, since a Slider fails on an inverted range. Then pass the ends toscope.numberasminimumandmaximumto hold the value inside them. A range that never changes can go in the schema instead, asdynamicNumber(minimum: 0, maximum: 1000), but the model cannot see a range declared only there. -
Write from a control with
scope.setter. It returns the function that writes a new value to the place the property is bound to. It returns null when the model wrote a fixed value instead of a binding, so a control handed null has nothing to write to and can show as read-only.class PriceSlider extends GenUiPortalCatalogItem {const PriceSlider();@overrideString get name => 'PriceSlider';@overrideSchema get schema => Schema.object(properties: {'value': GenUiPortalSchemas.dynamicNumber(),'min': GenUiPortalSchemas.dynamicNumber(),'max': GenUiPortalSchemas.dynamicNumber(),},required: const ['value', 'min', 'max'],);@overrideWidget build(BuildContext context, GenUiPortalScope scope) {// Null when the model left out an end or served them inverted.final range = scope.range('min', 'max');if (range == null) return const SizedBox.shrink();return Slider(value: (scope.number('value', minimum: range.min, maximum: range.max) ??range.min).toDouble(),min: range.min.toDouble(),max: range.max.toDouble(),onChanged: scope.setter('value'),);}} -
Say what your controls are for in the catalog item’s description in the dashboard, such as “a slider the person drags to choose a number”. The model reads those descriptions to decide which surfaces to mark as sending their data back and which values to bind.
-
Nothing else changes in your app. The SDK attaches the data model to every turn for the surfaces the model marked. You do not add it to a message or to a tap yourself.
The web SDK does not support this yet. It arrives with the web SDK’s React adapter.
Failure modes
Section titled “Failure modes”- A value shows as a map such as
{path: /value}instead of a number or text. Its property is declared with a plain type, or left out of the schema, so the SDK passed it through untouched. Declare it dynamic. In a debug build the SDK also reports the component and property to your app’s error handler, so you find this before a person does. - A slider or field does not move.
scope.setterreturned null because the model wrote a fixed value rather than a binding. The control has nothing to write to. Render it read-only, and if the model should have bound it, say so in the item’s description or your system prompt. blockedon a turn nobody typed anything alarming into. Something a person entered into a control failed the content checks. It rides on every later turn until they change it, so the same turn will be refused again. Offer another way forward, or clear the value.unavailableon every turn after a long session. Each surface marked to send its data back adds to every turn, and the platform refuses a turn whose data model is over 100,000 characters. A retry fails the same way. Start a new conversation, or have the model build fewer surfaces with controls on them. The model cannot fill a single surface with more than 10,000 characters of data, so this takes many surfaces.invalid_output. The model’s reply pointed its data at a surface it did not build, or tried to fill a surface with more than 10,000 characters of data. The platform refused to pass it on. Asking again may produce a valid reply.