Skip to content
Blog

GenUI Prompt First: How Flutter's GenUI Now Builds UI From System Prompts

Flutter's GenUI package moved from Structured Output First to Prompt First with A2UI v0.9. Here is what changed, why it matters, and how to wire PromptBuilder, TransportAdapter, and SurfaceController.

Published on • October 9, 2026

AI Assistant

Generative UI (GenUI) is the pattern where an agent does not only generate content — it decides how that content is displayed and made interactive. In Flutter, the genui package implements this by connecting to an agent over the A2UI protocol and rendering the result from a widget catalog in your own project. Importantly, the UI is not generated as code: it is generated at runtime as JSON that describes widgets your app already ships.

With the May 2026 update driven by A2UI v0.9, GenUI changed how that JSON gets produced. The package moved from Structured Output First to Prompt First — and the difference matters if you are building agentic Flutter apps in 2026.

The two approaches

Structured Output First (the old way) streamed A2UI messages through the provider’s structured-output APIs — JSON mode or function calling — with the schema passed out-of-band via API parameters.

Prompt First (the new way) puts everything in plain text: the agent includes blocks of JSON directly in its responses, and the UI schema plus A2UI formatting rules are injected into the system prompt as plain text.

Why the switch

Google’s stated rationale for the change:

  • Deeply nested schemas confused models. Structured-output constraints fought the model’s natural text-generation tendencies.
  • Catalog size limits. Structured outputs imposed caps on how large and complex your widget catalog could get.
  • Debugging was painful. Constraints lived in the network layer where you could not easily see or edit them.

Prompt First moves the source of truth to where LLMs actually excel: system instructions. Modern models are trained to follow detailed system prompts and examples, and now the schema is plain text you can inspect, edit, and version per app.

PromptBuilder: the new entry point

The PromptBuilder class takes your catalog and prompt fragments and produces the system prompt containing schema definitions and A2UI formatting rules:

final promptBuilder = PromptBuilder.chat(
  catalog: catalog,
  systemPromptFragments: [
    'You are a helpful travel assistant.',
  ],
);

final systemPrompt = promptBuilder.systemPrompt;
// Pass systemPrompt to your LLM of choice

Your app is now responsible for getting the right prompt into context. That sounds like more work — it is the price of owning the connection.

The three-layer architecture

Alongside Prompt First, genui was decoupled. The old ContentGenerator class was removed and replaced with three layers:

LayerClassResponsibility
EngineSurfaceControllerState and rendering
TransportA2uiTransportAdapterStreams messages between agent and renderer
FacadeConversationHigh-level chat API

Provider wrapper packages (genui_dartantic, genui_google_generative_ai, genui_firebase_ai) are gone from the tree. You own chat history, retry, error handling, model choice, and lifecycle.

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

late final adapter = A2uiTransportAdapter(
  onSend: (ChatMessage msg) async {
    final buffer = StringBuffer();
    for (final part in msg.parts) {
      if (part.isUiInteractionPart) {
        buffer.write(part.asUiInteractionPart!.interaction);
      } else if (part is TextPart) {
        buffer.write(part.text);
      }
    }
    final response = await myAgentClient.sendRequest(buffer.toString());
    adapter.addChunk(response); // parsed as an A2UI stream
  },
);

final conversation = Conversation(
  controller: controller,
  transport: adapter,
);

The benefits: any AI source without waiting for a package update, loose coupling (only tokens move between layers), and simple testing with mock or hard-coded token sources.

How a response becomes UI

Inside onSend, you call your LLM and pipe the response text back through adapter.addChunk(response). The A2uiParserTransformer parses chunks into GenerationEvents — plain text vs. A2UI messages — and the SurfaceController applies them. Your widget tree then renders the surface:

return Surface(host: conversation.host, surfaceId: id);

The surfaceId arrives via ConversationSurfaceAdded events, and user input flows back through conversation.sendRequest(ChatMessage.user(text)).

A2UI v0.9 breaking changes

If you are migrating, expect renames and structural changes:

  • beginRendering → createSurface
  • Flat discriminators: {"component": "Text", "text": "Hello"} instead of nested {"Text": {...}}
  • Simplified data binding: {"path": "/path/to/var"}
  • distribution → justify, alignment → align, usageHint → variant
  • GenUiConversation → Conversation, GenUiController → SurfaceController, GenUiSurface → Surface
  • New sibling package genai_primitives (types: ChatMessage, MessagePart, ToolDefinition)

Gotchas before you ship

  • The repo is labeled highly experimental — the API will change. A redesign into modular a2ui_core, a2ui_agent, and a2ui_flutter packages is already announced.
  • Current stable is genui 0.10.4 on pub.dev, requiring Flutter 3.35.7+.
  • You must build and wire the LLM call yourself — no more provider wrappers.
  • iOS/macOS builds need the com.apple.security.network.client entitlement for outbound requests.
  • The DataModel is observable: widgets bound to it rebuild when the model updates, which is how the agent mutates UI state after the initial render.

Wrapping up

Prompt First makes GenUI debugging tractable: the schema is text you can read in your editor, not a constraint buried in an API parameter. Combined with the Engine/Transport/Facade split, you get a Flutter package that renders agent-driven UI while staying agnostic about which model produces it. It is still experimental — but it is the direction A2UI is heading.

References