ai

July 22, 2025 ai

The 5 Stages of AI Grief

The 5 Stages of AI Grief

The future is already here — it’s just not very evenly distributed.” –W. Gibson

As a consultant and speaker, I talk to a lot of software engineers. As AI coding tools have gotten better (and they have gotten much better), I’ve watched engineers move through what feels a lot like the stages of grief.

1. Denial: AI is a fad. It’ll go away soon.”

denial

I still run into a large number of engineers who are convinced that AI coding is just hype that’ll blow over like so many other tech fads (Web 3, blockchain, NFTs, … they have a point). They refuse to even try ChatGPT, Copilot or Claude Code. They’ve been writing code the same way their whole lives and why should they change now?

2. Anger: AI doesn’t work. I tried it two years ago and it sucked.”

anger

Some engineers have tried AI coding tools, but they tried them when they were genuinely pretty bad. GPT-3.5 in early 2023 was… let’s call it inconsistent.” So they tried it, it failed to solve their problem, and they walked away convinced that AI coding was overhyped nonsense.

Or, these are the engineers who have strict company policies about which tools and which LLMs that they can and can’t use, often in-house, self-made stuff that leaves something to be desired. To be fair, these home-grown tools often do suck.

The problem is that these engineers haven’t (or aren’t allowed to) try the current crop of tools. They don’t realize that the AI that couldn’t write a for-loop eighteen months ago can now architect entire applications, if used correctly. But until they get over that hump, they’re not going to see the value in them.

3. Depression: Vibe coding: Build me a healthcare app”

depression

This is the stage where engineers realize AI actually works, but they’re holding it wrong. They’re trying to go from zero to sixty in 3.5 seconds with prompts like Build me a healthcare app” or Fix this bug in my code.”

Vibe coding works great when you’re building something small and self-contained. But then you ship that healthcare app and get hacked with an SQL-injection attack because you’re not an engineer at all and you don’t know what SQL injection is. That’s when the depression sets in.

The tool isn’t the problem. The problem is treating AI like a magic wand instead of a chainsaw. The chainsaw will get the job done, but you’ve got to know what you’re doing or you’re just going to hurt yourself.

4. Bargaining: Describe coding: Let’s design a new feature”

bargaining

I don’t do vibe coding.” OK. Well. I have experimented” with it. But I was younger. And I needed the money. And it was tastefully done!

Now, I’ve grown a bit and realize that there are better ways. I’ve adopted a technique I call describe coding” (patent pending. all rights reserved. I do own the domain name : ). Instead of leaping right into build me a thing that does a thing”, I start with let’s think about this for a minute.” I like AI coding tools that have an explicit planning mode, but even without that mode I use prompts like I’d like to design a … It should work like … It should have these constraints… ask any questions you may have before you start coding.” Actually, that last sentence I use so often, I’ve stored it in a snippet behind a hotkey on my Mac.

I collaborate on the AI until we come up with a design I like. Then the implementation. Then fixing the compiler errors (and the warnings, I’m not an animal!).

Then I dump each substantial feature into a design doc for future use. It will be useful in the future to get myself and the AI on the same page when it’s time for updates/bug fixes.

Then I generate tests and run them till they pass, being careful to really check that the tests model the behavior I want. Then update the docs and produce the PR.

Notice what I’m doing: design => implementation => testing => …

This is the standard practice that we’ve been doing for decades. You could call the use of my traditional software engineering techniques bargaining” but I like to think of it as applying the techniques that really work, but in a new way.

The combo of AI + the application of software engineering is where the magic happens, letting me code in minutes or hours what used to take days or weeks. The AI becomes a super-fast pair programming partner instead of a glorified autocomplete.

Plus, it’s so much frickin’ fun, I can’t stand it! If you only get to this stage, you’ll do well with AI coding.

And there’s still one more stage to go!

5. Acceptance: Orchestrating agent swarms

acceptance

The engineers who’ve made it to acceptance aren’t just using AI to write code. They’re orchestrating whole sets of them. Personally, I like to keep about three projects going at once, switching between them, using different instances of my favorite AI coding agent of the week (things are changing really fast). Even keeping to just a few projects, I still find myself with the oops wrong chat” problem, so that may be my limit.

Some people are pushing the limits, however, by combining AI agents at the command line with tmux and a little known feature of git called worktrees,” which gives each agent instance its own playground.

Once you’re swarming, you’re not a software engineer anymore — now you’re an engineering manager with a team. Swarm mode is where your points really rack up.

1. Denial… Again: Remote coding”

denial again!

Remote coding is giving AI agents in the cloud access to your repo and asking them to do things; they can create entire PRs without you ever seeing the code or the running app.

There is a growing set of AI coding products that provide for the close your eyes and think of England” kind of remote asynchronous coding and it’s not OK with me!

And so the cycle begins again with denial.

The Questions We’re All Asking

When I discuss the advancement of AI into our day to day work with folks, some questions keep coming up:

Will using AI atrophy my knowledge? Some skills will almost certainly atrophy - that’s what happens to muscles that aren’t used. But we lost our tails because we never used them either. If something dries up and falls off due to misuse, how important was it in the first place?

Will AI take my job? I honestly don’t know. But I do know this: software engineers who use AI tooling are going to be more effective and efficient than those who don’t. And when promotion time comes around, or when layoffs happen, effectiveness and efficiency are good qualities to have.

Will AI produce more, different jobs? Every single technology we’ve ever invented has put someone out of work. When Grog the plump caveman lost his cuddling-to-keep-the-tribe-warm job after fire was invented, his extra berries at bedtime were gone.

I don’t know what happened to Grog, but I can tell you that the invention of fire led to pretty much every other job. Should we avoid a new technology because it points to an uncertain future?

Every new technology seems different - this one only destroys jobs and doesn’t create them - because the lost jobs happen first and we can’t see into the future. The Luddites broke into fabric mills and destroyed steam-powered looms because they didn’t want to lose their jobs. Of course, we ended up creating many more jobs with the machines that looms ultimately led to than the looms ever created on their own.

Is AI going to be different? Is it only going to destroy jobs and not create them? I don’t know. But if that were the case, it would be unique in human history.

Where Are We?

All technology advances happen faster now. The spread of electricity took ~45 years to reach 25% of the US population. The internet took ~7 years. AI has been making changes in our society as large as those other two in just 2.5 years. What that means for us puny humans, I don’t know.

But here’s what I do know: the grief you’re experiencing with the oncoming AI is real. We are losing things. We’re already losing knowledge and skills. Artists and other content creators seem to have lost their IP. The new people entering every field that uses AI are going to do things differently; the old ways will be lost.

The question isn’t whether we’re going to lose things with AI. The question is: is what we gain going to be worth it? Will it be a higher standard of living for our children? Will AI help us solve real-world problems that we’ve been unable to solve ourselves? Once we get over the grief, will we be able to experience joy in the new world that we’ve created for ourselves?

I hope so.

The engineers who are curious, who are experimenting, who are moving through these stages thoughtfully - I believe that they’ll do just fine in whatever future we create for ourselves. The ones who get permanently stuck in anger or denial? I think they’ll have a harder time.

The future is already here. What are you going to do about it?

July 20, 2025 flutter ai

Welcome to dartantic_ai 1.0!

Welcome to dartantic_ai 1.0!

Dartantic is an agentic framework designed to make building client and server-side apps in Dart with generative AI easier and more fun!

It works across providers (Google, OpenAI, Anthropic, etc) and runs anywhere your Dart code runs (Flutter desktop, Flutter mobile, Flutter web, CLI, server).

It allows you to write code like this:

// Tools that work together
final tools = [
  Tool(
    name: 'get_current_time',
    description: 'Get the current date and time',
    onCall: (_) async => {'result': DateTime.now().toIso8601String()},
  ),
  Tool(
    name: 'find_events',
    description: 'Find events for a date',
    inputSchema: JsonSchema.object({
      'date': JsonSchema.string(),
    }),
    onCall: (args) async => ..., // find events
  ),
];

// Agent chains tools automatically, no matter what provider you're using,
// e.g. openai, google, openrouter or your custom provider. And if you want to
// specify the model, you can, e.g. "openai:gpt-4o", "google:gemini-2.5-flash" or
// "your-provider:your-model".
final agent = Agent('openai', tools: tools);
final result = await agent.send('What events do I have today?');

// Agent will:
// 1. Call get_current_time to figure out what "today" means
// 2. Extract date from response
// 3. Call find_events with that date
// 4. Return final answer with events

I had all of that working with Gemini and OpenAI LLMs three weeks ago. I just needed to add support for a few more providers and I’d be ready for a 1.0. So I did what anyone would do: I spent three weeks rebuilding dartantic from first principles.

Building on langchain_dart

It was three weeks ago when I first really dove into the most excellent langchain_dart repo from David Miguel Lozano. And when I did, I discovered that he was way ahead of me with features AND providers. There was a lot of Langchain stuff in there of course — David had been very thorough — but it also had a lovely compatibility layer over the set of LLM provider-specific Dart SDK packages (which David also built and maintained). So, on the day after I launched dartantic 0.9.7 at FlutterCon in New York, I sat down with Claude Code and carved my way into David’s Langchain implementation, chipping away until I had extracted that compat-layer.

And on top of that, I built dartantic_ai 1.0.

As you can see from the most epic CHANGELOG ever, I learned a ton from David along the way, including:

  • to use Dart types for typed output on the Agent.sendFor<TOutput> method instead of on the Agent itself so each LLM response can have it’s own type
  • to use Dart types for typed input on tool calls on the parameterized Tool<TInput> type itself
  • to use a parameterized model options parameter so each model can be created in a generic way, but also support provider-specific typed model options
  • to expose a set of static provider instances, e.g. Providers.openai, Providers.anthropic, etc. to make it easy to just grab one without using string names if you don’t want to
  • to expose usage tracking
  • to handle embeddings in chunks
  • and so many other tiny details that just makes dartantic better!

David’s langchain base allowed me to build support for 11x providers, 5x native (Mistral, Anthropic, Google, OpenAI and Ollama) and 6x more OpenAI-compatible configurations (Together, Cohere and Lambda as well as Ollama and Google configurations for their OpenAI-compatible endpoints). All 11x providers handle chat and 5x of them handle embeddings. I started with more OpenAI-compatible configurations, but their implementations were either so weak or so flakey or so both (I’m looking at you, Nvidia) that I dropped them — they couldn’t pass the more than 1100 tests I built out to test dartantic’s support for them. But feel free to drop in your own!

Industrial Strength

On top of David’s langchain work, I then built out a lot of new features for dartantic, including:

  • custom providers that participate in the named lookup just like the built-in providers
  • typed output
  • typed tool input
  • typed output WITH tool calls WITH streaming (progressive JSON rendering anyone?)
  • multi-provider chat-compatible message format
  • thorough logging w/ easy setup and filtering
  • usage tracking
  • and more…

You can see the nitty gritty in the dartantic docs.

What’s Next

I’ve separated out the core dartantic interfaces so that you can build a dartantic provider without depending on all of dartantic and so that I can make sure that dartantic continues to run everywhere that Dart runs. I’m working with the nice folks at Cactus to get their enterprise-grade local mobile-device-optimized LLMs into dartantic as a custom provider. I also want to get a provider for firebase_ai in there for my Flutter peeps who don’t want to mess with API keys in their client apps.

Of course, anyone that wants to can build a dartantic provider. Let me know if you do! I’d love to track them in the docs.

I also have plans to support image generation and audio transcription, as well as the new OpenAI Responses API and context caching to reduce token usage.

And I have big dreams for a dartantic builder that translates Dart types into JSON serialization and JSON schema for you automatically, streamlining the agent creation considerably:

@Agent()
class TacAgent extends Agent {
  TacAgent(super.model);

  @Run()
  Future<TownAndCountry> run(String prompt) => _$TownAndCountryAgentRun(prompt);
  
  @Tool()
  Future<DateTime> getCurrentDateTime() => DateTime.now();
}

I’m tracking my ideas for the future of dartantic on GitHub. Feel free to add your own.

Where Are We

My goal with dartantic isn’t for me to be a one-man band. The idea is that dartantic can grow with the AI needs of the Dart and Flutter community, while maintaining its principles of multi-provider support, multi-platform support and fun!

Want to steer where dartantic goes? Hate something and want it fixed? Get involved! Here’s how:

If you’re building AI-powered apps in Dart or Flutter, give dartantic a try. Switch between providers. Use typed output. Make tool calls. Build agents. Break things. Swear at it. Then come tell me what went wrong.

Welcome to dartantic 1.0. Let’s go break some stuff together.

July 16, 2025 ai

A Young Software Engineer’s Guide to AI

A Young Software Engineer’s Guide to AI

I was on an AI-focused podcast last week talking about how a new software engineer should work differently in this new era of AI. It reminded me of a conversation I had recently with a college grad in their first professional software engineering career. They were asking for advice from a wizened old coder (I was even wearing the suspenders). With the latest in AI coding tools, they were productive for sure, but they didn’t understand all of the code that the AI agent was producing. They were afraid that without being forced to write the code they old-fashioned way for cough 40 years cough, that they weren’t ever really going to understand what they were doing.

I don’t think that they are alone.

With that in mind, I’ve composed a letter for new SWEs just getting started with their careers.

Dear New Software Engineer

Congratulations on landing your first job! I know it’s exciting and terrifying at the same time. You’re probably sitting in an open-plan office (sorry about that), watching veterans write code with instincts honed through years of hands-on experience. Watching them work, you’re probably wondering how you’ll develop that same intuition when the latest corporate mandates encourage you to use AI coding agents. Especially when those agents generate code you don’t understand.

That’s OK. You’re not expected to understand all of the code you see at first, no matter who (or what) wrote it.

But that doesn’t mean you get to shrug your shoulders and press the commit button.

You’re an engineer: it’s your job to stand behind every line of code that you submit.

If you don’t understand what the AI is suggesting, then it’s your job to learn, even if you no longer have to write that code yourself. The good news is you’re in a better position to develop coding instincts than any generation of software engineers before you.

How We Old-Timers Learned

Back in my day, we walked uphill both ways to compile our code. And we were happy!

It was a different time and we used different techniques. We spent countless hours reading other people’s code - in books like Steve McConnell’s Code Complete, in discussion groups, on mailing lists. We debugged other people’s mistakes. We made our own mistakes. We’d argue about design patterns in Friday lunch-and-learns and spend weekends diving into open source projects just to understand how they worked.

The process was slow, sometimes tedious, but it built something invaluable: coding instincts. The ability to look at a piece of code and know that’s just wrong” even before you can articulate why. Having an opinion about what’s okay and what’s not OK in your code. Everyone develops their own instincts, but once you have them, you can spot problems that would take someone else hours to debug.

Learn the Same Way, But Faster

Those same learning techniques still work today. In fact, they work better than ever because you have an all-day, every-day collaboration partner who never gets tired of your questions (sorry, Mike, I know you had other work to do…).

You don’t have to wait for Friday’s lunch-and-learn to discuss the code you read about in Jon Bentley’s Programming Pearls. You can ask your AI to explain itself to you right now. When you see code you don’t understand, ask questions:

  • Why did you choose that approach?
  • What other options were available?
  • Is that the right solution for this specific problem?
  • What are the trade-offs of this approach?
  • Can you show me an alternative way to solve this?
  • What happens if [edge case] occurs?
  • Can you walk through this algorithm step by step?

Here’s a conversation I had recently with my AI-coding-agent-of-the-week:

Me: We (well, you really) just implemented simple hierarchical logging using a naming scheme, e.g. Logger(‘dartantic.chat.models’). I understand that the Dart logging package has a hierarchical logging feature built in. How does that differ from ours and should we be using that instead?

AI: I’ll take a look…

…looks it up…

AI: Here’s some sample code for using the hierarchical logging feature in the logging package:

…ugly, complicated hierarchical detail control code…

What you’ve done in this library is simpler and fits the needs of the library better. Let’s keep using what you’ve already done.”

Me: Thanks. I agree.

I learned about a feature I didn’t know existed, saw how it worked and then decided not to use it. That’s exactly the kind of exploration we’d do in the olden days of yore but now it happens in minutes or hours instead of days or weeks.

Go Further

But you don’t have to stop at learning the way we old-timers; you can go beyond:

  • Explore architectural patterns we never had time to try
  • Experiment with different approaches in minutes instead of days
  • Get instant feedback on code quality and best practices
  • Build prototypes to test ideas that would have taken weeks to implement
  • Write the spec, generate the code, throw away the code, update the spec, generate the code again, etc.

You’re not just getting help from an AI coder. You’re getting help from an AI coder trained on the collective knowledge of every software engineer who ever lived.

The patterns that took years to discover, the insights that made industry legends famous, the hard-won wisdom from decades of production systems - it’s all there. You just have to ask.

Coding Instincts - An Example

As you do this more, something interesting happens. You start developing opinions about the code you’re seeing. When something feels wrong, you know it, even if you can’t immediately explain why.

That’s when conversations like this start happening:

AI: I see the problem. It’s a bug in the underlying library. I’ll write a workaround.

…Me hitting the stop button…

Me: I’m sorry. I don’t believe you. Are you saying that we’ve just discovered a new bug in the underlying library that happens to coincide with the new code we just wrote? That’s as unlikely as getting hit by lightning while winning the lottery. I’d like you to write some diagnostic code to validate that claim before committing to a workaround, please.

…writing and running some diagnostic code…

AI: You’re right! The problem is in our code…

That’s what coding instincts look like. And it’s that gut feeling that you’re developing every time you ask the AI to explain itself, every time you ask for alternatives, every time you push back on its suggestions.

Don’t just accept what the AI gives you. Ask it to write tests for the code it created. Ask it to add diagnostic output to validate its assumptions. Ask it to critique its own solution.

Every time you do this, you’re doing the same thing we did when we spent hours in design review meetings or practiced test-driven development. You’re just doing it faster, with more patience from your mentor,” and with access to more knowledge than any of us ever had.

Keep Asking Why

I know you feel pressure to be immediately productive, to understand everything right away. Don’t get caught up in that. Every software engineer is constantly learning. Even after 40 years of doing things the hard way, I still learn things from AI every day.

If you stay curious and engaged, your coding instincts will develop just fine. In fact, they’ll probably develop faster and be more robust than mine ever were.

Yours sincerely, Chris Sells

P.S. Keep a close eye on AI coders that wrap errors in exception handling in the name of defensive coding” but really just to get the tests passing. They’re sneaky that way…

May 28, 2025 flutter ai

Flutter AI Tool Calling

Flutter AI Tool Calling

A little while ago, I was inspired by Thorsten’s blog post on building an AI Agent using Rust to build an AI Agent using Dart. The combination of a conversation with Gemini and a set of tools allowed us to build an agent that could take some prompts from the user and turn them into not just responses (Ask mode), but actions (Agent mode!). In the spirit of Agentic Apps month for Flutter this month, I wanted to share how to do the same thing in your Flutter app using the most recent release of the Flutter AI Toolkit.

Flutter AI Toolkit v0.9.0

As we near the 1.0 release of the AI Toolkit, the community has continued to contribute features that they’d like to see in a customizable, style-able and LLM-pluggable widget you can use when you’d like to enable your users to be able to talk to an AI in the context of your app.

In this case, Toshi Ossada contributed a PR that provided the inspiration for tool calling for the new FirebaseProvider in the AI Toolkit. This new provider replaces both the GeminiProvider and the VertexProvider as described in the migration guide for v0.9.0.

Tool calling is the ability to augment an LLM with a set of functions — what the AI industry refers to as tools” — that the LLM can call when it needs the data that the tools provide. For example, an LLM by itself has no idea what time it is; it needs to have some tool that can provide that information and if it doesn’t have one, it’ll just make something up. With confidence.

Here’s an example of how to provide tools to the FirebaseProvider:

class ChatPage extends StatelessWidget {
  const ChatPage({super.key});

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(title: const Text(App.title)),
    body: LlmChatView(
      provider: FirebaseProvider(
        model: FirebaseAI.googleAI().generativeModel(
          model: 'gemini-2.0-flash',
          tools: [
            Tool.functionDeclarations([
              FunctionDeclaration(
                'get_temperature',
                'Get the current local temperature',
                parameters: {},
              ),
              FunctionDeclaration(
                'get_time',
                'Get the current local time',
                parameters: {},
              ),
            ]),
          ],
        ),
        onFunctionCall: _onFunctionCall,
      ),
    ),
  );
  
  ... // _onFunctionCall goes here...
}

This code initializes the model with two tools: get_temperature and get_time. These tools come with names and descriptions so that the model can understand what they’re for and make an informed decision about when to call them.

The model is also initialized with an onFunctionCall callback so that when the LLM wants to use one of those tools, your function is called to handle it:

class ChatPage extends StatelessWidget {
  ...

  // note: we're not actually calling any external APIs in this example
  Future<Map<String, Object?>?> _onFunctionCall(
    FunctionCall functionCall,
  ) async => switch (functionCall.name) {
    'get_temperature' => {'temperature': 60, 'unit': 'F'},
    'get_time' => {'time': DateTime(1970, 1, 1).toIso8601String()},
    _ => throw Exception('Unknown function call: ${functionCall.name}'),
  };
}

We’re just returning hard-coded values here, but this is the place where you’d look up the data that the LLM wants as part of fulfilling the user’s request, as shown here:

In this example, we’re just looking up information that the LLM would have trouble getting on it’s own. However, if a call to a tool has a side affect then BOOM you’ve moved from Ask mode to Agent mode. Welcome to the future!

Flutter + Genkit + Interrupts (oh my!)

This all works great so long as everything is handled on the client-side. However, as soon as you mix in server-side LLMs, for example by using Genkit, you have some additional considerations to take into account.

Genkit is an open-source framework for building full-stack AI-powered applications, developed and used in production by Google. It currently has language support for Typescript/Javascript, Go and Python but unfortunately not Dart. However, if you’re willing to write some Typescript, it turns out that Genkit has great support server-side tools handled by Flutter apps with something called interrupts.

A Genkit interrupt is a tool that’s invoked on the server but fulfilled on the client.

How can that work?” I’m hearing you ask through the Interwebtubes.

Well let me tell you.

The way an LLM tool works is that during the handling of a request, if there’s a tool involved, the LLM API will call back into the function you provided via a callback like onFunctionCall . That call might take a while, e.g. you may need to dial up the national weather service, but when, the Future will complete and the LLM will be able to carry on.

That’s great,” you say. But how do I stretch a function callback over the wire from a Genkit server to a Flutter client?” You ask such good questions.

Well, as it turns out, tool calls being invoked in the middle of an LLM response is an API fiction. What’s really happening is that when an LLM wants to call a tool, it replies with a message marked as a tool call and that include the tool arguments. The LLM client library — like the firebase_ai package — notices this, calls your callback function, bundles up the response and continues the conversation without bothering you about it at all until the actual LLM response comes back, having taken into account the results of the tool call(s).

For example, the user’s request above in a single prompt looks like it returns a single response, but looking at the actual message history tells a different story:

[
  {
    "role": "user",
    "parts": [
      {
        "text": "what's the time and temperature?"
      }
    ]
  },
  {
    "role": "model",
    "parts": [
      {
        "functionCall": {
          "name": "get_time",
          "args": {}
        }
      },
      {
        "functionCall": {
          "name": "get_temperature",
          "args": {}
        }
      }
    ]
  },
  {
    "role": "function",
    "parts": [
      {
        "functionResponse": {
          "name": "get_time",
          "response": {
            "time": "1970-01-01T00:00:00.000"
          }
        }
      },
      {
        "functionResponse": {
          "name": "get_temperature",
          "response": {
            "temperature": 60,
            "unit": "F"
          }
        }
      }
    ]
  },
  {
    "role": "model",
    "parts": [
      {
        "text": "OK. The current time is 1970-01-01T00:00:00.000 and the temperature is 60 degrees Fahrenheit."
      }
    ]
  }
]

OK,” you say warming up to another question, but what’s that got to do with Genkit and server-side tool calls?” I’m getting there!

Genkit Tools

Genkit also provides for tool calls, as shown in this example:

// define a tool
const getWeather = ai.defineTool(
  {
    name: 'getWeather',
    description: 'Gets the current weather in a given location',
    inputSchema: z.object({
      location: z.string().describe('The location to get the current weather for'),
    }),
    outputSchema: z.string(),
  },
  async (input) => {
    // Here, we would typically make an API call or database query. For this
    // example, we just return a fixed value.
    return `The current weather in ${input.location} is 63°F and sunny.`;
  },
);

// use a tool
const response = await ai.generate({
  prompt: "What is the weather in Baltimore?",
  tools: [getWeather],
});

This is semantically the same — we define a tool that the LLM can call during a request. And again, it looks like a seamless callback, which you need to implement on the server, even though we know there is a flow of messages underneath just like what we see above.

But what if you could interrupt the flow of messages when there’s a tool call, pass the stack of messages back to the Flutter app and let it fill in the results? That’s exactly what Genkit interrupts are for.

Genkit Interrupts: Human in the Loop

From the Genkit docs: Interrupts are a special kind of tool that can pause the LLM generation-and-tool-calling loop to return control back to you. When you’re ready, you can then resume generation by sending replies that the LLM processes for further generation.”

As an example, imagine that you’ve got an app that helps people with their plants, maybe expanding their garden or diagnosing their sick plants.

Further imagine that you’ve got an LLM in your server-side code with access to a database of products that can help users with their plant needs.

Now imagine that the LLM has been instructed to ask the user a set of questions to clarify the plant needs before recommending one of those products.

Since the LLM is running in your Genkit server with access to your server-side database of products, to involve the user of your Flutter app in a conversation, you’ve now got the perfect storm for using interrupts to keep the human in the loop.”

To implement this in Genkit, you define your tools as interrupts so that the LLM can pause the response to gather data from the user:

const choiceInterrupt = ai.defineInterrupt(
  {
    name: 'choice',
    description: 'Asks the user a question with a list of choices',
    inputSchema: z.object({
      question: z.string().describe("The model's follow-up question."),
      choices: z.array(z.string()).describe("The list of choices."),
    }),
    outputSchema: z.string().describe("The user's choice."),
  });

In Genkit, an endpoint that you can call from your Flutter app via HTTP is called a flow”:

const greenThumb = ai.defineFlow(
  {
    name: "greenThumb",
    ...
  },
  async ({ prompt, messages, resume }) => {
    const response = await ai.generate({
      ...(messages && messages.length > 0 ? {} : { system: gtSystem }),
      prompt,
      tools: [choiceInterrupt, ...],
      messages,
      resume,
    });

    return { messages: response.messages };
  });

Notice that the greenThumb flow takes a set of messages and returns a set of messages. To kick things off in your Flutter code, you pass an empty list of messages. If the last message in the LLM response is an interrupt, it will include the info you need to show a UI to ask the user to answer the LLMs question:

{
  "messages": [
    {
      "role": "system",
      "content": [
        {
          "text": "\n  You're an expert gardener. The user will ask a question about how to manage\n  their plants in their garden. Be helpful and ask 3 to 5 clarifying questions,\n  using the choiceInterrupt tool.\n  \n  When you're done asking questions, provide a description of a product or\n  products that will help the user with their original query. Each product\n  description should NOT include another question for the user nor should it\n  include the name of any specific product.\n"
        }
      ]
    },
    {
      "role": "user",
      "content": [
        {
          "text": "I'd like to expand my garden."
        }
      ]
    },
    {
      "role": "model",
      "content": [
        {
          "text": "That's great! To give you the best advice, I need a little more information.\n\n1.  What kind of plants are you planning to add to your garden (e.g., vegetables, flowers, shrubs)?\n2.  What is the approximate size of the area you're planning to expand?\n3.  What type of soil do you have in your garden currently (e.g., sandy, clay, loamy)?\n4.  What is the average amount of sunlight your garden receives each day?\n\n"
        },
        {
          "toolRequest": {
            "name": "choiceInterrupt",
            "input": {
              "choices": [
                "Vegetables",
                "Flowers",
                "Shrubs",
                "Other"
              ],
              "question": "What kind of plants are you planning to add to your garden?"
            }
          },
          "metadata": {
            "interrupt": true
          }
        }
      ]
    }
  ]
}

This continues until the LLM gathers the data it needs to provide a response:

{
  "messages": [
    ...
    {
      "role": "model",
      "content": [
        {
          "text": "Based on your answers, here are a couple of product descriptions that might be helpful for expanding your flower garden:\n\n**Product 1:** This soil amendment is designed to improve drainage and aeration in existing soil, while also providing essential nutrients for healthy root development. It's perfect for preparing new garden beds or revitalizing existing ones. Simply mix it into the top layer of soil before planting to create an ideal environment for flowers to thrive.\n\n**Product 2:** These granular plant food spikes provide a slow-release source of nutrients for flowering plants. They are easy to use - simply insert them into the soil around your existing plants, and they will gradually release nutrients over an extended period, promoting vibrant blooms and strong growth. They're especially beneficial for areas with full sun exposure, as they help plants maintain consistent access to the resources they need.\n"
        }
      ]
    }
  ]
}

Of course, there are a lot of picky details to get this right, so I’ll refer you my flutter_fixit_warehouse sample, which provides all of the code and a more thorough explanation.

Where are we?

With the latest updates to the Flutter AI Toolkit, you can now build tool calls into your Flutter agentic apps. Furthermore, with Genkit’s support for interrupts, you can keep the human in the loop by handling server-side tool calls with input from your Flutter app users.

Now, with these new tools in your toolbox, go forth and build!

May 6, 2025 flutter ai

Pedantic AI in Dart: dartantic_ai

Pedantic AI in Dart: dartantic_ai

The Python community has a library called pydantic that adds type checking at run-time to a dynamically typed language. The library allows them to be pedantic” about type validation in Python aka pydantic; get it? : )

We don’t need that for Dart. We have static type checking and it’s wonderful.

Pedantic AI in Python: pydantic-ai

On top of pydantic, the Python community has built pydantic-ai, which makes it easy for you to specify typed output from your LLM requests and to describe typed access to your tools. For example:

# Python example with support for multiple models
import os

from pydantic import BaseModel
from pydantic_ai import Agent

class TownAndCountry(BaseModel):
    town: str
    country: str

model = 'openai:gpt-4o' # or 'google-gla:gemini-2.0-flash' or ...
print(f'Using model: {model}')
agent = Agent(model, output_type=TownAndCountry)

if __name__ == '__main__':
    result = await agent.run('The windy city in the US of A.')
    print(result.output) // Output: town='Chicago' country='United States'

Check out the definition of the TownAndCountry type and the use of it when creating an Agent object with the output_type parameter. That’s all you need to get an instance of TownAndCountry populated by the LLM based on the prompt.

Now that’s something we don’t have in Dart! Instead, we have to do something like this:

// Dart example for Gemini only
void main() async {
  final model = gemini.GenerativeModel(
    apiKey: Platform.environment['GEMINI_API_KEY']!,
    model: 'gemini-2.0-flash',
    generationConfig: gemini.GenerationConfig(
      responseMimeType: 'application/json',
      responseSchema: gemini.Schema.object(
        properties: {
          'town': gemini.Schema.string(),
          'country': gemini.Schema.string(),
        },
        requiredProperties: ['town', 'country'],
      ),
    ),
  );

  final result = await model.generateContent([
    gemini.Content.text('The windy city of the US of A.'),
  ]);

  final json = jsonDecode(result.text!);
  final obj = TownAndCountry.fromJson(json);
  print(obj); // Output: TownAndCountry(town: Chicago, country: United States)
}

Plus, while the above code works for the Gemini SDK for Dart, if I want to do the same thing using the OpenAI SDK for Dart, I have to write very different code:

// Dart example for OpenAI only
void main() async {
  final client = openai.OpenAIClient(
    apiKey: Platform.environment['OPENAI_API_KEY'],
  );

  final response = await client.createChatCompletion(
    request: const openai.CreateChatCompletionRequest(
      model: openai.ChatCompletionModel.modelId('gpt-4o'),
      responseFormat: openai.ResponseFormat.jsonObject(),
      messages: [
        openai.ChatCompletionMessage.system(
          content:
              'Respond ONLY with JSON containing keys "town" and "country".',
        ),
        openai.ChatCompletionMessage.user(
          content: openai.ChatCompletionUserMessageContent.string(
            'The windy city of the US of A.',
          ),
        ),
      ],
    ),
  );

  final data =
      jsonDecode(response.choices.first.message.content!)
          as Map<String, dynamic>;

  final result = TownAndCountry.fromJson(data);
  print(result); // Output: TownAndCountry(town: Chicago, country: United States)
}

There must be a better way!

A Better Way: dartantic_ai

I was inspired by pydantic-ai for two main features:

  1. An easy way to go between models using just a string descriptor, e.g. openai:gpt-4o
  2. A common way to provide type information for output and tool calls, i.e. JSON schema

Those are the features I focused on initially for dartantic_ai, allowing you to write code like the following:

// Dart example with support for multiple models
class TownAndCountry {
  TownAndCountry({required this.town, required this.country});
  final String town;
  final String country;  
  
  factory TownAndCountry.fromJson(Map<String, dynamic> json) => TownAndCountry(
      town: json['town'],
      country: json['country'],
    );
  
  static Map<String, dynamic> get schemaMap => {
    'type': 'object',
    'properties': {
      'town': {'type': 'string'},
      'country': {'type': 'string'},
    },
    'required': ['town', 'country'],
    'additionalProperties': false,
  };
  
  @override
  String toString() => 'TownAndCountry(town: $town, country: $country)';
}

void main() async {
  final agent = Agent(
    model: 'openai:gpt-4o', // or 'google:gemini-2.0-flash' or ...
    outputType: TownAndCountry.schemaMap,
  );

  final result = await agent.run('The windy city in the US of A.');
  final obj = TownAndCountry.fromJson(jsonDecode(result.output));
  print(obj); // Output: TownAndCountry(town: Chicago, country: United States)
}

Here we’ve created a class to hold the typed output from the agent, passing in hand-written JSON schema and JSON decoder functions. Already, this is much simpler code than either of the Gemini or the OpenAI samples and it works either family of models by simply changing the model description string.

Further, with a little bit of Dart builder magic, you can use json_serializable and soti_schema to generate the JSON serialization and JSON schema for you:

// Automatic JSON decoding and schema generation
@SotiSchema()
@JsonSerializable()
class TownAndCountry {
  TownAndCountry({required this.town, required this.country});

  factory TownAndCountry.fromJson(Map<String, dynamic> json) =>
      _$TownAndCountryFromJson(json);

  final String town;
  final String country;

  Map<String, dynamic> toJson() => _$TownAndCountryToJson(this);

  @jsonSchema
  static Map<String, dynamic> get schemaMap => _$TownAndCountrySchemaMap;

  @override
  String toString() => 'TownAndCountry(town: $town, country: $country)';
}

void main() async {
  final agent = Agent(
    model: 'openai:gpt-4o'
    outputType: TownAndCountry.schemaMap,
    outputFromJson: TownAndCountry.fromJson,
  );

  final result = await agent.runFor<TownAndCountry>(
    'The windy city in the US of A.',
  );

  print(result.output); // Output: TownAndCountry(town: Chicago, country: United States)
}

Using the builder, we no longer have to write the JSON serialization code or the JSON schema by hand — json_serialization and soti_schema handle that. And, for fun, we’re calling the runFor<T> method so that the output you get is typed w/o you having to manually call jsonDecode. Magic!

Potential Future

Right now, we’re in phrase 1” of dartantic_ai development — building out the core set of features and providers that work with those features (starting with Gemini and OpenAI). That’s what the code samples above are all about — what’s the best developer experience we can provide for a working Dart developer adding generative AI to their apps?

Once there’s a solid foundation, we can start experimenting with a builder that would allow you to write even simpler code:

@Agent()
class TacAgent extends Agent {
  TacAgent(super.model);

  @Run()
  Future<TownAndCountry> run(String prompt) => _$TownAndCountryAgentRun(prompt);
}

void main() async {
  final result = await TacAgent('openai:gpt-4o').run('The windy city of the US of A.');
  print(result.output); // Output: TownAndCountry(town: Chicago, country: United States)
}

And this is just the beginning. Today, dartantic supports tool calls, which you define with JSON schema in a way that’s similar to typed output from a run call. Now imagine being able to put a @Tool attribute on a method in your agent class and have the tool passed in automatically for you. There are all kinds of possibilities as soon as builders are involved.

Call for Contributors

As of the writing of this post, I’ve just started my dartantic_ai journey with a list of current and pending features you can read about on pub.dev. I only support the smallest amount of the Gemini and OpenAI SDK surface area to implement the initial features that are most important to me.

However, pydantic-ai has a big surface area with lots of great stuff for using LLMs in a type-safe, multi-model way that the Dart community would be interested in, including multi-agent support, agent graphs, multi-media support, streaming, etc. I’m going to need help to cover all of that, let alone making it work in a robust, matrix-tested way that can appeal to a growing community of Dart developers dipping their toes into AI.

Is dartantic_ai a style of interacting with LLMs from Dart that appeals to you? Are there features missing that you want or bugs you’ve found putting it to use? Then have I got a deal for you! Please contribute issues and PRs and let’s get this show on the road!

April 24, 2025 flutter ai

AI Agent with Dart + Gemini

AI Agent with Dart + Gemini

To say that there has been a lot of activity in the AI space for developers lately would be an understatement. As we transition from Ask” mode in our AI-based dev tooling to Agent” mode, it’s easy to see agents as something magical.

Any sufficiently advanced technology is indistinguishable from magic.” –A. C. Clarke

And while the vendors of AI-agent-based tooling might like you to think of their products as PFM, as Thorsten Ball points out in his blog post, How to Build an Agent or: The Emperor Has No Clothes, AI agents are not as magical as they appear. He then demonstrates that fact by implementing an AI agent using Go and Claude right before your eyes. I highly recommend reading it — Thorsten tells a gripping tale of AI and code. By the end, he’s pulled back the curtain on AI agents and made it quite clear that this technology is within anyone’s reach.

AI Agent in Dart

Combine Thor’s post with the recent Building Agentic Apps campaign announced by the Flutter team and I just couldn’t help myself from doing a bit of vibe coding to produce the Dart and Gemini version:

import 'dart:io';

import 'package:google_generative_ai/google_generative_ai.dart';

Future<void> main() async {
  final apiKey = Platform.environment['GEMINI_API_KEY'];
  if (apiKey == null) {
    stderr.writeln('Please set the GEMINI_API_KEY environment variable.');
    exit(1);
  }

  final model = GenerativeModel(
    // model: 'gemini-2.0-flash',
    // model: 'gemini-2.5-flash-preview-04-17',
    model: 'gemini-2.5-pro-preview-03-25',
    apiKey: apiKey,
    tools: [
      Tool(
        functionDeclarations: [
          FunctionDeclaration(
            'read_file',
            'Read the contents of a file at a relative path.',
            Schema(
              SchemaType.object,
              properties: {'path': Schema(SchemaType.string)},
            ),
          ),
          FunctionDeclaration(
            'list_files',
            'List all files in a given directory.',
            Schema(
              SchemaType.object,
              properties: {'dir': Schema(SchemaType.string)},
            ),
          ),
          FunctionDeclaration(
            'edit_file',
            'Overwrite the contents of a file with new content.',
            Schema(
              SchemaType.object,
              properties: {
                'path': Schema(SchemaType.string),
                'replace': Schema(SchemaType.string),
              },
            ),
          ),
        ],
      ),
    ],
  );

  final chat = model.startChat();

  print('Gemini 2.0 Flash Agent is running. Type "exit" to quit.');
  while (true) {
    stdout.write('\x1B[94mYou\x1B[0m: ');
    final input = stdin.readLineSync();
    if (input == null || input.toLowerCase() == 'exit') break;

    final response = await chat.sendMessage(Content.text(input));

    final text = response.text?.trim();
    if (text != null && text.isNotEmpty) {
      print('\x1B[93mGemini\x1B[0m: $text');
    }

    final functionResponses = <Content>[];
    for (final candidate in response.candidates) {
      for (final part in candidate.content.parts) {
        if (part is FunctionCall) {
          final result = await handleToolCall(part);
          print('\x1B[92mTool\x1B[0m: ${part.name}(${part.args})');
          functionResponses.add(
            Content.functionResponse(part.name, {'result': result}),
          );
        }
      }
    }

    if (functionResponses.isNotEmpty) {
      final response = await chat.sendMessage(
        Content(
          '',
          functionResponses.map((c) => c.parts).expand((p) => p).toList(),
        ),
      );
      if (response.text != null) {
        print('\x1B[93mGemini\x1B[0m: ${response.text}');
      }
    }
  }
}

Future<String> handleToolCall(FunctionCall call) async {
  final args = call.args;
  try {
    switch (call.name) {
      case 'read_file':
        return await readFile(args['path'] as String);
      case 'list_files':
        return await listFiles(args['dir'] as String? ?? '.');
      case 'edit_file':
        return await editFile(
          args['path'] as String,
          args['replace'] as String,
        );
      default:
        final err = 'Unknown tool: ${call.name}';
        print(err);
        return err;
    }
  } catch (e) {
    final err = 'Error executing ${call.name}: $e';
    print(err);
    return err;
  }
}

Future<String> readFile(String path) async {
  final file = File(path);
  if (!await file.exists()) return 'File not found: $path';
  return await file.readAsString();
}

Future<String> listFiles(String dirPath) async {
  final dir = Directory(dirPath);
  if (!await dir.exists()) return 'Directory not found: $dirPath';
  final entries = await dir.list().toList();
  return entries.map((e) => e.path).join('\n');
}

Future<String> editFile(String path, String content) async {
  final file = File(path);
  await file.writeAsString(content);
  return 'File $path updated successfully.';
}

The full project is available on GitHub, but everything of interest is right here, with code that does the following:

  1. Connects to the Gemini API using an API key.
  2. Defines tools for file operations (read_file, list_files, edit_file).
  3. Enters a loop to chat with the user via the console.
  4. Sends user input to the Gemini model.
  5. Handles function calls requested by the model by executing corresponding local Dart functions for file system interaction.
  6. Sends the results back to the model and prints the final response.

To demonstrate functional parity with Thorsten’s Go implementation, the following are some examples.

Example: horsing around

Here’s an example of context carrying on from one prompt to the next:

$ dart bin/main.dart

Gemini 2.0 Flash Agent is running. Type "exit" to quit.
You: Hey! My name is Chris! How are you?
Gemini: Hi Chris! I'm doing well, thank you for asking!

How can I help you today?
You: I recently purchased a horse and I'm looking for some playful horse-related names based on my own name. Can you give me a hand with that?
Gemini: That's a fun idea, Chris! Let's brainstorm some playful horse names related to your name:

1.  **Chris-Cross Gallop:** Playful, suggests movement and maybe jumping.
2.  **Krispy Kremeigh:** A silly pun combining a treat with a horse sound.
3.  **Sir Chris-top-hoof:** A more elaborate, knightly-sounding pun, especially if your full name is Christopher.
4.  **Chriscendo:** Like a musical crescendo, suggesting growing speed or presence.
5.  **Chrispy Trot:** Simple, catchy, and combines "Chris" with a horse gait.
6.  **Christo-fur:** If your horse is particularly furry or fluffy!
7.  **Captain Chris:** Simple, classic, gives a sense of leadership.
8.  **Whinny Chris:** Combines a horse sound with your name.
9.  **Topher Trot:** If your full name is Christopher, using the shorter form.
10. **Chrispy Comet:** Suggests speed and energy.

Hopefully, one of these sparks some inspiration! Do any of these sound like a good fit for your new horse's personality? Or would you like some more ideas?
You: exit

Notice that Gemini remembers my name.

Example: read_file tool

Here’s an example of the tool that reads files:

You: Gemini, buddy, help me solve a riddle in the secret-file.txt file.
Gemini: Okay, buddy! I can help with that. First, let's see what the riddle is.
Tool: read_file({path: secret-file.txt})
Gemini: Okay, the riddle in `secret-file.txt` is:

"what animal is the most disagreeable because it always says neigh?"

The answer is a **horse**!

Do you need help with anything else, buddy?
You: what's going on in bin/main.dart? be brief!
Tool: read_file({path: bin/main.dart})
Gemini: It's a Dart command-line application that:
1.  Connects to the Gemini API using an API key.
2.  Defines tools for file operations (`read_file`, `list_files`, `edit_file`).
3.  Enters a loop to chat with the user via the console.
4.  Sends user input to the Gemini model.
5.  Handles function calls requested by the model by executing corresponding local Dart functions for file system interaction.
6.  Sends the results back to the model and prints the final response.

It’s really the independent use of tools that makes an agent an agent — I tell it about the riddle in secret-file.txt but I don’t tell it use a tool. It has the tool — because I told it so in the system instructions — and it uses it as it sees fit.

Example: list_files tool

Here the agent is using another tool that knows how to list files in a folder, which it uses multiple times and in conjunction with the tool to read files.

You: what do you see in this directory?
Tool: list_files({})
Gemini: Okay, I see the following files and directories in the current directory:

*   `.DS_Store` (macOS metadata file)
*   `bin` (directory, likely containing executable scripts)
*   `CHANGELOG.md` (Markdown file)
*   `pubspec.lock` (Dart package lock file)
*   `secret-file.txt` (Text file)
*   `README.md` (Markdown file)
*   `pubspec.yaml` (Dart package configuration file)
*   `.gitignore` (Git configuration file)
*   `analysis_options.yaml` (Dart analysis configuration file)
*   `.dart_tool` (Dart build/cache directory)
*   `.git` (Git repository directory)
*   `.vscode` (VS Code editor configuration directory)

You: tell me about all of the Dart and Dart-project-related files in this directory and in the bin directory
Tool: list_files({})
Tool: list_files({dir: bin})
Gemini: Okay, I can list the files in the current directory and the `bin` directory and then describe the Dart-related ones.

Based on the file listing:

**In the `bin` directory:**

*   `bin/main.dart`: This is a Dart source code file, likely the main entry point for an executable application.

**In the current directory (`./`):**

*   `pubspec.yaml`: This is the core Dart project definition file. It defines the project's name, description, dependencies, and other metadata.
*   `pubspec.lock`: This file locks the specific versions of the dependencies listed in `pubspec.yaml` to ensure reproducible builds. It's automatically generated or updated when you run `dart pub get`.
*   `analysis_options.yaml`: This file configures the Dart static analyzer and linter rules for the project.
*   `.dart_tool/`: This directory is created and managed by the Dart/Flutter tools. It contains build artifacts, package configuration files (`package_config.json`), and other temporary files needed for the project. You usually don't edit files inside it directly.
*   `.gitignore`: While not strictly a Dart file, it often contains entries specific to Dart projects to prevent generated files (like `.dart_tool/`, `build/`, `.packages`, `pubspec.lock` - although `pubspec.lock` is often committed) from being checked into version control.
*   `bin/`: This directory itself, which we already examined, typically contains the executable Dart scripts for a project.

You: what dart sdk version are we using for this project?
Gemini: Okay, the Dart SDK version constraint for the project is defined in the `pubspec.yaml` file. I'll read that file to find the constraint.
Tool: read_file({path: pubspec.yaml})
Gemini: Based on the `pubspec.yaml` file, the Dart SDK version constraint for this project is:

environment:
  sdk: ^3.7.2

This means the project requires a Dart SDK version that is compatible with `3.7.2`. Specifically, it allows any version from `3.7.2` up to (but not including) `4.0.0`.

Again, the prompt isn’t explicit about what tool(s) to use — it decides itself based on what task it’s trying to accomplish.

Example: edit_file tool

One more tool — the ability to edit files:

$ dart bin/main.dart

Gemini 2.0 Flash Agent is running. Type "exit" to quit.
You: hey, gemini, create fizzbuzz.js that I can run with nodejs and that has fizzbuzz in it and executes it
Tool: edit_file({path: fizzbuzz.js, replace: function fizzBuzz(n) {
  for (let i = 1; i <= n; i++) {
    if (i % 3 === 0 && i % 5 === 0) {
      console.log("FizzBuzz");
    } else if (i % 3 === 0) {
      console.log("Fizz");
    } else if (i % 5 === 0) {
      console.log("Buzz");
    } else {
      console.log(i);
    }
  }
}

// Execute the function up to 100
fizzBuzz(100);
})
Gemini: OK. I've created the `fizzbuzz.js` file with the FizzBuzz logic. It will print FizzBuzz up to 100 when you run it using Node.js (e.g., `node fizzbuzz.js`).
You: exit

$ node fizzbuzz.js
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz
Fizz
22
23
Fizz
Buzz
26
Fizz
28
29
FizzBuzz
31
32
Fizz
34
Buzz
Fizz
37
38
Fizz
Buzz
41
Fizz
43
44
FizzBuzz
46
47
Fizz
49
Buzz
Fizz
52
53
Fizz
Buzz
56
Fizz
58
59
FizzBuzz
61
62
Fizz
64
Buzz
Fizz
67
68
Fizz
Buzz
71
Fizz
73
74
FizzBuzz
76
77
Fizz
79
Buzz
Fizz
82
83
Fizz
Buzz
86
Fizz
88
89
FizzBuzz
91
92
Fizz
94
Buzz
Fizz
97
98
Fizz
Buzz

$ dart bin/main.dart

Gemini 2.0 Flash Agent is running. Type "exit" to quit.
You: please edit fizzbuzz.js so that it only prints until 15
Gemini: Okay, I can help with that. First, I need to read the current content of `fizzbuzz.js` to see how it's written.
Tool: read_file({path: fizzbuzz.js})
Gemini: Okay, I see the content of `fizzbuzz.js`. It defines a function `fizzBuzz(n)` and then calls it with `fizzBuzz(100);`.

To make it print only until 15, I will change the last line from `fizzBuzz(100);` to `fizzBuzz(15);`.

Here's the code to edit the file:

You: go ahead
Tool: edit_file({path: fizzbuzz.js, replace: function fizzBuzz(n) {
  for (let i = 1; i <= n; i++) {
    if (i % 3 === 0 && i % 5 === 0) {
      console.log("FizzBuzz");
    } else if (i % 3 === 0) {
      console.log("Fizz");
    } else if (i % 5 === 0) {
      console.log("Buzz");
    } else {
      console.log(i);
    }
  }
}

// Execute the function up to 15
fizzBuzz(15);
})
Gemini: OK. I have edited the `fizzbuzz.js` file to change the function call from `fizzBuzz(100)` to `fizzBuzz(15)`. The script will now only print the FizzBuzz sequence up to 15 when executed.
You: exit

$ node fizzbuzz.js
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz

Not only can the agent list, read and write files all on its own, it can update existing files. And it can do so while producing code that actually works (which is more than many interview candidates can do with fizzbuzz in my experience).

Congrats!

And finally:

$ dart bin/main.dart

Gemini 2.0 Flash Agent is running. Type "exit" to quit.
You: Create a congrats.js script that rot13-decodes the following string 'Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!' and prints it
Tool: edit_file({replace: function rot13(str) {
  return str.replace(/[a-zA-Z]/g, function(char) {
    const charCode = char.charCodeAt(0);
    // Check case: uppercase A-Z (65-90), lowercase a-z (97-122)
    const base = charCode < 97 ? 65 : 97;
    // Apply ROT13 shift
    return String.fromCharCode(base + (charCode - base + 13) % 26);
  });
}

const encodedString = 'Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!';
const decodedString = rot13(encodedString);
console.log(decodedString);
, path: congrats.js})
You: exit

$ node congrats.js
Congratulations on building a code-editing agent!

At this point, it should be clear that building an AI agent in Dart using Gemini is certainly not magic. In fact, it’s not even hard — the whole thing is less than 140 LOC. This sample is obviously not something I’d recommend shipping, since there are no guardrails keeping the AI from seeing and changing every file in your file system. It does make a good place to start when building agentic apps for Dart and Flutter, however. Enjoy!

April 9, 2025 flutter ai

Building Generative AI for DartPad

Building Generative AI for DartPad

Hello, again, and welcome to another installment of Flutter + AI = Joy.” In today’s episode, we’re taking a behind-the-scenes look at the design and implementation of the generative AI features in the latest version of DartPad. Before we get started, if you haven’t already read Amanda’s most excellent blog post for an overview of the new functionality, I recommend starting there.

Streaming Responses

The first thing I needed to do to enable generative AI in DartPad was to add support for streaming responses. Even as fast as Gemini 2.0 Flash is, if you have to wait for the complete code for anything beyond Dart hello, world”, you’re gonna get antsy.

The backend service for DartPad is built on top of shelf, the package that provided server-wide support for Dart before it was cool. Shelf supports streaming, but the docs aren’t exactly available on the topic. Also, streaming hasn’t been used in DartPad before, so it was a bit of an experiment. Ultimately I turned gzip and I/O buffering off for the code-gen API endpoints and streamed the generated code back as UTF8-encoded bytes, which the client is expected to decode.

And this worked great — the server sent data back in chunks of bytes and the client decoded them back into a string, updating the UI for each chunk that it received. Except that the client only got one chunk with the complete response for every request. And this was true even though the server was sending back multiple chunks as Gemini provided them. So what was the problem?

It took a ridiculously long time (days!) to figure out that the Dart http package on the web was using XMLHttpRequest, which collapsed streaming responses into a single response, killing any chance to provide progress updates. With some hacking around, I figured out that the fetch API did the right thing, so the http package needed an update. I discovered this in February of 2025. The good news is that the Dart team had already done that work in November of 2024 and that the PR was pending! Once that PR landed, we were good to go.

Error Handling

My initial design proposal called for adding a Gemini menu to DartPad with New, Update, Fix and Image to Code functionality:

Image to Code was bundled together with Dart/Flutter Snippet (New) and Update code (Update) via the ability to attach images. Bringing up a dialog to enter a prompt made sense for New and Update, since DartPad doesn’t know what kind of code you want to generate or what updates you want to make.

For Fix, however, it was annoying to have to tell DartPad what the error was, since the analyzer was reporting the errors to me! So I hijacked the analyzer error message UI with the idea of building the prompt to suggest a fix for the user. The result is that now there’s a lightbulb to indicate analyzer messages and to provide an easy way for the user to trigger the menu of potential fixes. Right next to that, I added a Gemini sparkle icon:

Clicking on the sparkle bundles up the error message automatically, asks Gemini for a fix and provides you a diff:

That’s just magic! Once I had it working for analyzer errors, I needed it for run-time errors, too, so I added the Gemini sparkle to the console output window.

When you press on the sparkle icon in this case, DartPad will bundle up your run-time error and suggest a fix.

Unfortunately, there was some work to enable the magic for run-time errors. Previously, there had been no reason to distinguish between normal console output and error output. That meant there was no good way to decide when to show the blue sparkle. However, you certainly do not want to show the Suggest Fix button when a Dart app is printing the last 10 numbers of pi. Luckily, John Ryan, Flutter DevRel and engineering lead for DartPad, came to the rescue with a fix that allowed me to reliably show the blue sparkle only when it was needed.

UX Shortcuts

After a long time on Unix before Windows and a long time on Windows before Mac, I’ve become a keyboard guy. I want to know all of the keyboard shortcuts so I can avoid using the mouse. While building and testing DartPad, I spent a lot of time in the prompt and code generating dialogs, both of which require you to press the Accept button. So I was doing that a lot. This annoyed me, so I added a keyboard shortcut:

  • Ctrl+Enter (Cmd+Enter on macOS) will trigger the Accept action

And because I’m super lazy:

  • Accepting the generated code will trigger the Run action

  • Or if hot reload is enabled, the Reload action will be triggered instead

I added all of this simply because I couldn’t figure out a case when that isn’t what you wanted to happen. This means that you can enter your prompt, press Ctrl/Cmd+Enter once to generate the code, then again to accept it and it will automatically be run/reloaded for you. No muss, no fuss. No [mouses](https://en.wikipedia.org/wiki/Computer_mouse#:~:text=A computer mouse (plural mice,motion relative to a surface.) harmed in the creation of this feature.

Necessity is not the mother of invention; laziness is.” –J. Michael Sells (my dad)

Future Hopes & Dreams

The initial goal for adding generative AI features to DartPad was to do so with a simple one-and-done style prompt in a modal dialog instead of the multiple prompts of a chat-style UI. Plus, by adding the new functionality without interfering with any of the existing DartPad UI, we could test it first to see if anyone cared.

It’s already apparent that you care. And that you really don’t like the modal dialogs. Instead, you want the prompt and iterate style of a chat interface (aka vibe coding). Toward that end, personally I’d like to see DartPad move towards something like this in the future:

What do you think? How would you like DartPad to work wrt generative AI? Please drop your thoughts below!

April 2, 2025 flutter ai

Building the Flutter AI Toolkit

Building the Flutter AI Toolkit

It has been quite an exciting few years since I left Google in 2022. I had been on the Flutter team for 3 years by that point, helping it go from v1.0 to v3.0, from hundreds of thousands of users to millions. It was exciting, but time for a change. And boy did I get some! Over the next 3 years I did the following:

  • Worked on the developer experience for VR/AR/XR at Meta in their Reality Labs division.
  • Helped to ship the v1.0 for both the consumer and the enterprise versions of Cody from Sourcegraph, a long-time start-up with amazing code search capabilities. Seriously, if you haven’t tried Sourcegraph Code Search, you should. And if your company is using Code Search, their AI coding assistant Cody integrates with the same code. Give em both a try!
  • Retired from full-time employment after a 35-year software engineering career (and more than that if you count when I started writing code on my Apple ][+ at the age of 14).
  • Put up my shingle as an independent consultant specializing in applied AI.
  • Built the Flutter AI Toolkit.
  • Started giving Flutter talks again with Dart and Flutter and AI, Oh My! at FlutterCon in 2024 (you click the link to watch the talk).
  • Built the generative AI features in the latest version of DartPad.
  • Have built some other AI stuff too that hasn’t shipped yet. Stay tuned!

Even before I left the Flutter team, I was spending my spare time digging into AI. When I was at Meta, I was focused on xR, but again, spending my spare time on AI. And then ChatGPT flipped the table, so when the opportunity to work on AI in the developer tooling space came up, I couldn’t say no.”

In June of last year, I decided that I wanted to control my own time again, so I retired.” That’s not to say that I wanted to stop the work — I love what I do — it’s just that my pesky job was taking up all my time. As the VP of Product, I had all kinds of influence on what other folks did but no time to get my own hands dirty! Once I retired, of course, I wanted to spend time with Flutter; it’s got the best API, the best team and the best community in tech, and I’ve seen more than my share. But now I had a new focus: applied generative AI.

The first thing I did in this new area was work with the Flutter and Firebase teams on the creation and application of the Flutter AI Toolkit.

Hello, Flutter AI Toolkit

The goal of the Flutter AI Toolkit (or just AIT from now on) is that it provides an LLM chat widget that you can easily plug into your existing Flutter apps. The Flutter team shipped the initial version in December, 2024, which you can read all about in Amanda’s most excellent blog post on the topic. Since then, I’ve been taking user feedback to add new features and fix issues. The current version as I write this is 0.6.8 but I’m going to be shipping a 0.7.0 soon.

To get started, I’ll refer you to the flutter_ai_toolkit package README. Once you’ve done that, a minimal chat app in Flutter using Gemini 2.0 is as easy as this:

import 'package:flutter/material.dart';
import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart';
import 'package:google_generative_ai/google_generative_ai.dart';

void main() => runApp(_MainApp());

class _MainApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) => MaterialApp(
    home: Scaffold(
      body: LlmChatView(
        provider: GeminiProvider(
          model: GenerativeModel(
            model: 'gemini-2.0-flash',
            apiKey: 'GEMINI-API-KEY',
          ),
        ),
      ),
    ),
  );
}

There’s not much to this code, but it brings up some interesting points anyway:

  • The name of the chat widget is LlmChatView
  • It supports many options, but only one is required — a provider that connects your LLM to the features needed by the chat widget
  • There are two LLM providers that come out of the box: GeminiProvider and VertexProvider
  • These two providers both take a GenerativeModel instance provided by the Google AI Dart SDK and Vertex AI in Firebase Flutter packages respectively, enabling you to configure them fully as you choose.
  • The separation of the chat widget from the communication channel with the LLM allows you to plug in any LLM-like service you’d like.

If you run this code, you’ll get a very simple, but fully functional, cross-platform AI chat app. A slightly fancier version is hosted online that you can try right now. It looks like this by default:

Notice the suggestions and the welcome message, both of which you can specify as optional parameters to LlmChatView. Also notice the + button, which allows the user to upload an image or a file, and the mic button, which allows the user to provide voice input.

Custom Styling

Beyond the basic look n’ feel, the demo example also provides some styling options:

Out of the box, the Flutter AI Toolkit supports being hosted in MaterialApp or CupertinoApp. However, as of the pre-release status at the time of this writing, it does not automatically adapt to the current theme provided by either. Instead, the look n’ feel was designed by a Google UX designer to have its own modern style. And if you’d like to tailor it to your app, you have complete control (as you can see above). Check out the dark_mode and custom_styles examples for the details.

When I went to implement styling support, I took my inspiration for the styling support from the flutter_markdown package. Theming is great for when you’d like to have a lot of a widget in your app, e.g. you don’t want to set the styles for each Text widget — you want to use a theme for that. However, I figured that you’d have about one LlmChatView widget, so theming didn’t make as much sense as just passing in a style object like you can do with the Markdown widget.

That said, it’s an obviously missing feature to be able to add the ability to pick up on the current Material or Cupertino theme and set those as defaults for an LlmChatWidget. If this is something you’re passionate about, PRs are gladly considered!

Custom Response Widgets: Beyond Text

By default, the AIT shows LLM responses as Markdown:

But what if you want the AI to respond with something richer — like a card UI, a chart or a recipe? As an example, consider a recipes app that allows the user to maintain a database and to add to it via a conversation with an LLM. To be able to get the data out of the LLM to add to the database, you don’t want to scrape it out of Markdown — you want well-formed data that you can parse reliably. You can get that by configuring your model to output JSON. By default, that JSON would look like this when shown to the user:

Of course, this isn’t particularly useful for the user, so the AIT allows you to show your own Flutter widget, as the recipe example does:

In addition to showing the recipe in a way the user can read, the example also provides an Add Recipe button to allow the user to easily drop the recipe into their database, all from the comfort of their AI chat.

Pluggable LLMs

As big of a fan as I am of the Gemini models (2.5 pro experimental is blowing the doors off as I write this), different apps have different needs. Since I had to make LLMs pluggable to support Gemini and Vertex (both of which have their own API over the Gemini LLMs), I thought it would be useful to support the ability to talk to any LLM-like backend. And the community has responded with the flutter_ai_providers package. Together with what comes out of the box and what is provided by the community, the AIT supports the following models:

  • Google Gemini 1.x and 2.x, flash and pro, stable and experimental
  • OpenAI GPT and o series of models
  • Anthropic Claude series of models
  • 100s of locally hosted Ollama models, including Google Gemma3, QWQ, Deepseek, Llama3.x, etc.

If that isn’t enough for you, you can implement the LlmProvider interface and plug in anything you like. For debugging purposes, I built EchoProvider but my favorite use of this feature is hooking up the LlmChatView to flows that I define in Firebase Genkit.

Check out the AI Toolkit docs for more details, but it’s a pretty simple interface. Even the Gemini and Vertex AI providers are only ~150 LOC each.

Can Haz More?

I’ve just touched on some of the big pieces of the AIT; for the full details, I recommend the AI Toolkit docs. In addition, there are extensive feature-specific samples on GitHub.

For something a bit more end-to-end, I’ve also built out a full chat app using the AIT:

Notice the list of chats on the left. Those are stored in Firestore and the app provides full management of the history and name of each chat, so the user can rename them, delete them and create new ones. There’s even a little magic that automatically names each chat based on the first question the user answers, just for fun.

In addition, there’s a more fully-featured version of the recipe sample using Firebase for the storage of recipes as well as the Vertex AI LLM.

If you’re interested in other Flutter and Firebase AI samples, I’ll point you at the Quickdraw and Planning samples:

Where Are We?

I was not alone in building the AIT. In addition to the contributors on the repo (thank you, contributors!), I want to give a special shout-out to Rory Blyth. He’s been my friend for decades now and been working on generative AI since the GPT-1 era. He did a bunch of advance work on the mic input and the recipe sample as well as being my build buddy through the whole thing. Thanks, Rory!

It is amazing to me how much — and how quickly! — AI is changing the development tooling landscape. Hopefully you’ll find that the Flutter AI Toolkit makes it easy for you to integrate LLM chats into your Flutter apps. Customize the chat interface, display rich responses beyond text, and leverage community-driven providers for a myriad of LLM options. Dive into the docs, explore the samples, and don’t hesitate to contribute or report issues. Or just drop me a line; I’m super interested to see what you’re building!