Skip to content

Rendering generative UI with the frontend SDK

The frontend SDK is how your app sends what a person said and renders the interface the platform builds in reply. It is one object with one call, and it never sees your secret key.

Your application has two tiers, and each gets its own SDK. The backend SDK runs on your server, holds the secret key, and mints session tokens. The frontend SDK runs in your app, takes one of those tokens, sends a turn, and renders the result using your own components. The secret never crosses that line.

A project is one generative-UI experience in your app. A shopping assistant on your home page and a search assistant in your search flow are two projects, each with its own components, its own system prompt, and its own published version. You can run as many as you like at once.

A conversation is one thread inside one experience. It holds the interface built so far, and you send each new message to it. Most screens have exactly one; nothing stops you having several, and the platform keeps their histories apart.

Three things you provide, once each:

  • Your components. The platform never sends you a widget. It sends the name of one of your components and the values to build it with, and your code decides what that looks like. A ProductCard is yours: your layout, your fonts, your tap handling.
  • A version to pin. You build your app against one published version of a project, and you say which. That version’s component list can never change, so the interface you get can never ask for a component your app does not have.
  • A way to get a session token. The SDK asks whenever it needs one. Your app answers by calling your own server, which mints one with the backend SDK. The SDK never saves a token anywhere. It holds the one it is using in memory and asks you again before each attempt.
  1. Publish a version of your project and note its number from the Versions page, along with your organization id and project id from Connect.

  2. Configure the SDK once at startup with your organization id. It talks to the GenUI Portal production API by default, so pass a baseUrl only to reach another environment.

  3. Declare each of your components. Give it the name the catalog uses, the schema of its properties, and a build that turns one served instance into your widget. build gets a scope for that instance: read a value with scope.string('title'), scope.number('price') or scope.boolean('inStock'), or anything else from scope.properties. The values arrive already worked out, so a value sent as a reference into the interface’s own data reads back as its current value. The SDK reads your schema to work each value out, so declare a value that can arrive either way with CommonSchemas.dynamicString, or GenUiPortalSchemas.dynamicNumber for a number, rather than a plain type. A value you declare as a plain type, or leave out of the schema, reaches build exactly as it was sent, reference and all. If the component holds other components, place them yourself in build with scope.children() or scope.child('icon'). If it can be tapped, declare its action property with CommonSchemas.action and wire scope.action('action') to your button’s onPressed. See Letting a tap send the next turn below. A list of children the interface generates from its own data arrives as one child per entry, each reading its own entry. A child is built only once your build places it, so a tab you are not showing costs nothing. A component rebuilds on its own when the data it reads changes, and a control can write back with scope.setter('value'). See Generated UI that follows its data.

  4. Add the built-in layouts to your catalog. Row, Column and List are how the platform arranges your components, and your catalog does not include them until you add them. Leave them out and a surface that uses one renders blank. A layout packs its children to the start across its width or height, even where the interface asks them to stretch, and a List lays all its children out rather than scrolling, so place a surface inside a scroll view wherever it can grow past the screen. A horizontal List wider than the screen overflows even inside a scroll view, since a vertical scroll view cannot scroll it sideways. A Row gives each child its natural width and never wraps them, so a Row too wide for the screen overflows, and a component that fills all the width it is given, a ListTile say, throws in a Row or a horizontal List: give it a width of its own. A Column gives each child its natural height, so a component that fills all the height it is given, a ListView or GridView that does not shrink-wrap, throws in a Column or inside the scroll view: give it a height of its own. To draw a layout your own way, extend it, override its build, and put yours after the built-in layouts in the catalog, because the last component with a name wins.

  5. Create a conversation for the screen, passing your configuration, the project id, the pinned version, your catalog, and your token function. Build your catalog once as a top-level value and reference it, rather than writing it out at each place you start a conversation. A small wrapper component per experience is the tidiest way to do this.

  6. Send a message and render. One call sends it. The conversation is observable, so your UI rebuilds as the interface arrives, and you render the surfaces it produces where you want them.

    await conversation.sendMessage('what should I cook with?');
    await conversation.sendMessage('what should I cook with?');
  7. Let a tap send the next turn. A component whose catalog item has an action property can send a follow-up turn when a person taps it. You write the button, the SDK does the sending. The full walk-through is below.

  8. Keep the conversation id if you want the thread to survive a restart. The platform holds the history; pass the id back when you create the conversation and the thread continues. Refreshing a token never disturbs it.

  9. Dispose the conversation when the screen goes away. If you use the conversation builder, this happens for you, and an in-flight turn is cancelled rather than applied to a screen that has gone.

  • It will not mint a token. Only your server can, because only your server has the secret key.
  • It will not send a second turn while one is in flight. Wait for the first, or disable your control while the conversation reports that it is busy. Sending two at once would bill two turns to show one result. A tap that arrives while a turn is in flight is dropped.
  • It will not send a turn nobody asked for. A turn starts only from sendMessage or from a person tapping a component that carries an action. An error, a rebuild or a new surface never sends one.
  • It will not fall back to a different version. If the version you pinned is not being served, you are told, and the SDK stops asking: every later send fails the same way without reaching the platform. See below.

A generated interface can offer a person something to press: “show me more like this”, “compare these two”, “add to cart”. When they press it, the SDK sends that press to the platform as the next turn on the same conversation and renders the reply. Nobody types anything.

This works through an action, which is part of the A2UI protocol. You give a catalog item a property of type Action in the dashboard. When the model uses that item it fills the property in with a name for what the press means and any values that go with it, for example {"event": {"name": "show_similar", "context": {"productId": "p-114"}}}. A tap sends exactly that back.

The platform ships no button of its own. A button looks like your design system, so you write it, the same way you write a ProductCard.

  1. Add an Action property to the catalog item in the dashboard, for example action on a Button item. See Defining a catalog item.

  2. Declare it in your item’s schema with CommonSchemas.action, and hand scope.action('action') to your button. It returns the callback that sends the press, or null when the component was served without one.

    class ActionButton extends GenUiPortalCatalogItem {
    const ActionButton();
    @override
    String get name => 'Button';
    @override
    Schema get schema => Schema.object(
    properties: {
    'label': CommonSchemas.dynamicString,
    'action': CommonSchemas.action,
    },
    required: const ['label', 'action'],
    );
    @override
    Widget build(BuildContext context, GenUiPortalScope scope) {
    final conversation = GenUiPortalConversation.of(context);
    return FilledButton(
    onPressed: conversation.isWaiting ? null : scope.action('action'),
    child: Text(scope.string('label') ?? ''),
    );
    }
    }
  3. Decide what the button does while a turn is running. That is your call. GenUiPortalConversation.of(context) gives any item inside a conversation builder the conversation it belongs to, and the item rebuilds when isWaiting changes. The example above disables itself. You could show a spinner instead, or leave it as it is. A tap that lands while a turn is already in flight is dropped rather than queued.

  4. Handle errors from a tap. A turn a tap started has no await in your code to catch its error, so pass onError when you create the conversation. It receives the same typed errors sendMessage throws.

    final conversation = GenUiPortalConversation(
    // config, projectId, version, catalog, sessionToken as before
    onError: (error) => showSnackBar(error.code),
    );

By default a tap is sent straight away. Pass onAction to decide for yourself. You then receive each press and choose what happens. You can send it, record it in your own analytics first, or ignore it. Call sendAction to send it, and it behaves exactly like sendMessage: it returns when the reply is rendered and throws if the turn fails.

late final GenUiPortalConversation conversation = GenUiPortalConversation(
// config, projectId, version, catalog, sessionToken as before
onAction: (action) async {
analytics.track(action.name);
await conversation.sendAction(action);
},
);

The conversation is late so the handler can call back into it. With onAction set, onError is no longer called for taps, because your own await sees the error.

Every reply adds to the conversation’s surfaces, and the builder hands you all of them. A button on a reply from three turns ago still works if you keep showing it, because the platform accepts any action it actually served on that conversation. Whether older replies stay on screen, or stay tappable, is a layout decision that belongs to you. Render only the newest surface, or render older ones with their buttons disabled, if that suits your experience better.

Every failure is a typed error with a stable code, so your handler branches without parsing messages. In Dart they are sealed subtypes, so a switch covers every case; in TypeScript the code is a string union. Each error the platform answered carries a traceId you can show a person or quote to support, and a network error has none, because nothing answered. Every error carries an isTransient flag that is true only when retrying the same turn might succeed.

The platform tells you what happened and never why, on purpose: a reply that explained which check refused a message would be a way to probe those checks. The detail is kept in your project’s Logs.

  • blocked. The message, a value a person entered into an action, or the reply was refused by the content checks. The same input will be refused again, so offer the person another way to ask rather than retrying.
  • invalid_action. A tap sent an action the platform never served on this conversation, or sent it with values that do not match what was served. It usually means a stale screen, such as a button left on screen from before your app moved to a newer version whose catalog item no longer carries that action, or a hand-built request. Retrying the same action fails the same way.
  • no_result. The turn was processed and produced nothing worth showing. Not an error in your code. Say there is nothing to show.
  • unauthorized. The token was missing, expired, or not valid for this project, or the request came from an origin the project does not allow. The SDK already fetched a fresh token and tried once more before telling you, so if you see this, a new token did not fix it — check the project’s allowed origins, or take the person back to sign-in.
  • unsupported_version. The version your app pinned is not being served, usually because it was rolled back or never published. This cannot fix itself while your app is running, so the SDK stops trying and the conversation reports that it is unavailable. Render your existing non-generative experience, and fix it by publishing that version again or shipping an app update.
  • invalid_output. The model returned something that was not valid for your catalog. Nothing you did; the platform refused to pass it on. It is not marked transient, but asking again may produce different, valid output.
  • unavailable. The platform could not serve the turn. Usually transient, so back off and retry.
  • network. The request never got an answer. The default timeout is 10 seconds and is configurable.

The version you pin fixes the component list, and the platform validates every reply against that catalog before it reaches you, so the interface never names a component your build does not have. If one somehow did, the SDK does not fail the screen: it leaves that component out as an empty slot and renders everything else. The same holds for a value the SDK cannot work out, which leaves only its own component empty and reports the error to your app’s error handler. A missing component costs you one card, not the page.