Skip to content
Blog

genui_a2a: Pointing Flutter's GenUI SDK at a Backend Agent

The genui_a2a package connects Flutter's GenUI framework to a server-side agent over A2A — connector, streams, and wiring explained.

Published on • October 19, 2026

AI Assistant

So far, the GenUI examples you’ve seen call the LLM straight from the client — Firebase AI Logic does the heavy lifting, and the API key never leaves Google’s infrastructure. That’s fine for prototypes. It falls apart the moment your agent needs your CRM, your database, or tools that must never run on a user’s phone. For that architecture, the Flutter team ships genui_a2a (0.10.1, published by labs.flutter.dev): an integration package that connects genui to an Agent-to-Agent (A2A) server and renders whatever UI the server-side agent generates.

The get-started guide lists this as the “GenUI A2UI” provider option, explicitly framed as “useful for client/server architectures where your agent is running on the server.”

What the package adds

genui itself is transport-agnostic. genui_a2a fills in the transport with two components:

  • A2uiAgentConnector — handles the low-level communication with the A2A server, including sending messages and parsing stream events.
  • AgentCard — a data class holding metadata about the connected AI agent.

Under the hood it depends on a2ui_core, http, and sse_channel, and it maintains conversation state with the server (taskId, contextId) so multi-turn sessions stay coherent. Feature-wise it processes the same A2UI message vocabulary you’d get from a local model — CreateSurface, UpdateComponents, UpdateDataModel — and pipes them into genui’s SurfaceController. It also runs the loop in reverse: UI events captured by genui go back to the A2A server as A2A messages.

The wiring

Setup is seven steps, and the interesting part is which object listens to which stream. First, dependencies:

dart pub add genui genui_a2a

Then, in your screen’s state:

class _ChatScreenState extends State<ChatScreen> {
  final _controller = SurfaceController(
    catalogs: [CoreCatalogItems.asCatalog()],
  );
  late final A2uiTransportAdapter _transport;
  late final Conversation _conversation;
  late final A2uiAgentConnector _connector;
  late final StreamSubscription _subscription;
  late final StreamSubscription _textSubscription;

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

    // Transport: onSend pushes user turns at the connector.
    _transport = A2uiTransportAdapter(onSend: _sendToAgent);

    // Connector: your A2A server's URL.
    _connector = A2uiAgentConnector(
      url: Uri.parse('http://localhost:8080'),
    );

    // Conversation wires transport -> controller internally.
    _conversation = Conversation(
      controller: _controller,
      transport: _transport,
    );

    // Parsed A2UI messages drive the surfaces...
    _subscription = _connector.stream.listen(_controller.handleMessage);
    // ...while raw text chunks feed the parser.
    _textSubscription = _connector.textStream.listen(_transport.addChunk);
  }

  Future<void> _sendToAgent(ChatMessage message) async {
    await _connector.connectAndSend(message);
  }

  @override
  void dispose() {
    _conversation.dispose();
    _transport.dispose();
    _controller.dispose();
    _connector.dispose();
    _subscription.cancel();
    _textSubscription.cancel();
    super.dispose();
  }
}

Sending a turn is then just _conversation.sendRequest(ChatMessage.user(text)), and rendering is the familiar Surface widget bound to a surface context:

SizedBox(
  height: 300,
  child: Surface(surfaceContext: _controller.contextFor('main_surface')),
)

Note the two ingress paths: connector.stream delivers structured messages that go straight to handleMessage, and connector.textStream delivers text chunks that go through addChunk for parsing. Whether your server frames output as one or the other determines which subscription does the work — most apps will simply wire both, as above. You can also call connector.connectAndSend(message) directly when you want the raw response text and aren’t routing through Conversation.sendRequest.

Why run the agent on a server at all

Three reasons that show up in every production A2UI deployment:

  1. Tools and data stay server-side. The agent can query internal systems, call privileged APIs, and enforce authorization — none of which you want in a shipped binary.
  2. One agent, many renderers. The same A2A endpoint can serve your Flutter app today and a web client tomorrow. The A2UI ecosystem page documents exactly this pattern: Google ADK converts between A2A DataPart metadata and A2UI events, and AG2’s A2UIAgent serves identical UIs over A2A (JSON-RPC) and AG-UI (SSE) to web, desktop, and mobile clients.
  3. Model changes ship without an app release. Swap or re-prompt the backend; clients on old versions keep working because the protocol — not the model version — is the contract.

Practical notes

  • Catalog parity is your responsibility. The server agent generates components by name; your client only renders what its SurfaceController catalogs contain. Keep the server’s component vocabulary and your client CatalogItems in sync, or the agent will emit components that silently render nothing.
  • Debug with logging. genui_a2a builds on the logging package; the package example enables Logger.root.level = Level.ALL and prints records, which surfaces the A2A handshake and every parsed A2UI message.
  • iOS/macOS entitlements. Add com.apple.security.network.client (set to true) to your Runner/*.entitlements, or the connector’s requests will fail without a useful error.
  • Still alpha. genui and genui_a2a are marked experimental on the docs site — pin versions and read the changelog before upgrading.

The example directory on pub.dev contains a complete app covering this wiring, and the flutter/genui repo’s verdure sample demonstrates the client/server A2UI flow end to end. If you’re moving an AI feature off-device this year, this is the path Flutter has drawn on the map.

Sources