Skip to content
Blog

GenUI TransportAdapter: Wiring a Flutter GenUI App to Any LLM Agent

The A2uiTransportAdapter replaced ContentGenerator in Flutter's genui package after the A2UI v0.9 rewrite. Learn how the Engine/Transport/Facade split works, how onSend and addChunk stream tokens, and how to build your own transport for any LLM provider, WebSocket agent, or mock backend.

Published on • October 11, 2026

AI Assistant

You have a working Flutter chat app wired to Gemini through genui, and then the requirements change. Maybe your team decides the agent must run behind your own WebSocket gateway so you can authenticate users and log every turn. Maybe legal requires that no production traffic goes to a third-party SDK until it passes a security review. Maybe you simply want to test the widget-catalog logic on CI without burning API credits. With the pre-v0.9 genui package, each of these forced the same awkward move: subclass or replace a ContentGenerator, an abstraction that owned prompt construction, network calls, and response parsing in one opaque class.

That coupling is gone. The A2UI v0.9 rewrite removed ContentGenerator entirely and replaced it with three layers, and the middle layer — A2uiTransportAdapter — is the single seam through which every token between your app and any agent now flows. Your app owns the connection; genui owns parsing and rendering. This post focuses on that seam: what the adapter does, how onSend and addChunk form the streaming loop, how to build transports for HTTP, WebSocket, and mock agents, and how to migrate off ContentGenerator without rewriting your widget catalog.

In this tutorial, you will learn how to:

  • Map the Engine / Transport / Facade split onto concrete genui classes and understand who owns what
  • Implement the onSend callback and feed agent responses back through addChunk for token streaming
  • Build custom transports: plain HTTP streaming, a WebSocket agent channel, and a mock transport for tests
  • Use PromptBuilder to produce the system prompt your transport must deliver to the agent
  • Own error handling, retries, and chat history now that the framework no longer wraps your LLM call
  • Migrate an existing ContentGenerator-based app to the adapter pattern step by step
  • Recognise the A2UI v0.9 protocol changes that affect anything hand-constructing payloads

Key technologies: Flutter, package:genui (0.10.x), A2UI protocol v0.9, A2uiTransportAdapter, SurfaceController, Conversation, PromptBuilder, genai_primitives (ChatMessage, MessagePart).

Prerequisites

  • Flutter 3.35.7 or later (the current constraint for genui on pub.dev)
  • A Flutter project with genui added: flutter pub add genui
  • An LLM provider account or agent endpoint of your choice — any backend that can receive a prompt and return text works
  • Familiarity with Streams and Futures in Dart; prior exposure to GenUI or A2UI is helpful but not required
  • For iOS/macOS targets, the com.apple.security.network.client entitlement in your runner config so outbound requests succeed

The three layers, and where Transport sits

The v0.9 rewrite split the framework into three layers with deliberately narrow responsibilities:

LayerClassOwns
EngineSurfaceControllerUI surface lifecycle, the observable DataModel, applying A2UI messages
TransportA2uiTransportAdapter (implements Transport)Streaming messages between agent and renderer
FacadeConversationHigh-level chat API: sendRequest, events, host wiring

SurfaceController is constructed with your widget catalogs and never performs I/O. Conversation ties a controller and a transport together and orchestrates the turn loop. Everything network-shaped lives in the Transport — and A2uiTransportAdapter is the provided implementation that wraps A2uiParserTransformer to turn raw text chunks into structured GenerationEvents.

The critical contract: genui calls your code exactly once per turn, via the adapter’s onSend callback. Everything after that — how you call the agent, how you authenticate, how you retry — is yours. Tokens come back in via adapter.addChunk(text), and the adapter parses them.

The streaming loop: onSend and addChunk

Here is the canonical wiring. Note the shape: onSend receives a ChatMessage, you serialise its parts to text, call your agent, and pipe the response back one chunk at a time:

late final SurfaceController _controller;
late final A2uiTransportAdapter _transport;
late final Conversation _conversation;

void initState() {
  _controller = SurfaceController(
    catalogs: [BasicCatalogItems.asCatalog()],
  );
  _transport = A2uiTransportAdapter(onSend: _onSendToAgent);
  _conversation = Conversation(
    controller: _controller,
    transport: _transport,
  );

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

Future<void> _onSendToAgent(ChatMessage message) async {
  final buffer = StringBuffer();
  for (final part in message.parts) {
    if (part.isUiInteractionPart) {
      buffer.write(part.asUiInteractionPart!.interaction);
    } else if (part is TextPart) {
      buffer.write(part.text);
    }
  }

  final stream = myAgentClient.streamReply(buffer.toString());
  await for (final chunk in stream) {
    _transport.addChunk(chunk);
  }
}

// In dispose(): _conversation.dispose(); _transport.dispose(); _controller.dispose();
// In build(): Surface(host: _conversation.host, surfaceId: id) for each id in _surfaceIds.

Two details matter here. First, part.isUiInteractionPart lets you fold UI interactions (button taps the agent rendered) back into the string you send the agent — that is the high-bandwidth feedback loop GenUI exists to create. Second, addChunk is called inside an await for loop, so partial tokens render as they arrive; you do not wait for the full response before the surface starts updating.

Building a transport for any provider

Because onSend is an ordinary async callback, the same pattern adapts to any backend. Three shapes cover most real deployments.

Custom HTTP endpoint

If your agent is an ordinary HTTPS endpoint that streams plain text (or an SSE stream you decode yourself), no SDK is required. You can also use provider SDKs such as firebase_ai or dartantic_ai directly inside onSend — the framework no longer cares which one you pick:

final _client = http.Client();

Future<void> _onSendToAgent(ChatMessage message) async {
  final request = http.Request('POST', Uri.parse('https://agent.example.com/chat'));
  request.headers['Content-Type'] = 'application/json';
  request.body = jsonEncode({
    'prompt': _stringify(message),
    'system': _promptBuilder.systemPrompt,
  });

  final response = await _client.send(request);
  await for (final line in response.stream.transform(utf8.decoder).transform(const LineSplitter())) {
    if (line.startsWith('data: ')) {
      _transport.addChunk(line.substring(6));
    }
  }
}

The important design point: the system prompt you built with PromptBuilder (see below) travels with the request. The adapter never constructs prompts for you any more.

WebSocket agent channel

For an interactive agent that keeps a session open — pushing intermediate status, tool-call updates, or UI patches between turns — a WebSocket transport is a natural fit. Route every inbound frame through addChunk:

web_socket_channel.WebSocketChannel? _channel;

Future<void> _onSendToAgent(ChatMessage message) async {
  _channel ??= web_socket_channel.WebSocketChannel.connect(
    Uri.parse('wss://agent.example.com/session'),
  );

  _channel!.sink.add(jsonEncode({'text': _stringify(message)}));

  await for (final frame in _channel!.stream) {
    final payload = jsonDecode(frame as String) as Map<String, dynamic>;
    if (payload['type'] == 'a2ui' || payload['type'] == 'text') {
      _transport.addChunk(payload['chunk'] as String);
    }
  }
}

If your backend speaks the A2UI protocol natively, the repository also ships genui_a2a with an A2uiAgentConnector for exactly that case. For anything else, the pattern above is the whole integration.

Mock agent for tests

This is where the decoupling pays for itself. Unit-testing a GenUI surface previously meant standing up a ContentGenerator against a real or heavily stubbed provider client. Now a test can feed hard-coded tokens:

test('renders a text surface from a scripted agent response', () async {
  final controller = SurfaceController(
    catalogs: [BasicCatalogItems.asCatalog()],
  );
  final transport = A2uiTransportAdapter(
    onSend: (message) async {
      const script = '...createSurface and updateComponents JSON...';
      for (final chunk in _chunk(script, 8)) {
        transport.addChunk(chunk);
        await Future<void>.delayed(Duration.zero);
      }
    },
  );
  final conversation = Conversation(
    controller: controller,
    transport: transport,
  });

  conversation.sendRequest(ChatMessage.user('hello'));
  await conversation.events.firstWhere((e) => e is ConversationSurfaceAdded);

  expect(controller, isNotNull);
});

No network, no credentials, deterministic timing. The transport is the mock.

PromptBuilder: the prompt your transport must carry

With prompt-first architecture, the UI schema and A2UI formatting rules are injected into the system prompt as plain text rather than passed out-of-band as structured-output API parameters. Your app is responsible for getting that prompt into the agent’s context — and PromptBuilder generates it from your catalog:

final catalog = BasicCatalogItems.asCatalog();

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

// Hand promptBuilder.systemPrompt to whatever agent your transport calls.

A practical pattern is to build the prompt once in initState and close over it in onSend, so every turn sends a consistent system instruction while user turns vary. If your catalog gains new CatalogItems, rebuild the prompt — the schema in the prompt is what teaches the agent which components it may emit.

Who owns errors and retries now

Under ContentGenerator, the framework owned (and hid) failures inside the LLM call. In the new architecture, error handling and retry ownership sit with your transport, which is both a responsibility and an opportunity. A robust onSend handles three failure classes:

FailureExampleHandling
Transport errorsTimeout, DNS failure, socket closeRetry with backoff inside onSend; on final failure, surface a snackbar rather than throwing into the stream
Agent-level errorsHTTP 429/5xx, refused contentInspect the response before piping chunks; do not addChunk an error document as if it were A2UI
Partial streamsConnection dies mid-responseTreat the partial parse as-is; the A2uiParserTransformer tolerates chunk boundaries, but you may want to notify the user the turn was truncated

A minimal retry wrapper keeps the main callback readable:

Future<void> _onSendToAgent(ChatMessage message) async {
  for (var attempt = 0; attempt < 3; attempt++) {
    try {
      await _streamAgentReply(message);
      return;
    } on SocketException catch (e) {
      if (attempt == 2) rethrow;
      await Future<void>.delayed(Duration(seconds: 1 << attempt));
    }
  }
}

Because you own history too, decide explicitly whether each retry replays the full conversation or only the latest user turn — provider SDKs differ, and the framework will not do it for you.

Migrating from a ContentGenerator-based app

If you have a v0.7-era app, the migration is localised to the wiring layer; your catalogs and surface widgets carry over.

  1. Remove the provider wrapper packages. Names like genui_dartantic, genui_google_generative_ai, and genui_firebase_ai are gone from the tree — delete them from pubspec.yaml.
  2. Delete the ContentGenerator construction and anything that passed it into GenUiConversation.
  3. Replace GenUiManager/GenUiConversation with SurfaceController + A2uiTransportAdapter + Conversation, using the onSend implementation shown earlier. Class renames apply broadly: GenUiConversation → Conversation, GenUiController → SurfaceController, GenUiSurface → Surface, GenUiTransport → Transport.
  4. Move prompt assembly into your app. Build the system instruction with PromptBuilder.chat and pass it in the requests your transport makes.
  5. Adopt BasicCatalogItems in place of CoreCatalogItems, and note that messaging types now come from the sibling genai_primitives package (ChatMessage, MessagePart, ToolDefinition).
  6. Update hand-built A2UI payloads, if any: beginRendering → createSurface; flat component discriminators ({"component": "Text", "text": "Hello"}); simplified bindings ({"path": "/path/to/var"}); property renames such as distribution → justify and usageHint → variant.

The old and new side by side:

// Old (v0.7): framework owns the agent.
final generator = FirebaseAiContentGenerator(
  catalog: CoreCatalogItems.asCatalog(),
  systemInstruction: 'You are a helpful assistant.',
);
final conversation = GenUiConversation(
  genUiManager: GenUiManager(catalog: catalog),
  contentGenerator: generator,
);

// New (v0.9+): your transport owns the agent.
final surfaceController = SurfaceController(catalogs: [BasicCatalogItems.asCatalog()]);
late final adapter = A2uiTransportAdapter(onSend: _onSendToAgent);
final conversation = Conversation(
  controller: surfaceController,
  transport: adapter,
);

Yes, the new version has more lines of wiring. The trade is explicit control over lifecycle, history, retries, and provider choice — and a framework that no longer needs a package update when you switch models.

Common pitfalls

  • Forgetting the system prompt. Prompt-first means if you do not send promptBuilder.systemPrompt, the agent has no schema and will emit prose instead of A2UI JSON. Symptom: surfaces never appear and the chat is all text.
  • Buffering the whole response before addChunk. Collecting the full string first defeats streaming; call addChunk per token or per SSE frame.
  • Throwing from onSend without a plan. An uncaught exception there surfaces as a hung turn, not a user-visible error. Catch, retry, and notify.
  • Assuming the API is frozen. The repository is labelled highly experimental and a redesign into modular a2ui_core, a2ui_agent, and a2ui_flutter packages is announced — treat transport code as portable glue and pin your genui version.
  • Skipping the entitlement on Apple platforms. Outbound HTTP/WebSocket calls fail at the platform level without com.apple.security.network.client.

Further reading