Skip to main content

Get started with the GenUI SDK for Flutter

Learn how to use GenUI SDK for Flutter and add it to your existing Flutter app.

This guide explains how to get started with GenUI SDK for Flutter and its series of packages. The SDK's key components are described in the main components page.

Use the following instructions to add genui to your Flutter app. The code examples show how to perform the instructions on a brand new app created by running flutter create, but you can follow the same steps for your existing Flutter app.

Configure your agent provider

#

The genui package can connect to a variety of agent providers. Available providers include the following:

Google Gemini AI

The fastest way to get started! Use this package for experimentation and local testing as you're mapping out your experience.

Firebase AI Logic

Useful for production apps where interactions with the LLM are all in your Flutter client, without requiring a server. Firebase also makes it easier to ship your AI features securely since Firebase handles the management of your Gemini API key.

GenUI A2UI

Useful for client/server architectures where your agent is running on the server.

Build your own

You can also build your own adapter to connect to your preferred LLM provider. Expect more from us and the community soon.

The easiest way to start using GenUI is to use the google_generative_ai package directly, which only requires a GEMINI_API_KEY.

This package provides the integration with the Google Cloud Generative Language API. It allows you to use the power of Google's Gemini models to generate dynamic user interfaces in your Flutter applications.

This API is meant for quick explorations and local testing or prototyping, not for production or deployment. Flutter apps built for production should use Firebase AI. For mobile and web applications that need client-side access, consider using Firebase AI Logic instead.

  1. Add google_generative_ai and genui to your pubspec.yaml file:

    dart pub add genui google_generative_ai
    
  2. Create an instance of GenerativeModel and wrap it with your SurfaceController and A2uiTransportAdapter:

    dart
    import 'package:genui/genui.dart';
    import 'package:google_generative_ai/google_generative_ai.dart';
    
    final catalog = Catalog(components: [
      // ...
    ]);
    final catalogs = [catalog];
    
    final surfaceController = SurfaceController(catalogs: catalogs);
    final transportAdapter = A2uiTransportAdapter();
    transportAdapter.messageStream.listen(surfaceController.handleMessage);
    
    final promptBuilder = PromptBuilder.chat(
      catalog: catalog,
      instructions: 'You are a helpful assistant.',
    );
    
    final model = GenerativeModel(
      model: 'gemini-2.5-flash',
      apiKey: 'YOUR_API_KEY', // Or set GEMINI_API_KEY environment variable.
      systemInstruction: Content.system(promptBuilder.systemPrompt),
    );
    
    final conversation = Conversation(
      surfaceController: surfaceController,
      transportAdapter: transportAdapter,
    );
    
  3. To use this package, you need a Gemini API key. If you don't already have one, you can get it for free in Google AI Studio.

To connect to Gemini using the Firebase AI Logic SDK, follow these instructions:

  1. Create a new Firebase project using the Firebase Console.

  2. Enable the Gemini API for that project.

  3. Follow the first three steps in Firebase's Flutter setup guide to add Firebase to your app.

  4. Use dart pub add to add genui and firebase_ai_logic as dependencies in your pubspec.yaml file.

    dart pub add genui firebase_ai_logic
    
  5. In your app's main method, ensure that the widget bindings are initialized and then initialize Firebase.

    dart
    void main() async {
      WidgetsFlutterBinding.ensureInitialized();
      await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
      runApp(const MyApp());
    }
    

An integration package for genui and the A2UI Streaming UI Protocol. This package allows Flutter applications to connect to an Agent-to-Agent (A2UI) server and render dynamic user interfaces generated by an AI agent using the genui framework.

The main components in this package include:

  • A2uiAgentConnector: Handles the low-level web socket communication with the A2A server, including sending messages and parsing stream events.
  • AgentCard: A data class that holds metadata about the connected AI agent.

Follow these instructions:

  1. Set up dependencies: Use dart pub add to add genui, genui_a2a, and a2a as dependencies in your pubspec.yaml file.

    dart pub add genui genui_a2a a2a
    
  2. Initialize SurfaceController: Set up SurfaceController with your widget Catalogs.

  3. Create A2uiTransportAdapter: Instantiate A2uiTransportAdapter to parse the messages.

  4. Create A2uiAgentConnector: Instantiate A2uiAgentConnector, providing the A2A server URI.

  5. Create Conversation: Pass the adapter and controller to the Conversation.

  6. Render with Surface: Use Surface widgets in your UI to display the agent-generated content.

  7. Send Messages: Use connector.connectAndSend or Conversation.sendMessage to send user input to the agent-generated content.

    dart
    import 'package:flutter/material.dart';
    import 'package:genui/genui.dart';
    import 'package:genui_a2a/genui_a2a.dart';
    import 'package:logging/logging.dart';
    
    void main() {
      // Setup logging.
      Logger.root.level = Level.ALL;
      Logger.root.onRecord.listen((record) {
        print('${record.level.name}: ${record.time}: ${record.message}');
        if (record.error != null) {
          print(record.error);
        }
        if (record.stackTrace != null) {
          print(record.stackTrace);
        }
      });
    
      runApp(const GenUIExampleApp());
    }
    
    class GenUIExampleApp extends StatelessWidget {
      const GenUIExampleApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'A2UI Example',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          home: const ChatScreen(),
        );
      }
    }
    
    class ChatScreen extends StatefulWidget {
      const ChatScreen({super.key});
    
      @override
      State<ChatScreen> createState() => _ChatScreenState();
    }
    
    class _ChatScreenState extends State<ChatScreen> {
      final TextEditingController _textController = TextEditingController();
      final SurfaceController _surfaceController =
          SurfaceController(catalogs: [CoreCatalogItems.asCatalog()]);
      final A2uiTransportAdapter _transportAdapter = A2uiTransportAdapter();
      late final Conversation _uiAgent;
      late final A2uiAgentConnector _connector;
      final List<ChatMessage> _messages = [];
    
      @override
      void initState() {
        super.initState();
    
        // Connect Adapter -> Controller
        _transportAdapter.messageStream.listen(_surfaceController.handleMessage);
    
        _connector = A2uiAgentConnector(
          // TODO: Replace with your A2A server URL.
          url: Uri.parse('http://localhost:8080'),
        );
        _uiAgent = Conversation(
          surfaceController: _surfaceController,
          transportAdapter: _transportAdapter,
        );
    
        // Listen for messages from the remote agent.
        _connector.stream.listen(_surfaceController.handleMessage);
    
      }
    
      @override
      void dispose() {
        _textController.dispose();
        _uiAgent.dispose();
        _surfaceController.dispose();
        _connector.dispose();
        super.dispose();
      }
    
      void _handleSubmitted(String text) async {
        if (text.isEmpty) return;
        _textController.clear();
        final message = ChatMessage.user(TextPart(text));
        setState(() {
          _messages.insert(0, message);
        });
    
        final responseText = await _connector.connectAndSend(
            message,
            clientCapabilities: A2uiClientCapabilities(supportedProtocols: ['a2ui/0.9.0'])
        );
    
        // Handling response depends on your app's logic
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('A2UI Example'),
          ),
          body: Column(
            children: <Widget>[
              Expanded(
                child: ListView.builder(
                  padding: const EdgeInsets.all(8.0),
                  reverse: true,
                  itemBuilder: (_, int index) =>
                      _buildMessage(_messages[index]),
                  itemCount: _messages.length,
                ),
              ),
              const Divider(height: 1.0),
              Container(
                decoration: BoxDecoration(color: Theme.of(context).cardColor),
                child: _buildTextComposer(),
              ),
              // Surface for the main AI-generated UI:
              SizedBox(
                height: 300,
                child: Surface(
                  surfaceController: _surfaceController,
                  surfaceId: 'main_surface',
                ),
              ),
            ],
          ),
        );
      }
    
      Widget _buildMessage(ChatMessage message) {
        return Container(
          margin: const EdgeInsets.symmetric(vertical: 10.0),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Container(
                margin: const EdgeInsets.only(right: 16.0),
                child: CircleAvatar(child: Text(message.role == Role.user ? 'U' : 'A')),
              ),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(message.role == Role.user ? 'User' : 'Agent',
                        style: const TextStyle(fontWeight: FontWeight.bold)),
                    Container(
                      margin: const EdgeInsets.only(top: 5.0),
                      child: Text(message.parts.whereType<TextPart>().map((e) => e.text).join('\n')),
                    ),
                  ],
                ),
              ),
            ],
          ),
        );
      }
    
      Widget _buildTextComposer() {
        return IconTheme(
          data: IconThemeData(color: Theme.of(context).colorScheme.secondary),
          child: Container(
            margin: const EdgeInsets.symmetric(horizontal: 8.0),
            child: Row(
              children: <Widget>[
                Flexible(
                  child: TextField(
                    controller: _textController,
                    onSubmitted: _handleSubmitted,
                    decoration:
                        const InputDecoration.collapsed(hintText: 'Send a message'),
                  ),
                ),
                Container(
                  margin: const EdgeInsets.symmetric(horizontal: 4.0),
                  child: IconButton(
                    icon: const Icon(Icons.send),
                    onPressed: () => _handleSubmitted(_textController.text),
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    

The example directory on pub.dev contains a complete application demonstrating how to use this package.

To use genui with another agent provider, follow that provider's SDK documentation to implement a connection, and stream its results into an A2uiTransportAdapter.

Create the connection to an agent

#

If you build your Flutter project for iOS or macOS, add this key to your {ios,macos}/Runner/*.entitlements file(s) to enable outbound network requests:

xml
<dict>
...
<key>com.apple.security.network.client</key>
<true/>
</dict>

Next, use the following instructions to connect your app to your chosen agent provider.

  1. Create a SurfaceController, and provide it with the catalogs of widgets that you want to make available to the agent. Create an A2uiTransportAdapter to parse messages and connect it.

  2. Create a PromptBuilder, and provide it with a system instruction and the tools (functions you want the agent to be able to invoke). You should always include the tools provided by SurfaceController, but feel free to include others. Add this to your LLM system prompt.

  3. Create a Conversation using the instances of SurfaceController and A2uiTransportAdapter. Your app will primarily interact with this object to get things done.

    For example:

    dart
    class _MyHomePageState extends State<MyHomePage> {
      late final SurfaceController _surfaceController;
      late final A2uiTransportAdapter _transportAdapter;
      late final Conversation _conversation;
    
      @override
      void initState() {
        super.initState();
    
        // Create a SurfaceController with a widget catalog.
        // The CoreCatalogItems contain basic widgets for text, markdown, and images.
        _surfaceController = SurfaceController(catalogs: [CoreCatalogItems.asCatalog()]);
    
        _transportAdapter = A2uiTransportAdapter();
        _transportAdapter.messageStream.listen(_surfaceController.handleMessage);
    
        final catalog = CoreCatalogItems.asCatalog();
        final promptBuilder = PromptBuilder.chat(
          catalog: catalog,
          instructions: '''
            You are an expert in creating funny riddles. Every time I give you a word,
            you should generate UI that displays one new riddle related to that word.
            Each riddle should have both a question and an answer.
            ''',
        );
    
        // ... initialize your LLM Client of choice using promptBuilder.systemPrompt
    
        // Create the Conversation to orchestrate everything.
        _conversation = Conversation(
          surfaceController: _surfaceController,
          transportAdapter: _transportAdapter,
          onSurfaceAdded: _onSurfaceAdded, // Added in the next step.
          onSurfaceDeleted: _onSurfaceDeleted, // Added in the next step.
        );
      }
    
      @override
      void dispose() {
        _textController.dispose();
        _conversation.dispose();
    
        super.dispose();
      }
    }
    

Send messages and display the agent's responses

#

Send a message to the agent using the sendMessage method in the Conversation class, or by directly streaming into your LLM Client and pumping the result stream to the adapter via _transportAdapter.addChunk.

To receive and display generated UI:

  1. Use the callbacks in Conversation to track the addition and removal of UI surfaces as they are generated. These events include a surface ID for each surface.

  2. Build a Surface widget for each active surface using the surface IDs received in the previous step.

    For example:

    dart
    class _MyHomePageState extends State<MyHomePage> {
      // ...
    
      final _textController = TextEditingController();
      final _surfaceIds = <String>[];
    
      // Send a message containing the user's [text] to the agent.
      void _sendMessage(String text) {
        if (text.trim().isEmpty) return;
        // _conversation.sendMessage(text);
      }
    
      // A callback invoked by the [Conversation] when a new
      // UI surface is generated. Here, the ID is stored so the
      // build method can create a Surface to display it.
      void _onSurfaceAdded(SurfaceAdded update) {
        setState(() {
          _surfaceIds.add(update.surfaceId);
        });
      }
    
      // A callback invoked by Conversation when a UI surface is removed.
      void _onSurfaceDeleted(SurfaceRemoved update) {
        setState(() {
          _surfaceIds.remove(update.surfaceId);
        });
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            backgroundColor: Theme.of(context).colorScheme.inversePrimary,
            title: Text(widget.title),
          ),
          body: Column(
            children: [
              Expanded(
                child: ListView.builder(
                  itemCount: _surfaceIds.length,
                  itemBuilder: (context, index) {
                    // For each surface, create a Surface to display it.
                    final id = _surfaceIds[index];
                    return Surface(surfaceController: _conversation.surfaceController, surfaceId: id);
                  },
                ),
              ),
              SafeArea(
                child: Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 16.0),
                  child: Row(
                    children: [
                      Expanded(
                        child: TextField(
                          controller: _textController,
                          decoration: const InputDecoration(
                            hintText: 'Enter a message',
                          ),
                        ),
                      ),
                      const SizedBox(width: 16),
                      ElevatedButton(
                        onPressed: () {
                          // Send the user's text to the agent.
                          _sendMessage(_textController.text);
                          _textController.clear();
                        },
                        child: const Text('Send'),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        );
      }
    }
    

Add your own widgets to the catalog

#

For your convenience, you can use the provided core catalog of widgets. However, most production apps will want to define a custom catalog of widgets.

To add your own widgets, use the following instructions.

  1. Depend on the json_schema_builder package

    Use dart pub add to add json_schema_builder as a dependency in your pubspec.yaml file:

    dart pub add json_schema_builder
    
  2. Create the new widget's schema

    Each catalog item needs a schema that defines the data required to populate it. Using the json_schema_builder package, define one for the new widget.

    dart
    import 'package:json_schema_builder/json_schema_builder.dart';
    import 'package:flutter/material.dart';
    import 'package:genui/genui.dart';
    
    final _schema = S.object(
      properties: {
        'question': S.string(description: 'The question part of a riddle.'),
        'answer': S.string(description: 'The answer part of a riddle.'),
      },
      required: ['question', 'answer'],
    );
    
  3. Create a CatalogItem

    Each CatalogItem represents a type of widget that the agent is allowed to generate. To do that, it combines a name, a schema, and a builder function that produces the widgets that compose the generated UI.

    dart
    final riddleCard = CatalogItem(
      name: 'RiddleCard',
      dataSchema: _schema,
      widgetBuilder:
          ({
            required data,
            required id,
            required buildChild,
            required dispatchEvent,
            required context,
            required dataContext,
          }) {
            final json = data as Map<String, Object?>;
            final question = json['question'] as String;
            final answer = json['answer'] as String;
    
            return Container(
              constraints: const BoxConstraints(maxWidth: 400),
              decoration: BoxDecoration(border: Border.all()),
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(question, style: Theme.of(context).textTheme.headlineMedium),
                  const SizedBox(height: 8.0),
                  Text(answer, style: Theme.of(context).textTheme.headlineSmall),
                ],
              ),
            );
          },
    );
    
  4. Add the CatalogItem to the catalog

    Include your catalog items when instantiating SurfaceController.

    dart
    _surfaceController = SurfaceController(
      catalogs: [CoreCatalogItems.asCatalog().copyWith([riddleCard])],
    );
    
  5. Update the system instruction to use the new widget

    To make sure that the agent knows to use your new widget, tell the system instruction how and when to do so. Provide the name from the CatalogItem when you do.

    dart
    final promptBuilder = PromptBuilder.chat(
      catalog: catalog,
      instructions: '''
          You are an expert in creating funny riddles. Every time I give you a word,
          generate a RiddleCard that displays one new riddle related to that word.
          Each riddle should have both a question and an answer.
          ''',
    );
    
    // Pass promptBuilder.systemPrompt to your LLM Config
    

Data model and data binding

#

A core concept in genui is the DataModel, a centralized, observable store for all dynamic UI state. Instead of each widget managing its own state, its state is stored in the DataModel.

Widgets are bound to data in this model. When data in the model changes, only the widgets that depend on that specific piece of data are rebuilt. This is achieved through a DataContext object passed to each widget's builder function.

Binding to the data model

#

To bind a widget's property to the data model, specify a special JSON object in the data sent from the AI. This object can contain standard JSON primitives (for static values) or an object with a path property (to bind to a value in the data model).

For example, to display a user's name in a Text widget, the AI would generate:

json
{
  "component": "Text",
  "text": "Welcome to GenUI",
  "variant": "h1"
}

Image

#
json
{
  "component": "Image",
  "url": "https://example.com/image.png",
  "variant": "mediumFeature"
}

Updating the data model

#

Input widgets, like TextField, update the DataModel directly. When the user types in a text field that is bound to /user/name, the DataModel updates, and any other widgets bound to that same path will automatically rebuild to show the new value.

This reactive data flow simplifies state management and creates a powerful, high-bandwidth interaction loop between the user, the UI, and the AI.

Next steps

#

Check out the examples included in the genui repo. The travel app shows how to define your own widget catalog that the agent can use to generate domain-specific UI.

If something is unclear or missing, please create an issue.

System instructions

#

The genui package gives the LLM a set of tools it can use to generate UI. To get the LLM to use these tools, the system instructions provided via PromptBuilder must explicitly tell it to do so.

This is why the earlier example includes a system instruction for the agent with the line "Every time I give you a word, you should generate UI that...":

dart
final promptBuilder = PromptBuilder.chat(
  catalog: catalog,
  instructions: '''
    You are an expert in creating funny riddles.
    Every time I give you a word, you should generate UI that
    displays one new riddle related to that word.
    Each riddle should have both a question and an answer.
    ''',
);

Troubleshooting/FAQ

#

How can I configure logging?

#

To observe communication between your app and the agent, enable logging in your main method.

dart
import 'package:logging/logging.dart';
import 'package:genui/genui.dart';

final logger = configureGenUiLogging(level: Level.ALL);

void main() async {
  logger.onRecord.listen((record) {
    debugPrint('${record.loggerName}: ${record.message}');
  });

  // Additional initialization of bindings and Firebase.
}

I'm getting errors about my minimum macOS/iOS version.

#

Firebase has a minimum version requirement for Apple's platforms, which might be higher than Flutter's default. Check your Podfile (for iOS) and CMakeLists.txt (for macOS) to ensure that you're targeting a version that meets or exceeds Firebase's requirements.