Skip to content

Configure

Most "it cannot find my button" problems are a missing line of configuration, not a bug. Find your symptom, add the fix, hot restart.

Start from the symptom

SymptomFix
Your own controls are missing from the listing Add them to isInteractiveElement.
tap(text:) cannot find a custom label Return its text from extractText.
Custom-painted text, badges or charts are invisible Wrap them in Semantics(label:, value:).
get_logs explains how to set up a collector Set logCollector.
set_device_config answers with setup instructions Add MarionetteDeviceConfig, then hot restart.
Content inside one of your widgets is out of reach Remove that widget from shouldStopTraversalAtElement.

What works as is

With no configuration, Marionette recognizes Flutter's own interactive widgets, their subclasses (such as the button ElevatedButton.icon returns) and their generic variants:

ButtonStyleButton (Elevated, Filled, Outlined and Text buttons), IconButton, FloatingActionButton, TextField, TextFormField, Checkbox, Radio, Switch, Slider, DropdownButton, PopupMenuButton, their list-tile forms, GestureDetector and InkWell.

Text is read from Text, RichText, EditableText and the text fields. A field’s labelText or hintText is reported as its label, so a field without a key still has a name.

The configuration

MarionetteConfiguration is the optional argument of MarionetteBinding.ensureInitialized. Every field is optional, and your callbacks run after the built-in checks: they extend the defaults, they do not replace them.

FieldDefaultPurpose
isInteractiveElement null Mark your own widgets as interactive.
extractText null Return the text your widgets show.
logCollector null Collect the logs get_logs returns.
enableSessionReports false Record each run in .marionette/sessions/.
compaction CompactionMode.compact How much the element listing leaves out.
maxScreenshotSize Size(2000, 2000) Downscale screenshots to fit; null keeps full size.
shouldStopTraversalAtElement null Skip what lies inside a widget. Rarely needed.

Interactive widgets

A screen has hundreds of widgets; the agent gets the few it can act on. Your design system’s widgets are not on the built-in list, so mark them. The callback gets the Element, so one switch over element.widget describes a whole design system.

lib/main.dart
MarionetteConfiguration(
  isInteractiveElement: (element) => switch (element.widget) {
    MyButton() || MyTextField() => true,
    MyTile(:final onTap) => onTap != null,
    _ => false,
  },
)
  • A pattern such as MySelect() matches MySelect<String> and any type argument added later.
  • MyButton() matches every subclass, such as MyPrimaryButton.
  • Read fields for per-instance decisions: a MyTile is interactive only when it has an onTap.
  • Use the element as a BuildContext when placement matters, e.g. findAncestorWidgetOfExactType.

Text

extractText does two jobs. Widgets with text appear in the listing with that text, so the agent knows what each one shows. And tap and scroll_to can then match them by label. enter_text targets fields by key or identifier, or the field focused with a tap.

lib/main.dart
MarionetteConfiguration(
  extractText: (element) => switch (element.widget) {
    MyText(:final data) => data,
    MyTextField(:final labelText) => labelText,
    _ => null,
  },
)

Semantics

Text.rich and RichText are read without help. For text that never reaches a Text widget, such as CustomPaint, TextPainter or WidgetSpan content, use the annotation screen readers already rely on.

label and value
// The screen reader says "Volume, 70 percent";
// the agent sees  Type: Semantics, Text: "Volume: 70%".
Semantics(
  label: 'Volume',
  value: '70%',
  child: CustomPaint(painter: _VolumeBarPainter(level: 0.7)),
)

label and value are for reading only: the matcher ignores them, so a Semantics(label: "Submit") never steals a tap from the button inside it. To target a widget without a key, use identifier, which the matcher does read and screen readers never announce.

identifier
// No ValueKey, but a stable target: tap(identifier: "submit_button").
Semantics(
  identifier: 'submit_button',
  child: ElevatedButton(onPressed: _submit, child: const Text('Submit')),
)

Text(semanticsIdentifier:) works the same way. A TextSpan.semanticsIdentifier does not: it lives below the widget tree, so wrap that run of text in its own Semantics(identifier:) instead.

Traversal

To list the screen, Marionette walks the widget tree from the root down. shouldStopTraversalAtElement tells it not to look inside a widget: the widget itself is still listed, everything below it is not.

Use it forNever use it for
A composite widget that already reports itself, such as a select whose extractText returns its label: its inner text and buttons would only repeat it. Lists, scroll views and whole pages. The agent cannot see, tap or scroll_to anything inside them.

Logs

get_logs returns what the app logged since it started or since the last hot reload: the quickest proof that a request fired or an exception was thrown. Pick the adapter for how your app logs.

terminal
flutter pub add marionette_logging
lib/main.dart
MarionetteBinding.ensureInitialized(
  MarionetteConfiguration(logCollector: LoggingLogCollector()),
);
Logger.root.level = Level.ALL;
terminal
flutter pub add marionette_logger
lib/main.dart
final logCollector = LoggerLogCollector();
MarionetteBinding.ensureInitialized(
  MarionetteConfiguration(logCollector: logCollector),
);
// The collector is also a LogOutput, so one object serves both sides.
final logger = Logger(output: MultiOutput([ConsoleOutput(), logCollector]));
lib/main.dart
final collector = PrintLogCollector();
MarionetteBinding.ensureInitialized(
  MarionetteConfiguration(logCollector: collector),
);
myLogger.onLog((message) => collector.addLog(message));

Device config

set_device_config overrides text scale, bold text and light or dark mode in the running app. It is the one tool that needs a widget in your tree, because Marionette never inserts one behind your back. Hot restart after adding it: main() does not re-run on a hot reload.

lib/main.dart
void main() {
  if (!kReleaseMode) {
    MarionetteBinding.ensureInitialized();
    runApp(const MarionetteDeviceConfig(child: MyApp()));
  } else {
    WidgetsFlutterBinding.ensureInitialized();
    runApp(const MyApp());
  }
}

!kReleaseMode is a compile-time constant, so neither the binding nor the widget reaches a release build. Without a binding the widget builds its child unchanged. When reading results, note that some Material widgets clamp their own text scaling, and scales far above 3.0 mostly produce overflow errors, not signal.

Custom extensions

Give the agent actions the generic tools cannot express: go to a route by name, seed test data, flip a feature flag. An extension that declares an inputSchema becomes a named tool with validated arguments, so a bad value is rejected before it reaches your app.

lib/main.dart
registerMarionetteExtension(
  name: 'appNavigation.goToPage',
  description: 'Navigates to a page by name.',
  inputSchema: ExtensionInputSchema(
    properties: {
      'page': ExtensionParam.string(
        description: 'The page to navigate to.',
        enumValues: ['home', 'profile', 'settings'],
      ),
    },
    required: ['page'],
  ),
  callback: (params) async {
    final page = params['page'];
    if (page == null) {
      return MarionetteExtensionResult.invalidParams('Missing page');
    }
    navigateTo(page); // your app's own navigation
    return MarionetteExtensionResult.success({'page': page});
  },
);
Extension nameTool name the agent sees
appNavigation.goToPage app_navigation_go_to_page
analytics.flush analytics_flush

Tool names are reduced to [a-z0-9_-], the set every LLM API accepts. Parameters are flat scalars, because arguments travel over the VM service as strings. An extension without a schema still works through call_custom_extension, and list_custom_extensions shows them all.

A complete main.dart

lib/main.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:marionette_flutter/marionette_flutter.dart';
import 'package:marionette_logging/marionette_logging.dart';

void main() {
  if (!kReleaseMode) {
    MarionetteBinding.ensureInitialized(
      MarionetteConfiguration(
        // 1. Your interactive widgets.
        isInteractiveElement: (element) => switch (element.widget) {
          MyButton() || MyTextField() => true,
          _ => false,
        },
        // 2. Their text, so the agent can match by label.
        extractText: (element) => switch (element.widget) {
          MyText(:final data) => data,
          _ => null,
        },
        // 3. Logs for get_logs.
        logCollector: LoggingLogCollector(),
        // 4. A report for every run.
        enableSessionReports: true,
      ),
    );
    Logger.root.level = Level.ALL;
    // 5. set_device_config, opted in with one widget.
    runApp(const MarionetteDeviceConfig(child: MyApp()));
  } else {
    runApp(const MyApp());
  }
}