Skip to content
Blog

GenUI with A2UI Streaming: Wiring a Live Agent to Flutter Widgets

Stream A2UI protocol messages from an LLM into Flutter's GenUI SDK and watch surfaces build themselves in real time.

Published on • October 19, 2026

AI Assistant

The GenUI SDK for Flutter doesn’t speak “tokens” — it speaks A2UI, the Streaming UI Protocol from a2ui.org. Your job in a GenUI app is narrow and mechanical: get raw chunks out of an LLM (or a server agent), hand them to a transport adapter, and let the framework turn them into widgets. Everything between addChunk() and a rendered Surface is parsing, message application, and reactive rebuilds.

Here’s the streaming path end to end, based on the current get-started guide (the docs reflect Flutter 3.47 and were last updated September 29, 2026).

The three actors

You instantiate exactly three objects, in this order:

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

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

final conversation = Conversation(
  controller: surfaceController,
  transport: transportAdapter,
);
  • SurfaceController owns widget catalogs and applies A2UI messages (createSurface, updateComponents, updateDataModel, deleteSurface) to the UI state.
  • A2uiTransportAdapter is the seam. Its onSend callback receives a ChatMessage; everything after that is your code feeding chunks back in with addChunk().
  • Conversation wires transport → controller internally and exposes an events stream you subscribe to for surface lifecycle.

The onSend body is deliberately backend-agnostic. The docs list three provider options: Firebase AI Logic (all inference in the client, Firebase manages the Gemini API key), GenUI A2UI (client/server, agent runs on a server), or build your own adapter for any other LLM endpoint. The Firebase path looks like this:

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

final model = FirebaseAI.vertexAI().generativeModel(
  model: 'gemini-3.5-flash',
  systemInstruction: Content.system(promptBuilder.systemPromptJoined()),
);

What happens to a chunk

Inside the adapter, A2uiParserTransformer — which you get via A2uiTransportAdapter — parses the raw text stream incrementally. Partial JSON arriving across dozens of network chunks is buffered until complete messages fall out as A2uiMessageEvents, and ordinary prose falls out as TextEvents. That’s the difference between a stream that renders a button halfway through generation and one that waits for the full response: the parser is designed for robust chunk-boundary handling, so UI appears as soon as a message is well-formed.

Once parsed, the SurfaceController applies the messages. A createSurface allocates a new surface ID; updateComponents mutates component trees; updateDataModel writes paths in the shared DataModel; deleteSurface tears one down. Your app only watches the lifecycle:

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

and renders each one:

ListView.builder(
  itemCount: _surfaceIds.length,
  itemBuilder: (context, index) {
    final id = _surfaceIds[index];
    return Surface(surfaceContext: _surfaceController.contextFor(id));
  },
)

Sending is one line: _conversation.sendRequest(ChatMessage.user(text)). The Conversation listens to submit events coming back from the UI (button taps, field submissions) and can trigger the next model request automatically, closing the loop without your code re-entering it.

The system prompt is the real configuration

The LLM only emits A2UI because PromptBuilder’s system prompt tells it the tools exist. The docs are explicit: “the system instructions provided through PromptBuilder must explicitly tell it to do so.” A typical fragment names the widget and the trigger:

final promptBuilder = PromptBuilder.chat(
  catalog: catalog,
  systemPromptFragments: [
    '''
    You are an expert in creating funny riddles. Every time I give you a word,
    you should generate UI that displays one new riddle related to that word.
    Each riddle should have both a question and an answer.
    ''',
  ],
);

One warning straight from the docs: PromptBuilder.chat() can produce a system prompt 3,000–5,000+ tokens long, which can blow past the context window of an on-device or small model. If you target those, write a compact custom prompt covering just the createSurface → updateComponents flow, or pass only the schema portions you need through systemPromptFragments.

Streaming a server agent instead

If the agent lives behind a WebSocket rather than an LLM API, you swap the source, not the pipeline: the docs’ “GenUI A2UI” provider uses genui_a2a’s A2uiAgentConnector, whose stream you pipe into _surfaceController.handleMessage and whose textStream feeds _transport.addChunk. Details in the companion post on the genui_a2a package.

Debugging the pipe

Turn on protocol-level logging before you debug prompts:

import 'package:logging/logging.dart';
import 'package:genui/genui.dart';

final logger = configureLogging(level: Level.ALL);

void main() {
  logger.onRecord.listen((record) {
    debugPrint('${record.loggerName}: ${record.message}');
  });
}

You’ll see raw chunks, parsed A2uiMessages, and controller applications — which makes it obvious whether a blank screen is a prompt problem (no createSurface emitted) or a parsing problem (message emitted but never applied).

Two practical gotchas: on iOS and macOS, add com.apple.security.network.client with a true value to your Runner/*.entitlements or outbound requests silently fail, and if you pull in Firebase, check that your Podfile / CMakeLists.txt minimum versions meet Firebase’s requirements.

For a full client/server reference, the docs point at the Verdure example in the flutter/genui repo — an A2UI sample with the agent on the server. The package is alpha, so pin genui and expect API movement.

Sources