Skip to content
Blog

GenUI Custom Agent Adapters: Connect Flutter to Any LLM

The GenUI SDK ships adapters for Firebase AI Logic and A2UI servers, but its real power is that it is backend-agnostic. Learn how to write a custom agent adapter that plugs any LLM into Flutter's generative UI pipeline.

Published on • October 21, 2026

AI Assistant

When you first pick up the GenUI SDK for Flutter, the quickstart nudges you toward one of two built-in paths: Firebase AI Logic for client-only apps, or the A2UI package for a client/server agent. Both are convenient. Neither is mandatory. The most important thing to understand about genui is that it is deliberately backend-agnostic — the framework does not care where the model lives, only that text tokens flow back into it. That separation is what makes a custom agent adapter not just possible, but the intended escape hatch.

Source: Get started with the GenUI SDK for Flutter and the genui package README, The Flutter Team / labs.flutter.dev.

The adapter is the seam

After the architecture decoupling that shipped with A2UI v0.9, genui is split into three layers: the SurfaceController (engine), the A2uiTransportAdapter (transport), and the Conversation (facade). The transport layer is the seam where your app meets an AI provider. Everything above it — surface lifecycle, the data model, widget rendering — is provider-agnostic. Everything below it is your code.

The contract is small. A2uiTransportAdapter takes an onSend callback with a single signature:

Future<void> onSend(ChatMessage message)

Inside that callback you call whatever model you like, then pipe the streaming response back through addChunk. The framework does the rest: A2uiParserTransformer turns raw text chunks into structured GenerationEvents, SurfaceController applies them, and bound Surface widgets rebuild.

late final A2uiTransportAdapter transport;

transport = A2uiTransportAdapter(
  onSend: (ChatMessage message) async {
    // Call *any* backend here — a proxy, a local model, a rival provider.
    final stream = await myAgentClient.streamCompletion(message);
    await for (final chunk in stream) {
      transport.addChunk(chunk);
    }
  },
);

final conversation = Conversation(
  controller: surfaceController,
  transport: transport,
);

That is the whole integration surface. There is no ContentGenerator to implement, no provider base class to extend, no waiting for the Flutter team to ship a wrapper package. If your endpoint speaks HTTP and returns text, it can drive a GenUI surface.

Why you would build your own

There are three common reasons teams reach for a custom adapter rather than a shipped one.

A proxy or gateway. Many production apps do not call a model directly from the client. They route through their own backend for key management, rate limiting, and audit logging. A custom adapter lets that gateway sit between the app and the model without genui knowing the difference.

An on-device or local model. If you are running inference locally — a quantized model behind a native binding, or a desktop app talking to a local server — the Firebase and A2UI packages are the wrong shape. A thin onSend that forwards to a local endpoint gives you generative UI with no network round trip to the cloud.

A non-Gemini provider. Because the provider-specific wrapper packages disappeared in the decoupling, “use the model of the week” is now a one-function change instead of a dependency hunt. Swapping providers touches only onSend.

Streaming and event discipline

The one rule that matters when writing an adapter is that addChunk expects a text stream of A2UI-formatted messages, not a finished document. Feed it incrementally as chunks arrive. Buffering the full response and calling addChunk once technically works, but it throws away the streaming UI that is the whole point of generative interfaces — the user watches a surface assemble itself rather than staring at a spinner.

The parser is tolerant by design. It accumulates partial text, emits TextEvents for prose, and emits A2uiMessageEvents when it recognizes a complete A2uiMessage (createSurface, updateComponents, updateDataModel, deleteSurface). Your adapter does not need to know which is which; just forward bytes as they arrive.

System instructions still belong to you

Remember the Prompt First shift: the UI schema and A2UI rules are injected as plain text into the system prompt, not enforced through provider-level JSON constraints. When you build a custom adapter, generating that system prompt is now your responsibility. Use PromptBuilder to do the heavy lifting:

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

// Get the schema + A2UI rules into your model's context.
final systemInstruction = promptBuilder.systemPromptJoined();

PromptBuilder serializes your widget catalog and the A2UI formatting rules into the system prompt. You decide how to deliver it — as a system role on the first message, as a preamble, or through your gateway’s own system-instruction parameter. If you skip this step, the model will not know which widgets it is allowed to emit, and surfaces will come back empty or malformed.

Testing without a live model

A side benefit of the thin contract is that GenUI becomes trivially testable. Because the framework only ever consumes text tokens, a mock adapter can replay a canned A2UI stream:

final transport = A2uiTransportAdapter(
  onSend: (message) async {
    transport.addChunk(cannedA2uiResponse);
  },
);

You can assert that a given A2UI payload renders the expected widget tree, drives the expected data-model updates, and handles the expected interaction events — all without a network call, an API key, or a flaky model. This is the practical payoff of the decoupling: the generative-UI pipeline becomes a deterministic function of its input tokens.

When to use the shipped adapters instead

A custom adapter is not always the right call. If your interactions are entirely client-side and you want Firebase to manage the Gemini API key securely, the Firebase AI Logic path is less code and better operational hygiene. If you already run an A2A-registered agent on a server, genui_a2a’s A2uiAgentConnector handles websocket lifecycle, AgentCard metadata, and stream parsing for you. Reach for a custom onSend when neither of those shapes fits — a proprietary gateway, a local model, or a provider with no wrapper.

Key takeaways

The GenUI SDK’s decoupling turned “which LLM does Flutter support?” into “which LLM do you support?” The A2uiTransportAdapter.onSend callback is the entire integration surface, the system prompt (built with PromptBuilder) is how you teach the model your widget vocabulary, and the token-only contract makes the whole pipeline mockable. Start with a shipped adapter, and write a custom one the moment your architecture — not the framework’s limitations — demands it.

Further reading