GenUI with Firebase AI Logic: Wiring Flutter's Generative UI to a Managed Model Backend
Run Flutter GenUI against Firebase AI Logic instead of standing up your own agent server. Setup, the object graph from model to Conversation, a streaming transport wiring, and when a custom TransportAdapter still wins.
Published on • October 17, 2026
AI Assistant

GenUI talks to a model through a Transport. That is the whole contract, and it is also the decision that most teams get stuck on: if you want Gemini behind your generative UI, do you stand up an agent server just to hold the API key, or can the model be called straight from the Flutter client?
Firebase AI Logic is the answer the Flutter docs themselves point at. The GenUI getting-started guide lists three provider options - Firebase AI Logic, GenUI A2UI for a server-side agent, and build your own adapter - and describes Firebase AI Logic as the option “useful for production apps where interactions with the LLM are all in your Flutter client, without requiring a server.”
This post is that path end to end: project setup, the exact object graph that connects a Firebase model to a Conversation, how the event loop closes when the user taps a generated widget, and an honest comparison against writing your own TransportAdapter.
What you are actually assembling
A GenUI app has two halves that are easy to conflate:
- The rendering half is pure Flutter: a
Catalogof widget vocabularies, aSurfaceControllerthat applies A2UI messages, andSurfacewidgets that rebuild when the data model changes. It knows nothing about where text comes from. - The generation half is whatever implements
A2uiTransportAdapter.onSend: call a model, stream the response back in chunks, and let the parser turn those chunks into structured messages.
Firebase AI Logic drops into the second half only. You add firebase_ai, create a GenerativeModel, and pipe generateContentStream into transportAdapter.addChunk(...). The first half does not change at all, which is why this integration is roughly twenty lines of real code.
The package metadata is worth a glance before you commit: genui is published by labs.flutter.dev, is at version 0.10.4 as of this writing, and the Flutter docs label it alpha and likely to change. Treat the public API as provisional, and keep the transport layer thin so a version bump costs you one file rather than your whole UI layer.
Setup
1. Firebase project
Create a project in the Firebase console, open AI Services > AI Logic, and run the guided Get started workflow. It enables the required APIs and asks you to pick a Gemini API provider: the Gemini Developer API starts at no cost, while the Agent Platform Gemini API (formerly Vertex AI) requires billing on the Google Cloud project. Both work with GenUI; the difference is quota and billing, not code shape.
One detail that has teeth: since early July 2026 the console workflow automatically enforces Firebase App Check for AI Logic, and for local development you need the App Check debug provider registered or your requests will be rejected.
2. Dependencies
$ flutter pub add firebase_core firebase_ai firebase_app_check
$ dart pub add genui
$ flutterfire configure
The Flutter side of Firebase AI Logic requires Dart 3.2.0 or higher. flutterfire configure generates firebase_options.dart and, on Android, adds the Performance Monitoring and other required Gradle wiring for whatever Firebase products you selected.
If you build for iOS or macOS, add the outbound network entitlement to your {ios,macos}/Runner/*.entitlements files, otherwise the model calls fail silently behind sandboxing:
<key>com.apple.security.network.client</key>
<true/>
3. Initialise before the first frame
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_app_check/firebase_app_check.dart';
import 'firebase_options.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Debug builds: use the debug provider and register the token in the console.
await FirebaseAppCheck.instance.activate(
androidProvider: AndroidProvider.debug,
appleProvider: AppleProvider.debug,
);
runApp(const GenUiApp());
}
Ship a production attestation provider (Play Integrity on Android, App Attest or DeviceCheck on iOS) before release; a debug token in a production binary defeats the purpose of App Check.
The wiring: model, transport, conversation
This is the core of the integration, and it is the same shape the official guide uses. Build the catalog, derive the system instruction from it, create the Firebase model, then wrap everything in a Conversation:
import 'package:genui/genui.dart';
import 'package:firebase_ai/firebase_ai.dart';
final catalog = CoreCatalogItems.asCatalog().copyWith([tripCard]);
final surfaceController = SurfaceController(catalogs: [catalog]);
// The prompt builder keeps the widget vocabulary and the system prompt in sync.
final promptBuilder = PromptBuilder.chat(
catalog: catalog,
systemPromptFragments: const [
'You are a travel planner. Prefer TripCard for itineraries.',
],
);
final model = FirebaseAI.googleAI().generativeModel(
model: 'gemini-3.8-flash',
systemInstruction: Content.system(promptBuilder.systemPromptJoined()),
);
late final A2uiTransportAdapter transportAdapter;
transportAdapter = A2uiTransportAdapter(onSend: (message) async {
final contents = buildContents(message); // your history policy
await for (final chunk in model.generateContentStream(contents)) {
final text = chunk.text;
if (text != null && text.isNotEmpty) {
transportAdapter.addChunk(text);
}
}
});
final conversation = Conversation(
controller: surfaceController,
transport: transportAdapter,
);
A few notes on what each line is doing:
FirebaseAI.googleAI()selects the Gemini Developer API backend. UseFirebaseAI.vertexAI()(the Agent Platform Gemini API) if your project is billed through Google Cloud instead.systemPromptJoined()folds the catalog description and your fragments into one system instruction. This is the hook that tells the model which custom widgets exist; without it, aTripCardyou registered will never be used.onSendis the only place Firebase appears.buildContentsis deliberately yours: you can send a single turn, replay a chat history fromChatHistory/Contentobjects, or attach images. The transport only cares that chunks come back throughaddChunk.- The
Conversationwires transport to controller internally. You do not connect them by hand.
Streaming is the default, not an optimisation
Generative UI depends on incremental parsing. A2uiTransportAdapter wraps an A2uiParserTransformer that consumes raw text chunks and emits TextEvents or A2uiMessageEvents as soon as a complete message arrives, so the first components of a surface can render while the model is still writing. Firing generateContent and awaiting the whole response works, but you lose that first-paint advantage and the app feels like a chat app that happens to render widgets.
Closing the loop
Send a request from a text field, track surfaces as they appear, and dispose in the right order:
class _ChatState extends State<Chat> {
final _controller = TextEditingController();
final _surfaceIds = <String>[];
late final SurfaceController _surfaceController;
late final A2uiTransportAdapter _transport;
late final Conversation _conversation;
@override
void initState() {
super.initState();
_surfaceController = SurfaceController(catalogs: [catalog]);
_transport = A2uiTransportAdapter(onSend: onSendToFirebase);
_conversation = Conversation(
controller: _surfaceController,
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));
}
});
}
void _send(String text) {
if (text.trim().isEmpty) return;
_conversation.sendRequest(ChatMessage.user(text));
_controller.clear();
}
@override
Widget build(BuildContext context) {
return ListView(
children: [
for (final id in _surfaceIds)
Surface(host: _conversation.host, surfaceId: id),
],
);
}
@override
void dispose() {
_controller.dispose();
_conversation.dispose();
_transport.dispose();
_surfaceController.dispose();
super.dispose();
}
}
User interaction closes the loop on its own. When a generated widget fires an event, the SurfaceController captures it, updates the DataModel, and emits a ChatMessage on onSubmit; because Conversation listens to onSubmit, it immediately triggers another onSend. That is why widgets bound to the same data path rebuild together without you writing a single setState.
Firebase AI Logic versus your own backend
The GenUI docs position Firebase as “useful for production apps where interactions with the LLM are all in your Flutter client, without requiring a server.” In practice the choice comes down to where your prompt data lives:
| Concern | Firebase AI Logic | GenUI A2UI (genui_a2a) | Custom TransportAdapter |
|---|---|---|---|
| Where the model call runs | Flutter client | Your agent server | Your server, or any provider |
| Gemini API key | Held by Firebase, never shipped in the app | Server side | Yours to protect |
| Abuse protection | Firebase App Check, enforced during console setup | Your auth and rate limits | Yours |
| Private data or retrieval in the prompt | Not without exposing it to the client | Yes | Yes |
| Server-side prompt templates | Yes (Firebase feature) | Yes | Yes |
| Extra infrastructure | None | A WebSocket agent server | Depends on the provider |
| Best for | Single-tenant assistants, client-only features, prototypes that need to ship | Multi-step agents, RAG, per-user policy | Non-Gemini providers or an existing agent stack |
The deciding question is: does the prompt need data the user should not be able to extract from the binary? If it does, Firebase AI Logic is a transport convenience, not an architecture, and you need a server. If the prompt is essentially your widget catalog plus user input, running it client-side removes an entire deployment from your critical path.
There is a middle road. Firebase AI Logic supports server prompt templates and Remote Config, so you can change the system instruction and even the model name without shipping an app update, while still calling from the client. That covers the most common reason teams reach for a proxy in the first place.
Gotchas worth knowing before you ship
- Alpha status.
genuiis explicit about being experimental. Pin the version, read the changelog on upgrade, and keeponSendas the single seam to the model. - Model names move. The docs’ examples have used
gemini-3.5-flashandgemini-3.8-flashat different times. Prefer a model string you can change at runtime (Remote Config) over a hardcoded literal in three files. - App Check debug token. With enforcement on, a fresh clone of the repo will fail all AI calls until the debug token is registered. Log it once, register it, and document the step in your README.
- Minimum OS versions. Firebase raises its minimum iOS and macOS versions above Flutter’s defaults; check your
PodfileandCMakeLists.txtif you see version errors rather than network errors. - Logging.
configureLogging(level: Level.ALL)from thegenuipackage prints the conversation between your app and the model. It is the fastest way to see whether a missing surface is a prompt problem or a parsing problem. - Caching.
GenUIcaches generated surfaces, so identical prompts can return without a model call. Useful in tests, surprising in demos.
Checklist
- Firebase project created, Gemini API enabled, and the AI Logic setup workflow completed.
firebase_core,firebase_ai,firebase_app_check, andgenuiadded;flutterfire configurerun.- App Check activated with a debug provider for development and a production attestation provider before release.
- iOS and macOS network client entitlement added to the runner entitlements.
- Catalog,
PromptBuilder, and system instruction built from the same source so custom widgets are discoverable. onSendstreamsgenerateContentStreamchunks intotransportAdapter.addChunkrather than awaiting a single response.Conversation, transport, and controller disposed in that order indispose().- Decision recorded: client-side Firebase for prompts that carry no private data, otherwise a server behind a custom
TransportAdapter.
Sources: Get started with the GenUI SDK for Flutter, genui on pub.dev, Get started with the Gemini API using the Firebase AI Logic SDKs, What’s new in Flutter 3.44