← Back to list

Beyond Fixed Screens: Building Adaptive Flutter Apps with GenUI and A2UI

In traditional mobile app development, everything is predictable. You know your screens, you know your data. If you’re using Firebase…

Renuka Kelkar · 2026-06-13 10:32 · 30 claps · 14.1 min read
#flutter #ai-agent #a2ui #genuis
Open on Medium ↗
Wiki topics: AGT · AI Agents 📱 · Mobile Development

Beyond Fixed Screens: Building Adaptive Flutter Apps with GenUI and A2UI

In traditional mobile app development, everything is predictable. You know your screens, you know your data. If you’re using Firebase, you’ve designed the collections yourself, you know exactly what’s coming back. If you’re hitting an API, you’ve read the docs, you know the shape of the response. All your UI is built around that certainty.

But we’re in the agent era now.

More and more apps are adding AI agents and chatbots. And the moment you do that, the content becomes unpredictable; what your agent returns depends entirely on what the user asked. The response isn’t fixed. It changes every time. So how do you build UI for something you can’t predict in advance?

That’s exactly the problem A2UI is trying to solve.

As a Flutter developer, my instinct is to explore how Flutter fits into the new developments in the AI space whenever something new emerges. So I started digging, and Flutter actually has a GenUI SDK designed for building adaptive UI. I wanted to understand what it really does, how you use it, and whether it’s actually worth reaching for.

So let’s get into it. What GenUI is, how it works in Flutter, and honestly, where it shines and where it falls short.

So what exactly is A2UI?

Before we get into A2UI, let’s talk about how chatbots have worked until now, because that context matters.

Traditional chatbots, whether rule-based or LLM-powered, have one output: text. You ask a question, you get a text response. Your app takes that string and renders it in a chat bubble. That’s it. The entire interaction lives inside a conversation thread. If the bot tells you your sales were up 18% last month, you’re reading that as a sentence. There’s no chart, no card, no visual summary. Just words in a bubble.

And that’s fine for simple queries. But the moment the response gets complex, data, comparisons, recommendations, itineraries, plain text starts to feel like a step backwards. You’re asking a powerful AI model and getting back something that looks like an SMS.

The UX ceiling of traditional chatbots is low. Because no matter how good the model is, the output is always a flat string that your UI has no idea what to do with beyond displaying it.

A2UI changes that entirely.

A2UI stands for Agent-to-UI. It’s a protocol that sits between your AI agent and your app’s frontend. Instead of the agent returning text that your app just dumps into a chat bubble, the agent returns structured instructions that tell your UI exactly what to render.

The user asks something, the agent understands the intent, and instead of responding with a wall of text, it says, show a sales summary card or render a travel planner with these destinations. Your app listens and builds the screen on the fly.

This is a completely different mental model. The chatbot is no longer just a conversation. It’s a UI driver.

How A2UI actually works

Let’s break it down into steps. Think of it as a conversation between your user, an AI agent, and your app’s UI.

Step 1: User sends a message

The user types something into your app. Just like a regular chatbot. Nothing different here yet.

Step 2: Agent generates A2UI messages

{
  "component": "SalesCard",
  "data": {
    "total": "$28,450",
    "growth": "18.6%",
    "period": "April 2024"
  }
}

This is where things change. Instead of the agent writing back a text reply, it generates A2UI messages. These messages describe two things: the structure of the UI component to render, and the data to fill it with.

Step 3: Messages stream to your app

Those messages don’t arrive all at once. They stream to your client app in real time using Server-Sent Events (SSE), a JSONL stream that stays open while data is coming in. Your app starts buffering the component definitions and data model as they arrive.

Step 4: The beginRendering signal

Once the server has sent everything it needs to, it sends a special signal called beginRendering. This tells your client it’s safe to start building the UI. This signal exists for one reason: to prevent half-rendered widgets from flashing on screen while data is still loading.

Step 5: Client renders the UI

Now your app gets to work. It walks through the component tree, resolves the data bindings, and looks up each component in something called a WidgetRegistry. This is essentially a map of all the Flutter widgets your app knows how to render. It finds the right widget, passes in the data, and builds the screen.

The user now sees a real interactive UI. Not a chat bubble. An actual screen.

Step 6: User interacts

The user taps a button or takes an action. Your client packages that up into a userAction payload and sends it back to the agent via a separate A2A message.

Step 7: Agent responds and UI updates

The agent processes the action and sends back updated messages over the same SSE stream. Your app receives them, rebuilds the relevant parts of the UI, and the screen updates.

And then the loop continues.

The key thing to understand is that this isn’t a one-shot request and response. It’s a live loop. The agent and your UI are in constant conversation. The agent decides what to show, the user interacts with it, the agent responds, and the UI updates. All of that happens dynamically without a developer having hardcoded any of it.

Flutter GenUI SDK — what it is and how it thinks

What GenUI actually is

Flutter shipped the GenUI SDK in alpha with Flutter 3.38. It’s not just an API wrapper. At its core, it’s an orchestration layer, a suite of packages that coordinates the flow of information between your user, your Flutter widgets, and an AI agent, transforming text-based conversations into rich interactive experiences.

The goal is straightforward. Replace the static wall of text your LLM returns with a dynamic, interactive, graphical UI. Instead of a chat bubble with a paragraph, the user gets a date picker, a row of buttons, a sales card, and real widgets built from your own existing widget catalog.

The mental model: how GenUI sees your app

When you build a regular Flutter app, you think in screens and widget trees. GenUI doesn’t think that way. It thinks in three things:

Everything in GenUI comes back to those three things.

The full flow

Here’s what happens end-to-end when a user does something:

User Action
   |
   v
GenUiConversation
   |
   v
ContentGenerator (AI)
   |
   v
A2uiMessage stream
   |
   v
GenUiManager
   |
   v
DataModel + UI Surfaces
   |
   v
GenUiSurface (Flutter rebuild)

Let’s walk through each step so it makes sense.

Step 1: Your app sends a request

The user types a prompt, something like “Help me plan a trip to Tokyo.” Your app sends that to the AI agent along with one extra piece of information, a catalog of widgets your app knows how to render. The agent needs to know what’s available so it can decide what to build.

Step 2: The agent generates content and UI together

This is where things get different from a regular chatbot. The agent doesn’t just write a text reply. It uses tools from the GenUI SDK to describe the UI best suited to the content it generated. So instead of returning “Here are some top destinations in Tokyo,” it returns a structured description of a travel card with destinations, images, and action buttons. Content and UI are decided together, not separately.

Step 3: The messages stream to your app

The agent’s response doesn’t arrive all at once. It streams back as a series of A2UI messages. Your app starts receiving and buffering them as they come in, building up the component structure and the data model piece by piece.

Step 4: UI is dynamically rendered

Once everything is ready, the GenUI SDK deserialises the response and builds the widgets. Some are informational, some handle layout, and some are interactive, like sliders, buttons, or a date picker. Your app didn’t hardcode any of this. The agent decided what to show, and the SDK built it.

Step 5: The user interacts

The user taps a button, picks a date, and selects a destination. That interaction goes back to the agent as a follow-up message. The agent responds with updated UI. The loop continues.

This is what makes GenUI genuinely different. It’s not a one-time render. It’s a live back-and-forth between the user, the agent, and your UI. Every interaction can produce a new contextually relevant screen.

And one really important thing to understand. GenUI never renders anything outside of Flutter. It doesn’t bypass the widget system or do anything magical. It just decides what Flutter should render. Flutter is still doing all the actual work. GenUI is the decision layer, Flutter is still the execution layer.

So as a Flutter developer, you’re still in completely familiar territory. You’re just letting the agent make some of the decisions that you used to make yourself.

Core concepts, the building blocks of GenUI

Before we write any code, let’s understand the six key pieces that make GenUI work. Think of these like the cast of characters. Each one has a specific job, and they all work together.

1. Conversation

This is your main entry point. Think of it as the manager of the whole operation. You talk to the Conversation object from your app code and it takes care of coordinating everything else behind the scenes.

Under the hood, Conversation wraps two things: a SurfaceController and a Transport. It wires them together, manages the event stream, and orchestrates the entire generative UI process. You don’t instantiate surfaces or parse messages manually. Conversation handles all of that for you.

final conversation = Conversation(
  controller: _surfaceController,
  transport: _transportAdapter,
);

You listen to the events stream to know when surfaces are created or removed:

_conversation.events.listen((event) {
  if (event is ConversationSurfaceAdded) {
    setState(() => _surfaceIds.add(event.surfaceId));
  } else if (event is ConversationSurfaceRemoved) {
    setState(() => _surfaceIds.remove(event.surfaceId));
  }
});

And you send messages through it:

await _conversation.sendRequest(
  ChatMessage.user(TextPart(text)),
);

That’s all your app code ever needs to touch directly.

2. Catalog

The Catalog is the list of widgets you’re giving the agent permission to use. The agent can only build UI from what’s in here. If a widget isn’t in the catalog, the agent simply cannot use it.

Each item in the catalog has three things. A name so the agent can reference it by string. A data schema built with the json_schema_builder package that describes exactly what properties the widget accepts. And a builder function that tells Flutter how to render it.

final riddleCard = CatalogItem(
  name: 'RiddleCard',
  dataSchema: S.object(
    properties: {
      'question': S.string(description: 'The question part of a riddle.'),
      'answer': S.string(description: 'The answer part of a riddle.'),
    },
    required: ['question', 'answer'],
  ),
  widgetBuilder: ({
    required data,
    required id,
    required buildChild,
    required dispatchEvent,
    required context,
    required dataContext,
  }) {
    final json = data as Map<String, Object?>;
    final question = json['question'] as String;
    final answer = json['answer'] as String;
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(border: Border.all()),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(question, style: Theme.of(context).textTheme.headlineMedium),
          const SizedBox(height: 8),
          Text(answer, style: Theme.of(context).textTheme.headlineSmall),
        ],
      ),
    );
  },
);

You then pass your catalog items into the SurfaceController:

_surfaceController = SurfaceController(
  catalogs: [BasicCatalogItems.asCatalog().copyWith([riddleCard])],
);

GenUI ships with BasicCatalogItems out of the box, which includes common widgets like text, markdown, and images. You build on top of that with your own custom items.

3. DataModel

The DataModel is the single source of truth for all dynamic UI state in your app. Instead of each widget managing its own local state, everything lives in one central observable store.

Widgets are bound to specific paths in the data model using a path syntax. When one value at a path changes, only the widgets bound to that path rebuild. Not the whole screen. Just the relevant parts.

The AI can write to the DataModel via updateDataModel messages. Input widgets like TextField write to it automatically when the user types. And any widget bound to that path will react immediately.

For example, the AI might generate this to display an image:

{
  "component": "Image",
  "url": "https://example.com/image.png",
  "variant": "mediumFeature"
}

And a text component like this:

{
  "component": "Text",
  "text": "Welcome to GenUI",
  "variant": "h1"
}

The DataContext object passed into each widget builder is what gives that widget access to its slice of the DataModel. You read from it inside your builder function to get the current values.

This reactive data flow is what creates the high-bandwidth interaction loop between the user, the UI, and the agent.

4. SurfaceController

The SurfaceController is the runtime engine sitting under the Conversation. It has three responsibilities.

It manages the lifecycle of UI surfaces, tracking which ones exist and what state they’re in. It applies incoming A2UI messages to the right surfaces. And it keeps the DataModel updated as messages arrive.

You set it up once at initialisation and hand it to the Conversation. After that, it runs automatically.

final surfaceController = SurfaceController(
  catalogs: [BasicCatalogItems.asCatalog()],
);

When you want to render a surface in your widget tree, you use the Surface widget and give it the context for that surface ID:

Surface(
  surfaceContext: _surfaceController.contextFor(id),
)

The SurfaceController also generates the system prompt fragment that tells the AI what widgets are available. This is done through the PromptBuilder:

final promptBuilder = PromptBuilder.chat(
  catalog: catalog,
  systemPromptFragments: ['You are a helpful assistant.'],
);
// Pass this to your LLM config
promptBuilder.systemPromptJoined()

One thing to be aware of. This system prompt can be 3,000 to 5,000 tokens long. If you’re using a small or on-device model, that will likely exceed its context window. In that case, write a compact custom system prompt instead.

5. A2uiTransportAdapter

When your AI model responds, it streams back raw text chunks. The A2uiTransportAdapter sits between the LLM stream and the SurfaceController, transforming those raw chunks into structured GenerationEvents that the controller can understand and act on.

Think of it as a translator. The LLM speaks in text streams. The SurfaceController speaks in structured events. The adapter bridges the two.

_transportAdapter = A2uiTransportAdapter(onSend: (message) async {
  final stream = model.generateContentStream(message);
  await for (final chunk in stream) {
    _transportAdapter.addChunk(chunk.text ?? '');
  }
});

You call addChunk every time a new piece of the stream arrives. The adapter parses it progressively and emits structured events as it goes. You never have to wait for the full response before the UI starts updating.

If you’re connecting to a server that implements the A2UI protocol directly, you use A2uiAgentConnector instead. It handles the WebSocket connection and pipes messages straight into the SurfaceController:

_connector = A2uiAgentConnector(
  url: Uri.parse('http://localhost:8080'),
);
_connector.stream.listen(_surfaceController.handleMessage);

6. A2uiMessage

These are the actual instructions the AI sends to your UI. Every piece of dynamic UI you see in a GenUI app is the result of one of these four messages arriving and being acted on.

createSurface tells the app to create a new UI surface and begin rendering content on it.

updateComponents tells the app to add or modify widgets on an existing surface. This is how the agent builds up the visual layout piece by piece.

updateDataModel tells the app to update specific values in the DataModel. Any widget bound to those paths will automatically rebuild with the new values.

deleteSurface tells the app to remove a surface entirely when it’s no longer needed.

The A2uiTransportAdapter parses the raw LLM stream and turns it into these messages. The SurfaceController receives them and applies them. The Surface widget reflects the result. That’s the full pipeline from AI response to rendered Flutter UI.

Building your own catalog widgets

Why custom widgets matter

The BasicCatalogItems that ship with GenUI are a good starting point. But the real power comes when you build your own. This is where GenUI stops feeling like a demo and starts feeling like a real product framework.

In a normal Flutter app, you build a widget, decide what state it holds, and wire it up yourself. With GenUI, the widget holds no state at all. Everything comes from the data model. The agent populates the data, and the widget just knows how to display it.

The three things you need

To register a custom widget, you need three pieces working together:

1. A schema. The schema is the contract between your Flutter app and the AI. It tells the agent exactly what data your widget expects. You define it by json_schema_builderspecifying each field, its type, its description, and whether it's required.

The descriptions matter more than you might think. The agent reads them to understand what to put in each field, so clear descriptions directly improve the quality of the output.

2. A CatalogItem. This ties your widget name, schema, and Flutter builder function together into one registered item. The name is what the agent uses to reference your widget. The builder is standard Flutter code that renders the UI using data coming in from the model.

3. A system instruction. The agent needs to know your widget exists and when to use it. Without an explicit instruction, the agent might never reach for your custom widget even if it’s registered. A clear instruction removes the ambiguity and makes outputs consistent and predictable.

What changes when all three are in place

Once you have a schema, a CatalogItem, and a system instruction working together, something shifts. Instead of the AI assembling loosely defined UI fragments and hoping they look right, it’s populating structured, production-ready components.

Your branding is intact. Your layout is consistent. The prompt engineering burden drops significantly because the schema does a lot of that work for you. The creative decisions stay with you. The content decisions move to the agent.

Is GenUI worth it? Honest pros and cons

What works well

It solves a real problem. If you’re building agent-powered apps, the wall of text problem is genuinely frustrating. Your AI model is doing impressive things, and the user is reading a paragraph. GenUI closes that gap in a way that feels native to Flutter.

Flutter’s architecture is a natural fit. The declarative, composable nature of widgets means dynamic UI generation isn’t fighting the framework. It’s working with it. Other platforms have to bend their architecture to make this work. Flutter doesn’t.

It’s backend agnostic. You’re not forced into Gemini. Any model that can return structured JSON works. That flexibility matters as the AI landscape keeps shifting.

You stay in control of your design. The agent decides structure and content, but your widgets define the look. Your branding stays intact.

What to watch out for

It’s alpha. The API will change, sometimes significantly. Building anything production-critical on it today means accepting that you’ll be rewriting parts of it as it evolves.

Your catalog has to be defined upfront. The agent can only use what you’ve registered. If a user’s request needs something you haven’t built, the agent has no way to handle it.

Debugging is harder. When something renders wrong, tracing it back through the agent response, the deserialisation, and the widget registry takes more effort than a standard Flutter layout issue.

The ecosystem is still early. Documentation is thin in places and community examples are limited. Expect some friction if you’re picking this up for the first time.

My take

If you’re building agent-powered Flutter apps right now, GenUI is absolutely worth experimenting with. The concept is solid, the architecture fits Flutter well, and the direction is clearly right. Just go in with eyes open about the alpha status and don’t bet a production launch on it just yet.

The shift from fixed screens to adaptive apps isn’t coming. It’s already here. And Flutter, as always, has a place right in the middle of it.

Follow along here: https://renuvkelkar.github.io/genui-flutter-codelab/genui-flutter-travel-planner/


메타데이터
post_id
be2da84e291e
slug
beyond-fixed-screens-building-adaptive-flutter-apps-with-genui-and-a2ui-be2da84e291e
url
https://medium.com/@renuvkelkar/beyond-fixed-screens-building-adaptive-flutter-apps-with-genui-and-a2ui-be2da84e291e
canonical_url
https://medium.com/@renuvkelkar/beyond-fixed-screens-building-adaptive-flutter-apps-with-genui-and-a2ui-be2da84e291e
author_url
https://medium.com/@renuvkelkar
status
ok
fetched_at
2026-07-10 01:40:30