Skip to content

Concepts

The few ideas that explain every tool: what the agent sees, how it picks a target, and why the app runs in debug.

How it works

Agent

calls tap(key: "save")

MCP or shell
Bridge

translates it into a VM service call

VM service
Binding

runs ext.flutter.marionette.tap in the app

gesture
App

gets a real pointer event

MarionetteBinding registers a set of VM service extensions in your app. On connect, the bridge checks that the app runs the same Marionette version. From then on every tool call becomes one extension call: the app performs the action and answers with a short result, which goes back to the agent.

Gestures are dispatched as pointer events, so the app reacts the way it would to a finger: hit tests, gesture arenas and focus all apply. Nothing in your widgets changes.

How the agent sees a screen

get_interactive_elements walks the widget tree and returns a flat list in tree order: every widget a user can act on, every widget with text, and every Semantics with a label or value. Each entry carries its type, key, text, Semantics identifier and bounds, so the agent can name a target and knows where it is.

Below is one real screen of the example app and the list the agent received for it. Point at an entry to find it on the screen; switch to the full payload to see what compaction leaves out.

The Custom Widgets screen of the example app
Type: Text, Key: "last_action", Text: "Last action: none", bounds: {"x":16,"y":96,"width":379,"height":24}
Type: Text, Text: "Design system", bounds: {"x":16,"y":136,"width":379,"height":20}
Type: DsPrimaryButton, Key: "ds_primary_button", bounds: {"x":16,"y":164,"width":90,"height":44}
Type: GestureDetector, bounds: {"x":16,"y":164,"width":90,"height":44}
Type: Text, Text: "Primary", bounds: {"x":36,"y":176,"width":50,"height":20}
Type: DsSecondaryButton, Key: "ds_secondary_button", bounds: {"x":114,"y":164,"width":108,"height":44}
Type: GestureDetector, bounds: {"x":114,"y":164,"width":108,"height":44}
Type: Text, Text: "Secondary", bounds: {"x":134,"y":176,"width":68,"height":20}
Type: DsSelect<String>, Key: "ds_size_select", Text: "Size: Medium", bounds: {"x":16,"y":216,"width":131,"height":38}
Type: DsSelect<int>, Key: "ds_quantity_select", Text: "Quantity: 1", bounds: {"x":155,"y":216,"width":113,"height":38}
Type: DsTile, bounds: {"x":16,"y":254,"width":379,"height":48}
Type: GestureDetector, bounds: {"x":16,"y":254,"width":379,"height":48}
Type: Text, Text: "Tappable tile", bounds: {"x":16,"y":268,"width":355,"height":20}
Type: RichText, Text: "", bounds: {"x":371,"y":266,"width":24,"height":24}
Type: Text, Text: "Static tile", bounds: {"x":16,"y":314,"width":379,"height":20}
Type: Text, Text: "Built-in generic widgets", bounds: {"x":16,"y":362,"width":379,"height":20}
Type: DropdownButton<String>, Key: "fruit_dropdown", bounds: {"x":16,"y":382,"width":79,"height":48}
Type: InkWell, bounds: {"x":16,"y":382,"width":79,"height":48}
Type: GestureDetector, bounds: {"x":16,"y":382,"width":79,"height":48}
Type: Text, Text: "Apple", bounds: {"x":16,"y":394,"width":42,"height":24}
Type: RichText, Text: "", bounds: {"x":71,"y":394,"width":24,"height":24}
Type: PopupMenuButton<String>, Key: "actions_menu", bounds: {"x":330,"y":388,"width":65,"height":36}
Type: InkWell, bounds: {"x":330,"y":388,"width":65,"height":36}
Type: GestureDetector, bounds: {"x":330,"y":388,"width":65,"height":36}
Type: Text, Text: "Actions", bounds: {"x":338,"y":396,"width":49,"height":20}
Type: Radio<int>, Key: "radio_1", bounds: {"x":16,"y":430,"width":48,"height":48}
Type: Text, Text: "One", bounds: {"x":64,"y":444,"width":26,"height":20}
Type: Radio<int>, Key: "radio_2", bounds: {"x":90,"y":430,"width":48,"height":48}
Type: Text, Text: "Two", bounds: {"x":138,"y":444,"width":27,"height":20}
Type: RadioListTile<int>, Key: "radio_tile_3", bounds: {"x":16,"y":478,"width":379,"height":56}
Type: InkWell, bounds: {"x":16,"y":478,"width":379,"height":56}
Type: GestureDetector, bounds: {"x":16,"y":478,"width":379,"height":56}
Type: Radio<int>, bounds: {"x":32,"y":486,"width":40,"height":40}
Type: Text, Text: "Three", bounds: {"x":88,"y":494,"width":283,"height":24}
Type: ElevatedButton, Key: "icon_button", enabled: "true", bounds: {"x":16,"y":534,"width":123,"height":48}
Type: BackButton, bounds: {"x":4,"y":28,"width":48,"height":48}
Type: Text, Text: "Custom Widgets", bounds: {"x":72,"y":38,"width":162,"height":28}
Type: GestureDetector, bounds: {"x":0,"y":810,"width":137,"height":80}
Type: RichText, Text: "", bounds: {"x":57,"y":828,"width":24,"height":24}
Type: Text, Text: "Home", bounds: {"x":51,"y":860,"width":34,"height":16}
Type: GestureDetector, bounds: {"x":137,"y":810,"width":137,"height":80}
Type: RichText, Text: "", bounds: {"x":194,"y":828,"width":24,"height":24}
Type: Text, Text: "Profile", bounds: {"x":186,"y":860,"width":39,"height":16}
Type: GestureDetector, bounds: {"x":274,"y":810,"width":137,"height":80}
Type: RichText, Text: "", bounds: {"x":331,"y":828,"width":24,"height":24}
Type: Text, Text: "Settings", bounds: {"x":319,"y":860,"width":48,"height":16}
Type: Text, Key: "last_action", Text: "Last action: none", data: "Last action: none", inherit: "false", family: "Roboto", size: "16.0", letterSpacing: "0.15", baseline: "TextBaseline.alphabetic", height: "1.5", leadingDistribution: "TextLeadingDistribution.even", bounds: {"x":16.0,"y":96.0,"width":379.42857142857144,"height":24.0}, visible: true
Type: Text, Text: "Design system", data: "Design system", inherit: "false", family: "Roboto", size: "14.0", letterSpacing: "0.1", baseline: "TextBaseline.alphabetic", height: "1.43", leadingDistribution: "TextLeadingDistribution.even", bounds: {"x":16.0,"y":136.0,"width":379.42857142857144,"height":20.0}, visible: true
Type: DsPrimaryButton, Key: "ds_primary_button", bounds: {"x":16.0,"y":164.0,"width":90.1142578125,"height":44.0}, visible: true
Type: GestureDetector, startBehavior: "DragStartBehavior.start", bounds: {"x":16.0,"y":164.0,"width":90.1142578125,"height":44.0}, visible: true
Type: Text, Text: "Primary", data: "Primary", inherit: "true", bounds: {"x":36.0,"y":176.0,"width":50.1142578125,"height":20.0}, visible: true
Type: DsSecondaryButton, Key: "ds_secondary_button", bounds: {"x":114.1142578125,"y":164.0,"width":108.0322265625,"height":44.0}, visible: true
Type: GestureDetector, startBehavior: "DragStartBehavior.start", bounds: {"x":114.1142578125,"y":164.0,"width":108.0322265625,"height":44.0}, visible: true
Type: Text, Text: "Secondary", data: "Secondary", inherit: "true", bounds: {"x":134.1142578125,"y":176.0,"width":68.0322265625,"height":20.0}, visible: true
Type: DsSelect<String>, Key: "ds_size_select", Text: "Size: Medium", bounds: {"x":16.0,"y":216.0,"width":130.78125,"height":38.0}, visible: true
Type: DsSelect<int>, Key: "ds_quantity_select", Text: "Quantity: 1", bounds: {"x":154.78125,"y":216.0,"width":113.3662109375,"height":38.0}, visible: true
Type: DsTile, bounds: {"x":16.0,"y":254.0,"width":379.42857142857144,"height":48.0}, visible: true
Type: GestureDetector, startBehavior: "DragStartBehavior.start", bounds: {"x":16.0,"y":254.0,"width":379.42857142857144,"height":48.0}, visible: true
Type: Text, Text: "Tappable tile", data: "Tappable tile", bounds: {"x":16.0,"y":268.0,"width":355.42857142857144,"height":20.0}, visible: true
Type: RichText, Text: "", textAlign: "TextAlign.start", textDirection: "TextDirection.ltr", softWrap: "true", overflow: "TextOverflow.visible", textWidthBasis: "TextWidthBasis.parent", bounds: {"x":371.42857142857144,"y":266.0,"width":24.0,"height":24.0}, visible: true
Type: Text, Text: "Static tile", data: "Static tile", bounds: {"x":16.0,"y":314.0,"width":379.42857142857144,"height":20.0}, visible: true
Type: Text, Text: "Built-in generic widgets", data: "Built-in generic widgets", inherit: "false", family: "Roboto", size: "14.0", letterSpacing: "0.1", baseline: "TextBaseline.alphabetic", height: "1.43", leadingDistribution: "TextLeadingDistribution.even", bounds: {"x":16.0,"y":362.0,"width":379.42857142857144,"height":20.0}, visible: true
Type: DropdownButton<String>, Key: "fruit_dropdown", bounds: {"x":16.0,"y":382.0,"width":78.6890640258789,"height":48.0}, visible: true
Type: InkWell, bounds: {"x":16.0,"y":382.0,"width":78.6890640258789,"height":48.0}, visible: true
Type: GestureDetector, startBehavior: "DragStartBehavior.start", bounds: {"x":16.0,"y":382.0,"width":78.6890640258789,"height":48.0}, visible: true
Type: Text, Text: "Apple", data: "Apple", bounds: {"x":16.0,"y":394.0,"width":41.85443115234375,"height":24.0}, visible: true
Type: RichText, Text: "", textAlign: "TextAlign.start", textDirection: "TextDirection.ltr", softWrap: "true", overflow: "TextOverflow.visible", textWidthBasis: "TextWidthBasis.parent", bounds: {"x":70.6890640258789,"y":394.0,"width":24.0,"height":24.0}, visible: true
Type: PopupMenuButton<String>, Key: "actions_menu", bounds: {"x":330.29185267857144,"y":388.0,"width":65.13671875,"height":36.0}, visible: true
Type: InkWell, bounds: {"x":330.29185267857144,"y":388.0,"width":65.13671875,"height":36.0}, visible: true
Type: GestureDetector, startBehavior: "DragStartBehavior.start", bounds: {"x":330.29185267857144,"y":388.0,"width":65.13671875,"height":36.0}, visible: true
Type: Text, Text: "Actions", data: "Actions", bounds: {"x":338.29185267857144,"y":396.0,"width":49.13671875,"height":20.0}, visible: true
Type: Radio<int>, Key: "radio_1", bounds: {"x":16.0,"y":430.0,"width":48.0,"height":48.0}, visible: true
Type: Text, Text: "One", data: "One", bounds: {"x":64.0,"y":444.0,"width":25.537109375,"height":20.0}, visible: true
Type: Radio<int>, Key: "radio_2", bounds: {"x":89.537109375,"y":430.0,"width":48.0,"height":48.0}, visible: true
Type: Text, Text: "Two", data: "Two", bounds: {"x":137.537109375,"y":444.0,"width":27.21875,"height":20.0}, visible: true
Type: RadioListTile<int>, Key: "radio_tile_3", bounds: {"x":16.0,"y":478.0,"width":379.42857142857144,"height":56.0}, visible: true
Type: InkWell, bounds: {"x":16.0,"y":478.0,"width":379.42857142857144,"height":56.0}, visible: true
Type: GestureDetector, startBehavior: "DragStartBehavior.start", bounds: {"x":16.0,"y":478.0,"width":379.42857142857144,"height":56.0}, visible: true
Type: Radio<int>, bounds: {"x":32.0,"y":486.0,"width":40.0,"height":40.0}, visible: true
Type: Text, Text: "Three", data: "Three", bounds: {"x":88.0,"y":494.0,"width":283.42857142857144,"height":24.0}, visible: true
Type: ElevatedButton, Key: "icon_button", enabled: "true", bounds: {"x":16.0,"y":534.0,"width":123.26484298706055,"height":48.0}, visible: true
Type: BackButton, bounds: {"x":4.0,"y":28.0,"width":48.0,"height":48.0}, visible: true
Type: Text, Text: "Custom Widgets", data: "Custom Widgets", bounds: {"x":72.0,"y":38.0,"width":161.927734375,"height":28.0}, visible: true
Type: GestureDetector, startBehavior: "DragStartBehavior.start", bounds: {"x":0.0,"y":810.2857142857143,"width":137.14285714285714,"height":80.0}, visible: true
Type: RichText, Text: "", textAlign: "TextAlign.start", textDirection: "TextDirection.ltr", softWrap: "true", overflow: "TextOverflow.visible", textWidthBasis: "TextWidthBasis.parent", bounds: {"x":56.57142857142857,"y":828.2857142857143,"width":24.0,"height":24.0}, visible: true
Type: Text, Text: "Home", data: "Home", inherit: "false", family: "Roboto", size: "12.0", letterSpacing: "0.5", baseline: "TextBaseline.alphabetic", height: "1.33", leadingDistribution: "TextLeadingDistribution.even", bounds: {"x":51.46986607142857,"y":860.2857142857143,"width":34.203125,"height":16.0}, visible: true
Type: GestureDetector, startBehavior: "DragStartBehavior.start", bounds: {"x":137.14285714285714,"y":810.2857142857143,"width":137.14285714285714,"height":80.0}, visible: true
Type: RichText, Text: "", textAlign: "TextAlign.start", textDirection: "TextDirection.ltr", softWrap: "true", overflow: "TextOverflow.visible", textWidthBasis: "TextWidthBasis.parent", bounds: {"x":193.71428571428572,"y":828.2857142857143,"width":24.0,"height":24.0}, visible: true
Type: Text, Text: "Profile", data: "Profile", inherit: "false", family: "Roboto", size: "12.0", letterSpacing: "0.5", baseline: "TextBaseline.alphabetic", height: "1.33", leadingDistribution: "TextLeadingDistribution.even", bounds: {"x":186.29498182024275,"y":860.2857142857143,"width":38.83860778808594,"height":16.0}, visible: true
Type: GestureDetector, startBehavior: "DragStartBehavior.start", bounds: {"x":274.2857142857143,"y":810.2857142857143,"width":137.14285714285714,"height":80.0}, visible: true
Type: RichText, Text: "", textAlign: "TextAlign.start", textDirection: "TextDirection.ltr", softWrap: "true", overflow: "TextOverflow.visible", textWidthBasis: "TextWidthBasis.parent", bounds: {"x":330.85714285714283,"y":828.2857142857143,"width":24.0,"height":24.0}, visible: true
Type: Text, Text: "Settings", data: "Settings", inherit: "false", family: "Roboto", size: "12.0", letterSpacing: "0.5", baseline: "TextBaseline.alphabetic", height: "1.33", leadingDistribution: "TextLeadingDistribution.even", bounds: {"x":318.65597098214283,"y":860.2857142857143,"width":48.40234375,"height":16.0}, visible: true

take_screenshots is the other way to look. It costs far more context than the list, so agents use it to check layout or to keep evidence, and the list to act.

Choosing a target

Every gesture and text tool matches its target the same way. From the most to the least stable:

SelectorSurvivesUse it when
key copy changes, translation, refactors the widget has a ValueKey<String>. Prefer it.
identifier the same, and it is never read aloud no key fits: wrap it in Semantics(identifier:).
text nothing that edits or translates the label a quick look, or a one-off run.
type only screens with one widget of that type the screen has one PageView or one Switch.
coordinates nothing: any layout or font-scale change a diagnostic. If you need them, the widget needs a key.

The first match in the tree wins. When the same key repeats in identical subtrees, such as grid cells or repeated cards, add ancestor_keys: the keys of the wrappers, outermost first. Each one is looked up inside the previous one, and a key with no element fails the call by name instead of widening the search.

tap
{ "key": "cell.joinButton", "ancestor_keys": ["session_2", "grid.cell_3"] }

Compact by default

The list never carries style, decoration or colour objects: only primitive values. On top of that, the default CompactionMode.compact drops rendering details no tool reads, drops a Text element’s duplicated data, rounds bounds to whole logical pixels, and reports visible only when an element is hidden. On the example app that makes the payload about 65% smaller.

Nothing it drops is read by tap, enter_text, scroll_to or swipe. When the agent needs the full dump, it passes compaction: "none" (--compaction=none in the CLI), or the app sets compaction: CompactionMode.none as its default.

Debug and profile only

The VM service exists in debug and profile builds, never in release. That is a design choice, not a gap: gate the binding on kDebugMode, or on !kReleaseMode to include profile builds. Both are compile-time constants, so the release binary contains no Marionette code.

Hot reload keeps the app’s state and does not re-run main(). A change to main(), to a global singleton or to the shape of the state needs hot_restart, which works when the app runs under flutter run.

One binding per process

Flutter allows one WidgetsBinding per process, and MarionetteBinding is one. If another binding got there first, ensureInitialized throws and names it. Two cases come up often.

Tests that call main()

kDebugMode is true under flutter test, so the test binding and Marionette collide. Skip Marionette under test, or give tests their own entrypoint that never initializes it.

lib/main.dart
import 'dart:io' show Platform;

final isFlutterTest = Platform.environment.containsKey('FLUTTER_TEST');
if (kDebugMode && !isFlutterTest) {
  MarionetteBinding.ensureInitialized();
} else {
  WidgetsFlutterBinding.ensureInitialized();
}

Plugins with their own binding, such as Sentry

SentryFlutter.init installs a binding before it calls appRunner. Initialized inside appRunner, Marionette throws inside Sentry’s error zone and the app hangs on the splash screen with no error at all. Initialize Marionette first; Sentry reuses the binding it finds.

lib/main.dart
void main() {
  if (kDebugMode) {
    MarionetteBinding.ensureInitialized(); // before Sentry, not inside appRunner
  } else {
    WidgetsFlutterBinding.ensureInitialized();
  }
  SentryFlutter.init(
    (options) => options.dsn = _sentryDsn,
    appRunner: () => runApp(const MyApp()),
  );
}

MCP server or CLI

Both bridges drive the same app. Pick the one that fits your agent and the way you work; most coding agents can use either.

MCP serverCLI
Package marionette_mcp marionette_cli, the marionette command
Agent needs MCP support a shell, and the help-ai reference
Connection connect once, then calls --uri per call, or register a named instance
Custom extensions schema-bearing ones become named tools not available
Video — record-video, with ffmpeg

Both need the same setup in the app, and both write the same session reports.

When not to use it

  • A regression suite in CI. Gestures are a best-effort simulation of a user, not deterministic instrumentation. For tests that pass or fail the same way every time, use Patrol.
  • A release build. The VM service does not exist there.
  • Performance work. Marionette can trigger the interaction to profile; measuring it is the job of Flutter DevTools.
  • Writing the app: dependencies, analysis and runtime errors belong to the Dart and Flutter MCP server. Use both.