Skip to main content

Command Palette

Search for a command to run...

Building Agentic Flutter Apps with Gemini

Updated
8 min readView as Markdown
Building Agentic Flutter Apps with Gemini
G
Hello, I am a senior Flutter developer with vast experience in crafting mobile applications. I am a seasoned community organizer with vast experience in launching and building Google Developer communities under GDG Bugiri Uganda and Flutter Kampala.

AI has moved past static chatbots. The current wave of apps are agentic — they don't just answer a question in text, they reason about what the user is trying to do, decide what should happen next, and act on it. Instead of navigating to a pre-built screen, the user describes an outcome and the app figures out the UI and the actions required to get there.

This article walks through what that actually looks like in a Flutter app wired to Gemini — including the two things most walkthroughs on this topic skip: what happens when the model's output can't be trusted, and what you have to check before letting that output touch anything in the real world.

What makes an app "agentic," concretely

A traditional AI feature and an agentic one differ in what happens after the model responds.

In a traditional AI app, the user asks a question and the model answers in text. The conversation ends there — a human still has to read the answer, decide what it means, and go do something about it themselves.

In an agentic app, the model's output can also be a decision: change the UI to show a chart because the user asked for a trend, or emit an instruction to call a backend and fetch hotel prices. The user's request and the app's action are connected without a human manually bridging the gap in between. It's the difference between an assistant that tells you what to do and one that does the first step for you.

Why Flutter fits this pattern

Three properties make Flutter a reasonable platform for this, not just a popular one: widgets can be built from data at runtime instead of only from hand-written screens, which is what lets a model's JSON output become an actual UI; the same agent logic can run against mobile, web, and desktop from one codebase; and the ecosystem already has solid libraries for the pieces around the model call — HTTP clients, state management, local storage — so the agent-specific code stays small.

Example: an AI travel planner

Concretely: a user types "plan me a 3-day trip to Nairobi under $500." Gemini returns structured JSON describing an itinerary. Flutter renders that JSON as a scrollable list of day cards, each with an action button.

┌────────────────┐        ┌──────────────────┐        ┌────────────────────┐
│   Flutter App   │──────▶│  Gemini (reason)  │──────▶│  Backend / APIs     │
│  UI + actions   │◀──────│  + structure JSON │◀──────│  flights, hotels    │
└────────────────┘        └──────────────────┘        └────────────────────┘

Flutter owns rendering and triggering actions. Gemini owns turning a loosely-worded request into a structured plan. The backend owns the real-world data neither of the other two can know on its own. Keeping those three responsibilities separate is what makes each of them replaceable later — you can swap Gemini for a different model, or swap a REST backend for GraphQL, without touching the other two.

Calling Gemini from Flutter

dependencies:
  google_generative_ai: ^0.4.6
  flutter_dotenv: ^5.1.0

Model names in this space go stale fast — gemini-pro and gemini-1.5-pro are both retired as of writing. At the time of publishing, gemini-2.5-flash-lite is Google's current low-latency default; check the Gemini API model list before you ship, since these get replaced roughly every few months.

import 'package:google_generative_ai/google_generative_ai.dart';

final model = GenerativeModel(
  model: 'gemini-2.5-flash-lite',
  apiKey: dotenv.env['GEMINI_API_KEY']!,
);

Future<String?> askGemini(String prompt) async {
  try {
    final response = await model.generateContent([Content.text(prompt)]);
    return response.text;
  } catch (e) {
    // Network failure, quota limit, or safety-filter rejection all land here.
    debugPrint('Gemini call failed: $e');
    return null; // caller decides how to degrade — see the fallback UI below
  }
}

The try/catch here isn't decoration. Model calls fail for reasons that have nothing to do with your code — rate limits, transient network errors, or the model's own safety filters declining to answer — and a widget that assumes response.text always exists will crash on the first bad day the API has, not the tenth.

Turning a response into a widget, without trusting it blindly

If Gemini returns something like:

{
  "type": "itinerary",
  "days": [
    { "day": 1, "activity": "Visit Nairobi National Park" },
    { "day": 2, "activity": "Safari at Maasai Mara" }
  ]
}

the naive version parses it and renders directly. The problem is the same one every LLM-JSON pipeline runs into: the model occasionally wraps the JSON in a markdown code fence, drops a required field, or returns a type you didn't ask for. Validate the shape before you render it, and fall back to plain text when it doesn't hold up — the same rule that applies to any generative UI, not just agentic actions:

class ItineraryDay {
  ItineraryDay({required this.day, required this.activity});
  final int day;
  final String activity;
}

List<ItineraryDay>? parseItinerary(String rawResponse) {
  try {
    final cleaned = rawResponse.replaceAll('```json', '').replaceAll('```', '').trim();
    final json = jsonDecode(cleaned) as Map<String, dynamic>;
    if (json['type'] != 'itinerary' || json['days'] is! List) return null;

    final days = (json['days'] as List)
        .whereType<Map<String, dynamic>>()
        .where((d) => d['day'] is int && d['activity'] is String)
        .map((d) => ItineraryDay(day: d['day'], activity: d['activity']))
        .toList();

    return days.isEmpty ? null : days;
  } catch (_) {
    return null; // malformed JSON — caller falls back to text
  }
}

Widget buildItinerary(String rawResponse) {
  final days = parseItinerary(rawResponse);
  if (days == null) {
    return Text(rawResponse); // never crash the UI over a parsing failure
  }
  return ListView.builder(
    itemCount: days.length,
    itemBuilder: (context, i) => Card(
      child: ListTile(
        title: Text('Day ${days[i].day}'),
        subtitle: Text(days[i].activity),
      ),
    ),
  );
}

Adding agency: validate the action before you execute it

The higher-stakes version of the same JSON pattern is an action instruction rather than display data:

{ "action": "fetch_hotels", "location": "Nairobi", "budget": 200 }

It's tempting to switch on json['action'] and call the matching function directly. Don't — this is the one place in an agentic pipeline where a parsing mistake stops being a cosmetic bug and becomes a real one, because you're one step away from letting model output decide which API calls happen and with what parameters. Two checks make the difference between "flexible" and "unsafe": the action has to be one your app explicitly knows about, and its parameters have to pass the same validation you'd apply to user input, because that's functionally what they are.

enum AgentAction { fetchHotels, fetchFlights }

const _allowedActions = {'fetch_hotels': AgentAction.fetchHotels, 'fetch_flights': AgentAction.fetchFlights};
const _maxBudget = 5000;
final _allowedLocations = {'Nairobi', 'Kampala', 'Mombasa'}; // or validate against a real lookup

Future<void> handleAgentAction(Map<String, dynamic> json) async {
  final action = _allowedActions[json['action']];
  if (action == null) {
    debugPrint('Rejected unknown action: ${json['action']}');
    return; // unrecognized actions are dropped, never executed speculatively
  }

  final location = json['location'];
  final budget = json['budget'];
  if (location is! String || !_allowedLocations.contains(location)) return;
  if (budget is! int || budget <= 0 || budget > _maxBudget) return;

  switch (action) {
    case AgentAction.fetchHotels:
      await hotelRepository.search(location: location, maxPrice: budget);
    case AgentAction.fetchFlights:
      await flightRepository.search(destination: location);
  }
}

Notice what this buys you: the model can suggest any action name, any location string, any number it wants, and none of it reaches hotelRepository unless it matches an allowlist your code controls, not the model's output. If Gemini hallucinates an action called delete_account or a budget of -1, this function silently drops it instead of attempting it. That's the actual meaning of "don't let AI directly execute arbitrary actions" — a rule that only matters once you can see the validation it implies.

The other three things that bite you in production

Prompt engineering is what keeps you out of the validation code above as often as possible — a prompt that specifies the exact schema and gives one or two examples of correct output produces far fewer rejected actions than an open-ended one.

Latency means every Gemini call needs a loading state; a travel-planning request can take a few seconds, and a UI that just sits frozen reads as broken even when it isn't.

Cost compounds with usage in a way flat per-screen features don't — cache itinerary results for identical prompts, and consider a cheaper/faster model tier for lower-stakes calls (classification, simple lookups) while reserving a stronger model for the reasoning-heavy ones.

Where this is heading

The trajectory is toward apps that adapt their own structure to what a user is trying to do — a dashboard that rearranges itself around a stated goal, an agent that sits inside an existing workflow rather than being a separate chat screen bolted onto one. None of that changes the fundamentals above: whatever the model decides, your app still has to validate it before rendering it and validate it again before acting on it. The interesting engineering problem in agentic apps was never getting the model to decide things — it's building the layer that keeps a wrong decision from becoming a real one.


References

  1. Gemini API model list and lifecycle

  2. google_generative_ai package on pub.dev

  3. Gemini API prompt design strategies

  4. Firebase AI Logic for Flutter

  5. OWASP guidance on LLM output handling

150 views