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 callsendRequest(), you listen toevents, you dispose it last.SurfaceController— the runtime engine. It processesA2uiMessages (createSurface,updateComponents,updateDataModel,deleteSurface), manages theDataModel, and tracks the lifecycle of UI surfaces.A2uiTransportAdapter— theTransportimplementation. It wraps theA2uiParserTransformer, 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:
- The user types something; you call
_conversation.sendRequest(ChatMessage.user(text)). ConversationinvokesA2uiTransportAdapter.onSend.- Your
onSendcalls the LLM and feeds response chunks intoaddChunk(). A2uiParserTransformerparses chunks intoTextEvents orA2uiMessageEvents.SurfaceControllerapplies the messages and updates theDataModel.Surfacewidgets listening to that controller rebuild.- User interaction (a tap, typed text) is captured;
SurfaceControlleremits aChatMessageon submit. Conversationhears 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.