Chris Sells

Hi, My name is chris sells

I’m the founder and chief proprietor of sellsbrothers.com, the web site HQ for Sells Brothers, Inc. I'm a consultant in applied AI for developer tools, frameworks and ecosystems as well as a Flutter fanatic.

Read More About
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…

July 1, 2025 flutter

Critical Flutter OSS Projects Need Love! (aka Funding)

Critical Flutter OSS Projects Need Love! (aka Funding)

In a recent tweet, Dinko makes an excellent point about much of our Dart ecosystem being comprised of hobby project[s]” and having limited support.” He’s not wrong and that applies to Flutter as well. Most of the Flutter ecosystem is driven by passionate OSS developers. And that passion drives different devs to do different things — some like the code, some like the samples, some like the docs, some like the support and some just like to publish a package right out of flutter create and then move on with their day.

Most Dart packages are a hobby project or have limited support

It is unfortunately true that few packages get professional-quality care. For that, you typically need money.

Flutter Favorites

The Flutter Favorite program was invented to give recognition to developers building high-quality packages that really serve the Dart and Flutter community. And it worked. When I was on the team, the Flutter Favorites program contributed to both the number of packages on pub.dev and the average quality to increase. These are not metrics that typically go together.

Unfortunately, I believe that we’ve reached a quality plateau on pub.dev. And I think I know why.

The Need for Flutter Funding

One of the jobs of the Flutter Favorites committee was to provider encouragement and help for package authors on the cusp of meeting the quality bar for a Flutter Favorite candidate package. As one example, I remember reaching out to the author of a popular BLE package that needed some work. There was some missing functionality and the overall package didn’t meet the quality bar, but it was a great start and clearly filling a real need in the community. The author told me that he’d love to be a Flutter Favorite, but he was too busy making a living to dedicate the time. If we had perhaps had a small amount of funding…

Unfortunately, there was no funding for such things at the time. And except for the FlutterFlow Flutter Funding program (which sounds great, btw, but may be biased towards projects that overly favor FlutterFlow), there is no broadly-focused organization whose job it is to fund engineering projects in the Dart and Flutter ecosystem.

I would love to see more of this – Companies resurrecting important, under maintained packages.

As Eric Seidel pointed out recently, many critical packages in our ecosystem are showing their age. When you look at the data — packages like google_sign_in with 31 open issues, flutter_barcode_scanner with 201 open issues and no updates in 3 years — it’s clear that we have a sustainability problem. The community is willing to help, but maintainers need time, and time costs money.

I have an idea.

Proposal: Flutter Funding Committee

I’ve been working on a proposal to create the Flutter Funding Committee (FFC) — a non-profit organization designed to fill exactly these funding gaps. The idea is simple: take donations from companies deeply invested in the flourishing Dart and Flutter ecosystem and use that money to fund important community projects that would otherwise fall through the cracks. Think of it as an escape valve” that allows the Dart and Flutter team to move maintenance work off their plate without dropping any balls (to shake a couple of metaphors together with a swizzle stick).

The committee would fund things like:

  • Maintenance and improvement of critical pub.dev packages that are widely used but under-resourced
  • Bug fixes and improvements in Flutter/Dart that are important to the broader ecosystem but not on the current roadmap
  • New tools, packages or plugins that fill gaps in the ecosystem
  • Bounties for targeted issues or enhancements requested by the community

But here’s the key differentiator from similar foundations like Python Software Foundation or the OpenJS Foundation: the FFC explicitly would NOT take over technical governance. The FCC would be purely a funding mechanism to support the ecosystem around that core.

Projects We Could Fund

So what would this look like in practice? Let me give you some concrete examples of the kinds of projects the FFC could tackle:

Emerging Tech Integration: Want to see Flutter work better with emerging technologies? Let’s fund someone to build proper WebAssembly integration, or an agentic toolkit like the cool Python kids have, but for Dart!

Critical Package Maintenance: Remember that BLE package I mentioned? Or google_sign_in. Or another package near and dear to my heart go_router. It’s currently in maintainence mode, but 257 (!) open issues with almost 2M downloads. This is a package in need of some love.


go_router in maintenance mode


Desktop Feature Gaps: Flutter’s desktop support is solid but still has gaps that the core team hasn’t prioritized. Things like system-level menus, OS integration features or platform-specific UI components that would make Flutter desktop apps feel truly native.

And the list goes on…

The beauty of the FFC approach is that the companies funding these projects would get to vote on what gets priority. If you’re a company building Flutter apps for enterprise customers, you might prioritize desktop features. If you’re focused on mobile, maybe you care more about social auth. The committee would work together to decide.

Where Are We?

This isn’t just a pipe dream. I’ve already had conversations with key players in the Flutter ecosystem, and the response has been larely positive (although nobody has written a check yet : ). Companies that depend on Flutter for their business understand the value proposition immediately — instead of each of them solving ecosystem problems in isolation, they can pool resources and tackle the big issues together.

What I find most exciting about this approach is that it builds on the proven success of the Flutter Favorites program. We know that recognition and quality standards work. Now we’re just adding the missing piece: funding to make those standards achievable for maintainers who care but can’t afford to work for free.

The Flutter ecosystem has grown up. We’ve got millions of developers, thousands of companies betting their businesses on Flutter, and a package ecosystem that rivals any platform out there. It’s time our funding mechanisms grew up too.

If you’re interested in this idea — whether as a potential funding company, a maintainer who could benefit, or just someone who cares about Flutter’s long-term health — I’d love to hear from you. Because the future of Flutter isn’t just about what Google builds; it’s about what we build. Together.

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 17, 2025 tools

How to Write Actionable Bug Reports

How to Write Actionable Bug Reports

I often find myself maintaining open source projects. I’ve contributed to many over the years, even hosting multi-person source available” projects on this very website in zip files before there were such things as GitHub or Pull Requests or Open Source Software. No matter what mechanism I’m using to manage the project, if it’s popular, I get issues filed against that project. Those issues break down into two categories:

  1. Feature Requests
  2. Bug Reports

Feature Requests: Include Why

If it’s a feature request, I’ll ask that you include why you’re asking for a feature as well as what you’re asking for. It’s pretty typical for an engineer to have a problem, wish for a particular solution and then ask for it. However, it’s often the case that someone using the project doesn’t quite have the entire architecture in mind when they ask for a solution, which means that the requested solution might not fit in well with the rest of the project.

On the other hand, if you can include why you’re asking for a specific solution, i.e. what problem are you trying to solve, then the maintainer of the project can say no, but…” with an alternative solution that does fit in with the architecture.

A good maintainer will ask why before saying no, but if you provide the why out of the gate, you could save yourself a round trip in the issue’s comments.

Bug Reports: Include a Minimal Repo Project

If the project in question does something that you thought the project should already do but it doesn’t work the way you expect, e.g. sets fire to your garage, then that’s a bug report. For a bug report to be useful, it has to be actionable.

More sophisticated projects will include a template that asks pertinent questions like:

  • What behavior did you see?
  • What behavior did you expect to see?
  • What error message did you get?

However, for me, the biggest thing that makes a bug report actionable is the inclusion of a minimal reproduction project. Each one of the words in that phrase is important and we’ll go through them back to front:

  • Project: I’m looking for the code for an entire project, ideally in a zip file, that I can download, unzip, load up into my IDE, resolve dependencies and run. The project should have everything in there that I need or, if there is something I need to add, like an API key, there should be a clear and prominent comment that makes that clear.

    In a pinch, I’ll take a single source code file where the rest of the project is implied, but it needs to be self-contained and complete.

    The goal is that I can take your code and run it with no muss or fuss. The more popular the project I’m working on is, the more bug reports I’m going to get and the less time I have to spend on each one. Please make it quick and easy for me to run the code that demonstrates your issue, which brings me to…

  • Reproduction: The code you send me should reproduce your issue! And it should put it in my face loud and proud. If it’s an intermittent problem and I can’t get it to happen with a reasonable number of runs, then there’s not a lot I can do to fix it. The goal is to make it clear what the issue is and if it’s not, I’m going to have to drop a comment into the conversation and move on.

  • Minimal: Please please please do not copy and paste your personal or — even worse — your company’s proprietary code into a project issue. I don’t want to see that stuff! And there’s often so so much of it! I want you to spend time removing anything domain-specific and trimming out the code that does not contribute to the problem. I do this for several reasons:

    1. I don’t want your lawyers suing me for someday building something that is similar to something you once posted in a bug on one of my projects.
    2. I want you to have done the legwork to verify that the problem is in my code and not yours. If you trim away the extraneous, then you’ll either figure out that it’s your problem or mine.
    3. If the problem is mine, your minimal repro project is going to make it clear to me that it is my problem, because you’ve cleared away everything that isn’t contributing to the issue. And this helps me to fix it more quickly.

Just One Example

As an example, I saw a bug report recently on the Flutter AI Toolkit, a project I’m working on with the Flutter team. A very nice person posted an issue which seemed like a bug to me, if it’s true, so I wanted to get to work on it right away. However, the bug wasn’t actionable because it didn’t have a minimal repro project, so I posted a comment asking for one.

The person responded quickly with a repro project, but I suspected that it was not minimal because it had domain-specific details in it and there were style settings used. The style settings were a dead giveaway because the issue had nothing to do with look n’ feel. So I asked the person to work on the minimal” part of the minimal reproduction project” and moved on to my next task.

And then they got back to me with a minimal repro project so I could get to work on their issue.

Where Are We?

I am a huge fan of people reporting issues in my projects. Every one of them is an opportunity to make it more useful for a wider set of people, which is why I enjoy working on OSS projects in the first place. If you don’t post issues that are actionable, that’s OK — I’ll ask for the info I need. But if you’d like to move things along a little bit more quickly for yourself, you work put yourself in the maintainer’s shoes and provide the same info you’d need if you were going to fix the issue yourself.

Or, even better, consider a PR. : )


Older Entries →