Specs, Not Prompts: Building Flutter Apps with AI Agents, from Requirements to Code / Nov 6, 2026

12 min readBy Emmanuel Akinfulubi
Specs, Not Prompts: Building Flutter Apps with AI Agents, from Requirements to Code

This is the written version of my session at FlutterBytes Conference 2026 in Lagos, 6 and 7 November: "Spec-Driven Development: Using AI Agents to Build Flutter Apps from Requirements to Code". All code here is illustrative, kept minimal to show the idea. The PayButton widget and its test were run with flutter analyze and flutter test on Flutter 3.47.1 (Dart 3.13.1) and pass.

Specs. Not prompts.

This year's FlutterBytes theme is "Becoming A Flutter AI Engineer", and my session is an answer to one specific part of that: how to get an AI agent to build a Flutter feature you'd actually merge.

The one-line version: give your AI agent specs, not prompts.

The pipeline is four steps: requirements, design, tasks, code. Each one is a file in your repo. Each one narrows what the agent has to guess. By the time it writes Dart, the decisions that usually go wrong have already been made, by you, in writing.

This post follows the session in order: the problem, the fix, the pipeline, and what to take back to your own codebase.

The problem: vibe coding works until your app has 40 screens

Vibe coding works. You describe a screen, the agent writes it, you run it, it looks right. For a demo, a prototype or a weekend app, that's often enough.

Then your Flutter app has 40 screens, and the same workflow starts to break:

  • It rewrites your theme to fix a button. You asked for one padding change. The agent decided the cleanest fix was a new ThemeData.
  • State management drifts screen by screen. Provider here, setState there, a new Bloc nobody asked for.
  • There are no tests, so nothing is safe to change. Every fix might break something three screens away, and you won't know until a user tells you.
  • The diff is 900 lines and you approve it anyway. Because it's late, it compiles, and reviewing it properly would take longer than writing it yourself.

None of this means the agent is bad at Dart. It's usually very good at Dart. I told the same story as two git logs in Vibe Coding vs Spec-Driven Development.

The agent isn't dumb. Nobody gave it the plan.

Every prompt is a fresh guess about an app the agent can only partly see.

When you type "fix the checkout button", the agent has to infer a lot: which button, what "fixed" means, whether it's allowed to touch the theme, which state approach this feature uses, whether a loading state exists, what happens on failure, and how you'll know it's done. A small app survives those guesses because there isn't much to get wrong. A real one doesn't.

Look at what a typical prompt leaves out:

"Build me a checkout screen."

That's a wish. The agent fills every gap with a guess: double taps, loading states, error handling, where state lives, which packages to use. Some guesses will be right. You won't know which until you read every line.

The fix isn't a better prompt. It's writing down what you want before any code exists, in a form that can't be misread.

The fix: write it down first

One word does most of the work: SHALL.

Here's the same checkout behaviour, written as a requirement:

## Checkout: pay with card
WHEN the user taps Pay,
THE APP SHALL disable the button
and show a loader until the
payment succeeds or fails.

Compare that with "build me a checkout screen". This version names a trigger (the user taps Pay), a system (the app) and a response (disable the button, show a loader, until the payment succeeds or fails). It answers the double-tap question. It answers the loading-state question. It even says when the loading state ends.

That's an EARS requirement. Easy to build, easy to test.

EARS in five minutes

EARS stands for the Easy Approach to Requirements Syntax. It was developed by Alistair Mavin and colleagues at Rolls-Royce while analysing airworthiness regulations for a jet engine's control system, and first published in 2009 at the IEEE International Requirements Engineering Conference (official EARS guide; Mavin et al., RE'09).

The idea is to keep requirements in plain English, but constrain them with a small set of keywords and a fixed clause order. The generic shape is:

While <optional pre-condition>, when <optional trigger>, the <system name> shall <system response>

That gives a handful of patterns:

PatternKeywordShape
Ubiquitous(none)THE APP SHALL …
Event-drivenWHENWHEN <trigger>, THE APP SHALL …
State-drivenWHILEWHILE <state>, THE APP SHALL …
Optional featureWHEREWHERE <feature is included>, THE APP SHALL …
Unwanted behaviourIF / THENIF <trigger>, THEN THE APP SHALL …

Patterns can be combined (for example WHILE + WHEN) for richer behaviour.

Why this works so well with agents: each line has exactly one system, at most one trigger, and a response you can observe. That's close to the shape of a test. An agent reading WHEN the user taps Pay, THE APP SHALL disable the button has far less room to wander than one reading "make checkout nice". For more on writing them, not just for Flutter, see Writing Requirements an AI Coding Agent Can Build From.

A requirements.md excerpt

Here's what a short requirements.md for the checkout feature might look like. It's illustrative, not a full spec:

# requirements.md (excerpt, illustrative)
 
## 1. Checkout: pay with card
 
**User story:** As a shopper, I want to pay by card, so that I can complete my order.
 
### Acceptance criteria
 
1.1 WHEN the user taps Pay, THE APP SHALL disable the Pay button and show a loader
    until the payment succeeds or fails.
1.2 WHEN the payment succeeds, THE APP SHALL show the order confirmation screen.
1.3 IF the payment fails, THEN THE APP SHALL show the error message and re-enable
    the Pay button.
1.4 WHILE the device is offline, THE APP SHALL disable the Pay button and show
    "You're offline".
1.5 THE APP SHALL show the order total above the Pay button.

Each criterion is numbered. That number will follow it through the design, the tasks and the tests.

The pipeline: four tabs in your editor

Picture four tabs open in your editor: requirements.md, design.md, tasks.md and lib/. The active tab moves as the work does.

requirements.md: what it must do

What the feature must do, written as WHEN / SHALL statements. No "should probably", no "nice UI". Each line is one behaviour you could test. You write this, or you have the agent draft it and then you edit it hard. Either way, you sign it off before anything else happens.

design.md: how it fits your app

Widgets, state, data flow, packages. This is the file that stops the agent inventing a new architecture on every screen.

# design.md (excerpt, illustrative)
 
## 1. Checkout: pay with card
 
- Widget: `PayButton` (StatefulWidget) in `lib/checkout/pay_button.dart`
- Input: `Future<void> Function() onPay`, passed in by the checkout screen
- State: a local `_paying` flag. No new state-management package.
- Disabled = `onPressed: null` on the `ElevatedButton`
- Loader: `CircularProgressIndicator` inside the button while `_paying` is true
- Tests: `test/checkout/pay_button_test.dart`, one `testWidgets` per criterion

Notice what it settles: where the file lives, how the payment is injected (so the widget is testable), where state lives and which package is explicitly not allowed.

tasks.md: small steps the agent ticks off

The work, broken into small steps. Each task points back to the requirement it satisfies.

# tasks.md (excerpt, illustrative)
 
- [ ] 1. Create `PayButton` with an `onPay` callback and a "Pay" label
  - Requirements: 1.1
- [ ] 2. Disable the button and show a loader while `onPay` is running
  - Requirements: 1.1
- [ ] 3. Write the widget test for 1.1 in `test/checkout/pay_button_test.dart`
  - Requirements: 1.1
- [ ] 4. Show the error message and re-enable the button when `onPay` fails
  - Requirements: 1.3

Small tasks mean small diffs. Small diffs are diffs you actually read. That's how you stop approving 900 lines on trust.

lib/ + test/: the code, with a test per requirement

Only now does the agent write Flutter. It works one task at a time, and "done" means the matching test passes, not "it looks right in the emulator".

Prompt vs spec

Put the two side by side:

  • The prompt: "Build me a checkout screen."
  • The spec: requirements.md (WHEN / SHALL), design.md (widgets, state, data flow, packages), tasks.md (small steps), lib/ + test/ (code with a test per requirement).

Same agent. Same model. Very different app.

The agent and the model don't change between the two. The input does. That's the whole session in one line.

Three things to use on Monday

1. A requirements.md to copy

The excerpt above is the shape: a user story, then numbered EARS acceptance criteria covering the happy path (WHEN), the failure path (IF / THEN), states like offline (WHILE) and the always-true rules (plain SHALL).

Copy it, replace the feature, and keep the numbering. The point is not starting from a blank file.

2. A rules file that keeps the agent in line

A rules file is a project-level set of instructions the agent reads before it works. Where it lives depends on your tool, so check its docs. The content is the same idea everywhere: architecture, packages and no-go files, written down once so you don't repeat them in every prompt.

# Project rules (excerpt, illustrative)
 
## Architecture
- Feature folders under lib/<feature>/. Widgets, state and services stay in their feature.
- State: use what the feature already uses. Don't add a state-management package.
 
## Packages
- Only packages already in pubspec.yaml. Ask before adding one.
 
## No-go files
- Don't edit lib/theme/ or any generated *.g.dart file.
- Don't edit pubspec.lock by hand.
 
## Definition of done
- Every acceptance criterion in requirements.md has a passing widget test.
- `flutter analyze` and `flutter test` pass.

Look back at the four things that break at 40 screens. "Don't edit lib/theme/" handles the rewritten theme. "Don't add a state-management package" handles the drift. "Definition of done" handles the missing tests. Small tasks in tasks.md handle the 900-line diff.

3. A workflow from criteria to widget tests

Every requirement proven, not hoped. Each acceptance criterion becomes a widget test, and the test's name carries the criterion number. Here's requirement 1.1 end to end.

The widget (lib/checkout/pay_button.dart, illustrative):

import 'package:flutter/material.dart';
 
/// Illustrative: the smallest widget that satisfies requirement 1.1.
class PayButton extends StatefulWidget {
  const PayButton({super.key, required this.onPay});
 
  /// Runs the payment. The button stays disabled until this completes.
  final Future<void> Function() onPay;
 
  @override
  State<PayButton> createState() => _PayButtonState();
}
 
class _PayButtonState extends State<PayButton> {
  bool _paying = false;
 
  Future<void> _handleTap() async {
    setState(() => _paying = true);
    try {
      await widget.onPay();
    } finally {
      // Success or failure, the loader goes away (requirement 1.1, "until").
      if (mounted) setState(() => _paying = false);
    }
  }
 
  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      key: const Key('pay-button'),
      onPressed: _paying ? null : _handleTap, // null = disabled
      child: _paying
          ? const SizedBox.square(
              dimension: 20,
              child: CircularProgressIndicator(strokeWidth: 2),
            )
          : const Text('Pay'),
    );
  }
}

The test (test/checkout/pay_button_test.dart, illustrative):

import 'dart:async';
 
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/checkout/pay_button.dart';
 
void main() {
  testWidgets('1.1 WHEN the user taps Pay, the button is disabled and a loader shows',
      (WidgetTester tester) async {
    // A payment we control: it stays "in flight" until we complete it.
    final payment = Completer<void>();
 
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: PayButton(onPay: () => payment.future),
        ),
      ),
    );
 
    await tester.tap(find.text('Pay'));
    await tester.pump(); // one frame; pumpAndSettle would wait on the spinner forever
 
    final button = find.byKey(const Key('pay-button'));
    expect(tester.widget<ElevatedButton>(button).onPressed, isNull);
    expect(find.byType(CircularProgressIndicator), findsOneWidget);
 
    // "...until the payment succeeds or fails": finish it and check we recover.
    payment.complete();
    await tester.pump();
 
    expect(find.byType(CircularProgressIndicator), findsNothing);
    expect(tester.widget<ElevatedButton>(button).onPressed, isNotNull);
  });
}

A few choices worth explaining:

  • Completer<void> as the payment. The test decides when the payment finishes, so it can check the in-flight state and then the recovered state. No real network, no timers.
  • tester.pump(), not pumpAndSettle(). A CircularProgressIndicator animates until it's removed, so the frame never "settles". One pump() is enough to render the loading state.
  • find.byKey for the button. Once you tap, the "Pay" label is replaced by the spinner, so finding by text would miss it. A key keeps the finder stable.
  • onPressed == null means disabled. A Material button with no onPressed handler is rendered and treated as disabled, which is exactly what 1.1 asks for.
  • The test name starts with "1.1". When it fails, you know which requirement broke without opening the file.

This sketch proves 1.1 only. Requirement 1.3 (the error message on failure) is task 4 and gets its own test. That's the pattern: one criterion, one test, and the agent isn't finished with a task until its test passes.

Swap my_app for your package name if you try it.

Which agent?

I'm deliberately not naming one. The workflow is the point. The files are plain Markdown and plain Dart, so they work with whichever agent your team already uses, and they stay useful in the repo after the agent is gone.

If you're a Flutter developer who has been fighting your AI agent, pick the feature that keeps going wrong and write its requirements.md before the next prompt. That one file changes more than any model upgrade.

Specs. Not prompts.

Sources

Frequently asked questions

What is spec-driven development for Flutter?
Writing down what a feature must do, how it fits your app and the order to build it in, as files in your repo, before an AI agent writes any Dart. The agent then works one small task at a time, and each acceptance criterion is proven by a widget test.
What is EARS?
The Easy Approach to Requirements Syntax, developed by Alistair Mavin and colleagues at Rolls-Royce and first published in 2009. It keeps requirements in plain English but gives every line one system, at most one trigger and a response you can observe, using a few keywords: WHEN, WHILE, IF THEN and WHERE.
Which AI agent does this work with?
Any of them. The files are plain Markdown and plain Dart, so they work with whichever agent your team uses, and they stay useful in the repo after the agent is gone. Only the location of the rules file differs between tools.
Why use pump instead of pumpAndSettle when testing a loading state?
A CircularProgressIndicator keeps animating until it is removed, so the frame never settles and pumpAndSettle times out. A single pump renders the loading state so you can assert on it.