Skip to content
Blog

The GenUI Conversation Facade: Wiring the Agent Loop in Six Objects

Conversation, SurfaceController, and A2uiTransportAdapter form the genui runtime — here is how each one actually behaves.

Published on • October 20, 2026

AI Assistant

Most of the confusion around the genui package comes from trying to use it like a chat widget. It isn’t one. The Conversation class is the package’s primary facade: it encapsulates the SurfaceController and a Transport, manages conversation events, and orchestrates the whole generative UI process. Once you understand what each layer owns, the wiring is short.

The four objects and what they own

  • Conversation — the entry point. You call sendRequest(), you listen to events, you dispose it last.
  • SurfaceController — the runtime engine. It processes A2uiMessages (createSurface, updateComponents, updateDataModel, deleteSurface), manages the DataModel, and tracks the lifecycle of UI surfaces.
  • A2uiTransportAdapter — the Transport implementation. It wraps the A2uiParserTransformer, which robustly parses A2UI message streams arriving as raw text chunks from an LLM.
  • Surface — the widget you place in your tree, keyed by a surface ID.

The adapter is deliberately backend-agnostic: you implement its onSend callback and pipe your model’s stream into addChunk(). Firebase AI, a raw HTTP client, your own backend — all look identical from genui’s side.

Construction

class _MyHomePageState extends State<MyHomePage> {
  late final SurfaceController _controller;
  late final A2uiTransportAdapter _transport;
  late final Conversation _conversation;

  @override
  void initState() {
    super.initState();

    // Create a SurfaceController with a widget catalog.
    _controller = SurfaceController(catalogs: [CoreCatalogItems.asCatalog()]);

    // Transport adapter: the bridge between your LLM and the framework.
    _transport = A2uiTransportAdapter(onSend: _onSendToLLM);

    // The Conversation orchestrates everything.
    _conversation = Conversation(
      controller: _controller,
      transport: _transport,
    );

    _conversation.events.listen((event) {
      if (event is ConversationSurfaceAdded) {
        _onSurfaceAdded(event);
      } else if (event is ConversationSurfaceRemoved) {
        _onSurfaceRemoved(event);
      }
    });
  }

  Future<void> _onSendToLLM(ChatMessage message) async {
    final responseStream = myLlmClient.streamGenerateContent(message);
    await for (final chunk in responseStream) {
      _transport.addChunk(chunk);
    }
  }

  @override
  void dispose() {
    _textController.dispose();
    _conversation.dispose();
    _transport.dispose();
    _controller.dispose();
    super.dispose();
  }
}

Order matters here: the SurfaceController needs its catalogs up front, the transport needs onSend before any request, and the Conversation takes both. On teardown, dispose in reverse.

The loop you actually implement

The README lays out the cycle precisely:

  1. The user types something; you call _conversation.sendRequest(ChatMessage.user(text)).
  2. Conversation invokes A2uiTransportAdapter.onSend.
  3. Your onSend calls the LLM and feeds response chunks into addChunk().
  4. A2uiParserTransformer parses chunks into TextEvents or A2uiMessageEvents.
  5. SurfaceController applies the messages and updates the DataModel.
  6. Surface widgets listening to that controller rebuild.
  7. User interaction (a tap, typed text) is captured; SurfaceController emits a ChatMessage on submit.
  8. Conversation hears the submit and automatically triggers the next request — the loop closes without you writing it.

Step 8 is the part people miss. Conversation listens to onSubmit, so an action button inside generated UI can continue the conversation on its own. Your job is sending the first message and rendering surfaces.

Rendering surfaces

Surface IDs arrive through events, so track them and build a Surface per ID:

void _sendMessage(String text) {
  if (text.trim().isEmpty) return;
  _conversation.sendRequest(ChatMessage.user(text));
}

// in build():
ListView.builder(
  itemCount: _surfaceIds.length,
  itemBuilder: (context, index) {
    final id = _surfaceIds[index];
    return Surface(host: _conversation.host, surfaceId: id);
  },
)

ConversationSurfaceAdded carries a surfaceId; ConversationSurfaceRemoved tells you when to drop it. Add and remove those IDs in setState and the list stays in sync with what the model created and deleted.

Where the facade saves you

Without Conversation, you’d be hand-managing: transport lifecycle, parser event routing, surface bookkeeping, history, and the submit-to-request feedback loop. The facade collapses all of that into an events stream plus one send method. It also means your test surface is small — you can fake the transport by implementing onSend yourself, which is exactly what makes unit testing a GenUI feature straightforward: feed canned chunks through addChunk() and assert on the surfaces that appear.

Note the docs’ component overview describes the same cycle with slightly different method names (sendMessage(), handleMessage()); the package README for 0.10.4 uses sendRequest() and addChunk(). Check the API reference for your pinned version before copying code — the package is explicitly experimental.

Debugging the pipeline

When a surface doesn’t appear, the failure is almost always in the parse step, not the widget step. Enable logging as shown in the README:

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}');
  });
}

That trace shows raw chunks, parsed A2uiMessages, and controller handling — enough to tell whether the model emitted bad A2UI, the transport dropped a chunk, or the catalog rejected the requested widget. Also remember that on iOS and macOS builds you need the outbound network client entitlement in your *.entitlements files, or the LLM call silently never leaves the device.

Sources