Let's look at how to move past rigid text-only chatbots by using the GenUI SDK for Flutter to render dynamic, interactive widgets straight from LLM responses.
What you'll learn:
- How the GenUI SDK orchestrates LLM prompts and widget catalogs.
- Setting up a basic Flutter client with the
genuipackage. - Deserializing server-side A2UI protocol payloads into real widgets.
- Handling user interactions that trigger follow-up agent requests.
Setting Up the GenUI SDK in Your Flutter Project
Traditional apps are static because every screen is hardcoded into the binary. When building agentic workflows, you want the backend or the LLM to decide which UI components best answer a user's intent—whether that's a date picker, a card list, or a custom slider.
To get started, add the core package to your pubspec.yaml and initialize your content generator adapter. For local prototyping, the Gemini API adapter gives you rapid feedback loops.
import 'package:flutter/material.dart';
import 'package:genui/genui.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize the GenUI runtime with your content generator
final generativeClient = GenUIClient(
apiKey: String.fromEnvironment('GEMINI_API_KEY'),
catalog: DefaultWidgetCatalog.instance,
);
runApp(GenUIRootApp(client: generativeClient));
}
class GenUIRootApp extends StatelessWidget {
final GenUIClient client;
const GenUIRootApp({super.key, required this.client});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: GenUIScreen(client: client),
);
}
}
Speed Note: When shipping production apps, prefer the Firebase AI logic adapter over direct client-side API keys to securely manage your credentials without maintaining a separate proxy server.
Defining Your Custom Widget Catalog
The core safety feature of generative UI is constraint. You don't want an LLM inventing random layout code that breaks your app; you want it to pick from a curated list of brand-compliant components you've already built and tested.
You register these components in a WidgetCatalog so the orchestration layer knows how to map incoming protocol schemas to actual Flutter code.
import 'package:flutter/material.dart';
import 'package:genui/genui.dart';
final customCatalog = WidgetCatalog(
components: {
'trip_summary_card': ComponentDefinition(
builder: (context, data) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(data['destination'] ?? 'Unknown Destination',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text('Duration: ${data['duration']} days'),
],
),
),
);
},
),
},
);
Gotcha: Always validate incoming JSON properties against null values inside your builders. LLMs can occasionally omit optional fields, which will throw runtime exceptions if you use raw index access.
Orchestrating the Prompt-to-UI Loop
When a user submits a prompt like "Help me plan a trip to Tokyo," your app sends that text alongside your registered widget catalog definition to the LLM.
The model responds with a dual payload: conversational text for context, and structured metadata describing which UI components to render and populate.
import 'package:flutter/material.dart';
import 'package:genui/genui.dart';
class GenUIScreen extends StatefulWidget {
final GenUIClient client;
const GenUIScreen({super.key, required this.client});
@override
State<GenUIScreen> createState() =>._GenUIScreenState();
}
class _GenUIScreenState extends State<GenUIScreen> {
final TextEditingController _controller = TextEditingController();
List<Widget> _dynamicUiStream = [];
void _handleSubmit() async {
final prompt = _controller.text;
if (prompt.isEmpty) return;
// Request stream containing both data and UI component configurations
final responseStream = widget.client.sendPrompt(prompt);
responseStream.listen((uiComponent) {
setState(() {
_dynamicUiStream.add(uiComponent);
});
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Generative UI Planner')),
body: Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: _dynamicUiStream,
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(child: TextField(controller: _controller)),
IconButton(
icon: const Icon(Icons.send),
onPressed: _handleSubmit,
),
],
),
),
],
),
);
}
}
Handling User Actions Inside Generated Layouts
Generative UIs shouldn't just be read-only billboards. When a user interacts with a dynamically generated button, slider, or form field, that interaction should flow right back into the agent context to fetch the next set of UI blocks.
import 'package:flutter/material.dart';
class InteractiveBudgetWidget extends StatelessWidget {
final Map<String, dynamic> data;
final Function(String actionId, Map<String, dynamic> payload) onActionTriggered;
const InteractiveBudgetWidget({
super.key,
required this.data,
required this.onActionTriggered,
});
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
// Send user selection back to the agent loop
onActionTriggered('select_budget_tier', {
'tier': data['recommendedTier'],
});
},
child: Text('Confirm Budget: \$${data['amount']}'),
);
}
}
Accessibility Note: Ensure any dynamic layout components returned by your catalog pass basic screen reader semantics. Pass semantic labels down through your component data definitions so generated accessibility trees don't collapse.
Summary
Building with the GenUI SDK lets you shift from maintaining rigid, static screen definitions to orchestrating fluid, context-aware user interfaces driven by LLMs. By combining structured widget catalogs with the A2UI protocol, you keep full control over your brand guidelines while unlocking deeply personalized application flows.
Next Steps:
- Pull down the sample apps from the official
flutter/genuirepository to inspect a working client-server architecture. - Define your first custom component catalog tailored to your app's core design system.
- Experiment with the Firebase AI logic adapter for secure, client-side production deployments.