Agent skill

Marionette Flutter Drive App

by leancodepl in leancodepl/marionette_mcp

Set up and drive a running Flutter app (debug or profile) with Marionette — an AI agent's hands and eyes for the app.

Apache-2.0Auto-check passedMobile

Install Marionette Flutter Drive App

skills CLI
$ npx skills add leancodepl/marionette_mcp --skill marionette-flutter-drive-app -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install leancodepl/marionette_mcp marionette-flutter-drive-app --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/leancodepl/marionette_mcp.git skills-src && mkdir -p .claude/skills && cp -r skills-src/packages/marionette_flutter/skills/marionette-flutter-drive-app .claude/skills/marionette-flutter-drive-app && rm -rf skills-src

Use ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.

Claude Code skills documentation · loads skills from .claude/skills/

Facts

Skill name
marionette-flutter-drive-app
GitHub stars
482
Token cost
~11k tokens
SKILL.md length
4,772 words
Files
1
Skills in repo
1
Repo updated
First seen
Licence
Apache-2.0

At a glance

Set up and drive a running Flutter app (debug or profile) with Marionette — an AI agent's hands and eyes for the app.

  • Interact with a Flutter app
  • SKILL.md covers Preparing the app, When to use this, When not to use this and Connecting, plus 7 more sections
  • Calls flutter and dart
  • Integrating marionetteflutter

What it does

Marionette Flutter Drive App is an agent skill from leancodepl/marionette_mcp. Set up and drive a running Flutter app (debug or profile) with Marionette — an AI agent's hands and eyes for the app. Covers adding marionetteflutter, MarionetteBinding, MarionetteConfiguration for custom widgets, a LogCollector, and MarionetteDeviceConfig — then tapping/typing/scrolling, screenshots, logs, hot reload/restart, device sweeps, and custom extensions, via marionettemcp or the marionette CLI. Use whenever asked to test, click through, smoke-test, automate, QA, or interact with a Flutter app; when…

Its SKILL.md is about 11k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.

It sits in Mobile, covering Cross-platform mobile apps and App store release. It works with Flutter and Model Context Protocol. The repository describes itself as: MCP server enabling AI agents to interact with Flutter apps at runtime - let them inspect widgets, simulate taps, enter text, scroll, and take screenshots. The licence is Apache-2.0.

When your agent uses it

  • Interact with a Flutter app
  • Integrating marionetteflutter
  • Registering a custom extension
  • After a feature

Example prompts

  • “/marionette-flutter-drive-app”

What it can do on your machine

Read from SKILL.md and the folder at commit 1081d7f. It shows what the files ask for, not the result of running them.

  • Tool permissions

    Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.

    From allowed-tools in the SKILL.md frontmatter.

  • Runs code

    Shell commands in SKILL.md call:

    • flutter
    • dart

    From the folder's file list and the shell code blocks in SKILL.md.

  • Network

    Links to these hosts (documentation or services it may open):

    • patrol.leancode.co

    From URLs in SKILL.md, links to its own repository left out.

  • Credentials

    Names no API keys, tokens, secrets or passwords.

    From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.

Context cost

Marionette Flutter Drive App loads about 11k tokens when it runs. Until then it costs about 262 tokens; SKILL.md has 4,772 words of instructions outside code blocks.

Always · name and description, kept in context so the agent knows when to use it
~262
When it runs · the whole SKILL.md, loaded when a task matches
~11k

Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.

Safety

Auto-check passed

The automated check found no risky patterns in SKILL.md.

Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.

SKILL.md

The full file from leancodepl/marionette_mcp at commit 1081d7f, republished under its Apache-2.0 licence (© leancodepl). 4,772 words, ~11,256 tokens.

Download SKILL.mdSave it as .claude/skills/marionette-flutter-drive-app/SKILL.md (or your agent's skills folder).
name
marionette-flutter-drive-app
description
Set up and drive a running Flutter app (debug or profile) with Marionette — an AI agent's hands and eyes for the app. Covers adding marionette_flutter, MarionetteBinding, MarionetteConfiguration for custom widgets, a LogCollector, and MarionetteDeviceConfig — then tapping/typing/scrolling, screenshots, logs, hot reload/restart, device sweeps, and custom extensions, via marionette_mcp or the marionette CLI. Use whenever asked to test, click through, smoke-test, automate, QA, or interact with a Flutter app; when integrating marionette_flutter or registering a custom extension; after a feature or UI/design-system change; to reproduce or verify a bug; to walk a behaviour-neutral refactor; to sweep for accessibility; to exercise form validation; or to capture before/after evidence for a PR. Also load whenever a Marionette call fails, can't see a widget, reports not connected or a version mismatch, or hits a single-binding assertion. Not for CI suites, release builds, or performance work — see When not to use.

marionette_flutter: drive an app

Marionette lets an AI agent drive a running Flutter app (debug or profile — never release) the way a real user would: read the screen, tap, type, scroll, screenshot, read logs, hot reload. It has two halves:

  • marionette_flutter — the package you add to the app. It registers a VM service extension per action (ext.flutter.marionette.*).
  • The bridge — either marionette_mcp (MCP tools, e.g. connect, tap, get_interactive_elements) or marionette_cli (shell commands, e.g. marionette tap --key ...). Nearly the same capabilities, different transport — the CLI additionally offers record-video, with no MCP equivalent (see the capability table, below). Prefer the MCP tools when they're in your tool list; fall back to the CLI in restricted environments (enterprise policy, a shell-only agent) — run marionette help-ai once at the start of such a session and follow its reference for exact syntax.

Everything shared between the two applies to either transport — action names differ only in casing (get_interactive_elements vs get-interactive-elements).

Preparing the app

connect (or any CLI command) only works if the target app initialized MarionetteBinding. If you're not sure whether it has, check main.dart (or ask) — skip the rest of this section if it's already there and you just need to drive the app, and jump to When to use this.

bash
flutter pub add marionette_flutter
dart
void main() {
  if (kDebugMode) {
    MarionetteBinding.ensureInitialized();
  } else {
    WidgetsFlutterBinding.ensureInitialized();
  }
  runApp(const MyApp());
}

This is all a standard Material-widgets app needs. It runs inside main(), so any time you add or change it, hot restart — a hot reload doesn't re-execute main(), so the change will look like it did nothing. Gate every piece of Marionette wiring behind kDebugMode (or !kReleaseMode — see Device-config sweeps, below) so none of it ships in a release build: both are compile-time constants the release branch survives cleanly.

Custom design system? Teach Marionette about it

Zero-config setup only recognizes stock Material widgets (ElevatedButton, TextField, Switch, …) and extracts text only from Text, RichText, EditableText, TextField, and TextFormField. If the app wraps its own buttons, fields, or text — true of most production apps — get_interactive_elements will look sparse and tap(text: ...) will fail to find things that are clearly on screen. That's not a Marionette bug; it just doesn't know your widgets yet:

dart
MarionetteConfiguration(
  // Recognize your custom interactive widgets.
  isInteractiveElement: (element) => switch (element.widget) {
    MyButton() || MyTextField() => true,
    _ => false,
  },
  // Extract their visible text for text-based matching and discovery.
  extractText: (element) {
    final widget = element.widget;
    if (widget is MyText) return widget.data;
    return null;
  },
)

Pass it as MarionetteBinding.ensureInitialized(MarionetteConfiguration(...)). For a non-trivial design system, extract the configuration to its own file (e.g. lib/debug/marionette_config.dart) imported only behind kDebugMode, rather than growing it inline in main.dart — that keeps release builds free of design-system introspection helpers even before the compiler strips them.

isInteractiveElement and extractText receive the Element, not just the Widget. Match with an is check or pattern on element.widget — that covers generic widgets (MySelect() matches MySelect<String>) and subclasses (MyButton() matches MyPrimaryButton extends MyButton), and can read the widget's fields to decide per instance. extractText can also walk the subtree when a label is itself a widget rather than a plain string. If the app still uses the deprecated isInteractiveWidget: (type) => ... or shouldStopTraversal: (type) => ..., move it to isInteractiveElement / shouldStopTraversalAtElement: a Type only compares with exact ==, so it silently misses generic and subclassed widgets. While both are set, their results are combined with OR. Custom-painted text and badges reach no Text widget at all, and a WidgetSpan is only a problem when its embedded content isn't itself built from Text/RichText (an icon, a custom-painted chip) — plain text nested inside one is still its own discoverable element. For genuinely non-text content, annotate with Semantics(label: ..., value: ...) instead; Marionette surfaces that as a Semantics element with the joined 'label: value' string, and it costs nothing if label/value are absent (unlabeled Semantics nodes are silently skipped).

Not every custom widget needs an entry: add one when it's a primary interactive primitive (button, field, toggle, tab, chip) or when get_interactive_elements is noticeably missing it; skip widgets that are only ever decorative, or ones that already wrap a primitive Marionette sees (a MyCard built on GestureDetector is already covered). The list doesn't need to be exhaustive up front — extend it as real use surfaces a missing target.

One more MarionetteConfiguration field, shouldStopTraversalAtElement, is worth naming only to warn against it: it's tempting to add a scroll container there to "reduce traversal cost," but on a real production app that measurably dropped widget coverage from 25.8% to 17.8% — the agent lost visibility into everything nested below the cut, including the content it needed to reach. Leave it null unless a profiler has shown a real, measured cost, and never point it at a scrolling container.

Logs for get_logs

Without a LogCollector, get_logs returns a message explaining how to add one rather than actual logs. Pick whichever matches how the app already logs:

  • Uses the logging package → flutter pub add marionette_logging, pass LoggingLogCollector() as logCollector.
  • Uses the logger package → flutter pub add marionette_logger, pass LoggerLogCollector() (it doubles as a LogOutput for that package too).
  • Anything else → PrintLogCollector (ships in marionette_flutter) exposes a manual addLog(message) to call from wherever logs already flow — routing Flutter's own debugPrint through it is a common choice, tee'd into the existing implementation rather than replacing it, to keep debugPrintThrottled's throttling.
Screenshot size

take_screenshots downscales captures to fit within 2000×2000 physical pixels by default, to keep base64 payloads manageable. Override maxScreenshotSize on MarionetteConfiguration if a screen needs more detail (Size(3000, 3000), say), or set it to null to disable resizing entirely — but keep the default unless there's a concrete reason to raise it, since larger screenshots mean larger payloads on every call.

Device-config sweeps

set_device_config — sweeping text scale, bold text, or light/dark appearance without touching real OS settings — is the one capability that needs a widget in the tree, since Marionette won't insert one into an app behind its back:

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

Without it, set_device_config responds with these setup instructions instead of succeeding. Because it lives in main(), adding it also needs a hot restart. !kReleaseMode (rather than kDebugMode) is the better gate here because it additionally covers profile builds, where the VM service is still reachable — use the same gate for both the binding and this widget, since gating the widget more loosely than the binding gains nothing (it degrades to a no-op child-passthrough if the binding isn't there, so a missed gate costs one inert element, never a crash).

The single-binding rule

Flutter allows exactly one WidgetsBinding per process. If something else claims it first, MarionetteBinding.ensureInitialized() throws a clear error — except with one real offender, where it doesn't:

  • flutter test running main() under kDebugMode collides with AutomatedTestWidgetsFlutterBinding. Guard against it — Platform.environment.containsKey('FLUTTER_TEST') — or give tests a separate entrypoint that skips MarionetteBinding entirely.
  • Sentry is the known silent offender: SentryFlutter.init() always calls WidgetsBinding.ensureInitialized() inside its own appRunner-wrapping zone before your code runs, and it swallows the resulting binding error as if it were a reportable crash — so the app just hangs on the splash screen forever with no exception, no crash, and no log output. The fix is initialization order: call MarionetteBinding.ensureInitialized() before SentryFlutter.init(), not inside its appRunner. Sentry checks for an existing binding first and reuses it, so going first avoids the conflict entirely. The same ordering fix applies to any other plugin whose init() touches WidgetsBinding ahead of your appRunner.
Version alignment

The MCP connect tool checks that marionette_mcp and the app's marionette_flutter are on the same version, and fails with a clear message rather than a confusing runtime error if they aren't. The CLI does not perform this check — a stale marionette_flutter there can fail in less obvious ways instead of a clear mismatch error. Either way, don't chase a mismatch by retrying — align the versions: flutter pub add marionette_flutter in the app, and dart pub global activate marionette_mcp/marionette_cli for the bridge (or dart pub add dev:marionette_mcp/dev:marionette_cli if you'd rather pin it as a dev dependency instead of a global tool).

When to use this

Reach for Marionette any time the fastest way to know whether something works is to actually run the app and look, rather than reason about the code:

  • After implementing a feature. Connect, navigate to the new screen, and exercise the happy path — a working build isn't the same as a working feature.
  • After a UI or design-system change. Confirm the affected screens still render and respond as expected, not just that the app compiles.
  • Reproducing a bug from a ticket. Follow the reported steps, capture a screenshot and get_logs output as evidence the bug is real and what it looks like — before touching any code.
  • Verifying a bugfix. Reproduce with the same steps, apply the fix, hot_reload, and replay those exact steps again to confirm the symptom is gone (not a different, adjacent symptom).
  • Walking a "behaviour-neutral" refactor. Claims that a refactor changes nothing observable are exactly the claims worth checking — a quick regression walk of the touched screens catches what review alone misses.
  • An accessibility sweep. Use set_device_config to try text_scale around 2.0–3.0 (real devices top out near 3.0) and bold_text: true on the screen in question, then check get_logs for overflow errors — that combination is where cramped layouts usually break first. (Needs the app to have opted in via MarionetteDeviceConfig — see Device-config sweeps.)
  • Form and validation work. Enter deliberately invalid input and read the error strings back via get_interactive_elements/take_screenshots — it's the only way to confirm the user-visible message is right, not just that a validator returns an error code.
  • Evidence for a PR or ticket comment. A quick before/after take_screenshots pair is often more convincing, and faster to produce, than a written description of a UI change. For a multi-step flow rather than a single static change, record-video (CLI-only, no MCP equivalent — see the capability table) captures the whole interaction as a short clip, which shows off a new feature or a fixed flow better than a screenshot pair can.

When not to use this

  • CI or a repeatable regression suite. Marionette's gestures are best-effort simulations of user input, not deterministic instrumentation — results can vary across platforms, custom widgets, and overlays. For a suite that needs to pass or fail the same way every time, use Patrol instead.
  • Release builds. Not a limitation to work around — Marionette relies on the Dart VM Service, which doesn't exist in a release binary. Debug and profile only.
  • Performance work. Marionette can trigger the interactions you want to profile, but measuring them is Flutter DevTools' job, not this one.

Connecting

Every action needs a VM service WebSocket URI (ws://127.0.0.1:PORT/TOKEN=/ws) — the developer running the app has it in their flutter run console output, or in a DevTools link. Ask for it and pass it to connect (MCP) or --uri / a registered -i <name> (CLI) before anything else.

If more than one running instance is already in play — several apps registered with the CLI, or the user's message mentions more than one device or simulator — don't guess which one to use. Either infer the right one from what's already been said in the conversation (e.g. "the iOS one" when only one registered instance is iOS), or ask which to target. Getting the wrong instance is easy to miss until several steps later, so it's worth the one question up front.

For the CLI, prefer --uri for a one-off session and marionette register <name> <uri> + -i <name> when you'll issue many commands against the same app in a row. When you're done for the session (MCP), call disconnect — it's not required for correctness (a fresh connect implicitly replaces it), but leaving a session dangling makes it easy to act on a stale connection later without noticing.

Give connect a session_title (a short description of what you're testing, e.g. "profile validation" — keep it under ~60 characters, longer is truncated) — if the app enables session reports (MarionetteConfiguration(enableSessionReports: true)), it opens a fresh session directory that carries this run's step log and screenshots. One Marionette run is one session directory, always: there's no resuming, even if you pass the same title again on a later connect — that just opens another, separate directory. If a run gets interrupted partway through (a compaction, a dropped connection), its session is simply abandoned; don't try to pick up where it left off, and don't reuse its title. Always give it in English, regardless of the prompt's own language (see The report contract, below) — it gets slugified straight into the session directory's name, and that name should stay predictable and filesystem-friendly rather than following the run's language. See Reporting what you found for what that directory is for and what you're expected to do with it before disconnecting.

What you can do once connected

CategoryActionsNotes
Inspectionget_interactive_elements, take_screenshots, get_logsget_interactive_elements is how you "see" the screen — call it before guessing at a target, and again after any navigation you didn't drive step by step. take_screenshots returns images inline by default; pass inline: false once a screenshot is evidence to cite rather than something you need to look at now — see Reporting what you found. get_logs needs a LogCollector wired up app-side (see Logs for get_logs, above); otherwise it returns setup instructions instead of an error. On a screen that repeats the same subtree, pass ancestor_keys to list just one copy of it (see Repeated keys, below).
Gesturestap, secondary_tap, double_tap, long_press, swipe, pinch_zoom, scroll_to, press_back_buttonMatch by key › identifier › text › type › coordinates, in that preference order — see Good practices. When the same key repeats in several identical subtrees, add ancestor_keys — see Repeated keys, below. secondary_tap is desktop-only.
Text inputenter_text, press_keyenter_text overwrites a field's value directly, and takes ancestor_keys like the gestures do. press_key sends a real key event (submit on enter, shortcuts via modifiers) but only edits in-place on desktop/web — on mobile, field editing still needs enter_text.
Device configset_device_configSweeps text scale / bold text / light-dark under the app's current screen, without touching OS settings. Needs the app to opt in — see Device-config sweeps, above — otherwise it returns setup instructions rather than failing.
Custom extensionslist_custom_extensions, call_custom_extension, plus any first-class tool an app registered with a schemaSee Custom extensions, below.
Dev workflowhot_reload, hot_restarthot_reload preserves state; use hot_restart only for changes a reload can't pick up (main()/bootstrap edits, global singletons, state shape) — requires the app to be running via flutter run.
Sessionconnect, disconnectconnect must be called before any other tool; a second connect implicitly disconnects the first. connect also opens a fresh session directory (session_title) that owns the step log and screenshots — see Reporting what you found.
Video (CLI only)record-videoRecords a WebM video of the session (-o/--output, -d/--duration, --width/--height; needs ffmpeg on PATH). No MCP equivalent — use take_screenshots there instead.

Good practices

  • Look before you act. Call get_interactive_elements (or take_screenshots for a visual check) before the first gesture on a new screen. Don't infer what's on screen from memory of the source code — the whole point of Marionette is to check the running state. The listing is compact by default: it drops rendering details and reports visible only when an element is not visible. Pass compaction: "none" (--compaction=none in the CLI) when you need the full property dump.
  • Selector priority: key > identifier > text > type/coordinates. Keys (ValueKey<String>) and Semantics identifiers survive copy changes, localization, and refactors; text breaks the moment a label is edited or translated; coordinates break on any layout shift, font-scale change, or screen-size difference. Treat coordinates as a last-resort diagnostic, not the normal way to target something — if nothing else works, that's a sign the widget needs a key or Semantics(identifier: ...), not a reason to keep using coordinates.
  • Repeated keys: scope the match with ancestor_keys. Every tool takes the first match in the tree, so on a screen that repeats the same subtree (grid cells, repeated cards, a dev screen embedding several copies of the app) a plain tap(key: "cell.joinButton") always hits the first copy. Pass the keys of the wrappers around the one you mean, outermost first: {"key": "cell.joinButton", "ancestor_keys": ["session_2", "grid.cell_3"]}. Each key is looked up strictly inside the previous one, which is what lets you reach a wrapper whose own key also repeats (grid.cell_3 exists in every session), and the target itself must be inside the last one. A single key is often not enough — if the wrapper repeats too, go one level up.
    • Finding the chain: get_interactive_elements is a flat list in tree order, so a keyed wrapper, when it is listed at all, appears just before the elements inside it. A wrapper whose centre catches no hit test isn't listed, so if a key you expect is missing, read it from the source or ask for it. To confirm a chain, list only that subtree: get_interactive_elements(ancestor_keys: [...]). A scope alone is enough there, the wrapper itself is listed too, and a wrong key fails by name.
    • A key with no element fails the call, naming it and where it was looked for (Scope element with key "grid.cell_9" (ancestor_keys[1]) not found inside "session_2"). It never falls back to searching the whole screen, so a typo can't silently act on the wrong copy.
    • scroll_to works into lazy lists: a scope that isn't built yet (e.g. ancestor_keys: ["row_20"] in a long ListView) is looked for again after every drag, and the list that scrolls may be inside the scope or above it. A scope key that exists nowhere is reported only after the list has been scanned, like a missing target.
    • coordinates and focused_element ignore ancestor_keys, and a scope on its own is not a selector for the gesture tools.
    • Prefer this over tapping by coordinates or baking indices into keys: the chain survives layout changes and keeps production keys clean.
  • An element "not found" is usually a setup gap, not a bug. Before concluding a widget can't be reached, suspect it's a custom widget type Marionette doesn't recognize yet, or custom-painted content with no Semantics annotation — both fixed under Preparing the app, above, not something to work around here.
  • If coverage looks low across a whole screen rather than one missing element, don't reach for shouldStopTraversalAtElement. It's tempting to suggest filtering a scroll container out of traversal to "reduce noise," but that's the one config change measured to make things worse (25.8% → 17.8% widget coverage on a real app) — see Custom design system?, above.
  • Confirm side effects with get_logs, not just UI state. A tap that looks like it worked isn't the same as the network call actually firing.
  • Give the app's flows explicit context. Marionette can see the widget tree and act on it, but it has no idea what the product's flows, naming conventions, or edge cases are. State which screen to reach, the expected keys/labels, any precondition the flow assumes (e.g. "assume the user is already logged in"), and the interaction goal in one sentence, rather than assuming that's inferable from the UI alone.
  • Treat every gesture as best-effort. Focus, text entry, and scrolling are simulations of user input and can behave differently across platforms, custom widgets, or overlays. A flow that's consistently flaky (not just occasionally) is a signal to expose a clearer key/identifier, flatten a deeply nested GestureDetector hit target, or extend MarionetteConfiguration — not to retry harder.
Show full SKILL.md (1,683 more words)Show less

Reporting what you found

This section applies only when connect's response actually includes an Opened session: path — check the real response, not just whether you expected one. Two different things can be missing it: the app never enabled session reports (MarionetteConfiguration(enableSessionReports: true)) — don't turn that on yourself unless the user asks for a report — or it did, but the session directory itself couldn't be created (a connect response saying so instead, e.g. "Could not open a session directory, so this run will not be logged"). Either way, there's no session directory, no steps.md, and nowhere to write report.md — see the note on this at the end of The report contract, below.

connect opens a fresh session directory under .marionette/sessions/ — the server's own record of the run, kept separate from your own context. disconnect's response repeats that directory's path with a reminder to write report.md: treat that as a requirement, not a suggestion — it's the enforcement mechanism precisely because an instruction here, on its own, gets skipped.

Two files in that directory are appended by the server, not you — don't write to them yourself:

  • steps.md — one line per tool call: the tool, a short selector, and the outcome. No payloads (enter_text values are redacted).
  • screenshots/ — populated only when you call take_screenshots with inline: false, which is what you want once a screenshot is evidence you're citing rather than something you need to look at right now (each inline image costs real visual tokens).

You own one more file there, which the server never writes:

  • report.md — written once, at the end, from your own context. Write it on either of two triggers: the run completed, or you're stopping early (a blocking failure, a budget/time limit, an ambiguous requirement you can't resolve alone). A report for a run stopped early states why it stopped and what was left untested, instead of silently reporting only what got covered — there's no resuming to pick it back up later, so this is the only record of what happened. Before drafting a single line, name the report language explicitly: the language of the prompt that started this session (the message that led to the first connect call) — not English by default, not the language of the app's UI under test, not whatever language your own tool-call narration has been in. This check is easy to skip because it's just a rule sitting in the contract below; doing it now, as a deliberate step before writing, is what actually prevents defaulting to English. See The report contract for the full rule.
The report contract
  • Write report.md — and the chat message — in the language the prompt that started the run was written in. Proper nouns stay exactly as they are regardless of that language: tool names, file paths (report.md, steps.md, screenshots/01.png), and widget keys/identifiers (login_emailTextField) are never translated. Check this before writing, not after — see the reminder in Reporting what you found, above. session_title is the one thing that's exempt in the other direction: give it in English always, whatever language the prompt itself is in — see Connecting, above.

  • The Tested: line is rendered from steps.md, never from memory or your own sense of what you did. This is the anti-overstatement mechanism — read the step count and the actions taken back out of the file the server wrote, don't estimate.

  • Every finding cites evidence — a step, a get_logs line, or a screenshot path. No evidence means it's a suspicion, not a finding: say so explicitly rather than upgrading a hunch.

  • Under six lines per finding, no prose paragraphs. A severity tag, one-line summary, a repro path, and the evidence citation — that's the shape; if it doesn't fit, cut the finding down rather than making room.

  • Prefix the headline and every finding with a status icon, so severity is scannable without reading a word: ✅ clean (no findings), ⚠️ only suspicions, 🐛 at least one confirmed bug — on the headline, the worst severity present; on a finding, its own. ➖ marks something explicitly skipped (a Not covered: line, or an individual check you didn't run). Icons decorate the existing tags/wording — [bug], [suspicion], Not covered: — they never replace them: keep both, so the report still greps cleanly and reads fine wherever emoji don't render.

  • The chat message after disconnecting follows a fixed, compact template — not free-form prose, and not a second copy of report.md:

    Marionette · <flow/feature> — <VERDICT>: <reason, a few words>
    ✓ <what worked, comma-separated>
    ✗ [<type>] <finding, one line>       (up to 5; beyond that: "+N more in report.md")
    ∅ Not covered: <what> — <why>
    → <session path>/report.md · <N> steps, <M> screenshots

    <VERDICT> is a fixed, four-word vocabulary — like [bug]/[suspicion], never translated regardless of the prompt's language: PASS (nothing found, full coverage), SUSPECT (only suspicion(s), no confirmed bug), FAIL (at least one confirmed bug), BLOCKED (coverage was cut short by something that stopped you continuing — bad test data, a stuck flow, a missing prerequisite; use this over FAIL/PASS when that's the story, even if you also found a bug along the way). Each ✗ line's [type] is bug, blocker, ux, or suspicion — bug/blocker/ux corresponds to a [bug]-tagged finding in report.md (evidence-backed); suspicion corresponds to a [suspicion]-tagged one. Rows are optional beyond the headline and the → line: skip ✓ if nothing worked, skip ✗/∅ for a PASS. The step/screenshot counts on the → line come from steps.md, same as Tested: in report.md — not a re-estimate. This uses ✓/✗/∅/→, not the ✅/⚠️/🐛/➖ from report.md, deliberately: this message prints as raw terminal text, where a plain Unicode symbol renders as one predictable, monochrome character everywhere, while a full-color emoji can render at an inconsistent width or not at all depending on the terminal. report.md is a file usually opened in an editor or on GitHub, where colored emoji render cleanly — different consumption context, different choice; don't mix the two sets.

  • No session directory (see Reporting what you found, above)? There's no report.md and no contract to follow — give the same shape of chat message anyway (verdict, ✓/✗/∅ lines), just drop the → line (no path to give) and don't invent a Tested:-style step count — you have no steps.md to render one from truthfully.

This is what report.md itself looks like (shown in English here; write yours in whatever language the prompt used) — a confirmed bug, a clean run, then a suspicion:

🐛 Marionette report — 2 findings · User Profile
Tested: profile view, edit form, save flow (14 steps, 3 screenshots)

1. 🐛 [bug] Date of birth accepts future dates — no validation
   Repro: Profile → Edit → DOB = 2099-01-01 → Save
   Evidence: step 9, log "saved dob=2099-01-01"

2. 🐛 [bug] Save does not persist — changes lost after restart
   Repro: Profile → Edit → Name = "X" → Save → hot_restart → Profile
   Evidence: step 14, screenshots/02-after-restart.png
✅ Marionette report — no issues · Checkout
Tested: cart, address form, payment, confirmation (18 steps)
Checks: field validation, back navigation, dark mode, text scale 2.0

A run with only a suspicion (no hard evidence of a real bug) gets ⚠️ instead, on both the headline and the finding itself:

⚠️ Marionette report — no confirmed issues, 1 suspicion · Settings
Tested: notification toggles, language picker, theme toggle (9 steps, 2 screenshots)

1. ⚠️ [suspicion] Dark mode toggle reverts to light after hot_restart
   Repro: Settings → enable Dark mode → hot_restart → Settings
   Evidence: screenshots/02-after-restart.png shows light theme again

➖ Not covered: account deletion, data export — out of scope for this run

The chat message for those three, plus a BLOCKED case none of them happen to show (this has a different shape from report.md's own headline — denser, with a verdict word, since it has to stand alone as the whole message):

Marionette · User Profile — FAIL: 2 bugs
✓ Profile view, edit form
✗ [bug] Date of birth accepts future dates — no validation
✗ [bug] Save does not persist — changes lost after restart
→ .marionette/sessions/user-profile-20260922T1412/report.md · 14 steps, 3 screenshots
Marionette · Checkout — PASS: no issues
✓ Cart, address form, payment, confirmation, dark mode, text scale 2.0
→ .marionette/sessions/checkout-20260922T1801/report.md · 18 steps
Marionette · Settings — SUSPECT: theme toggle may not persist
✓ Notification toggles, language picker
✗ [suspicion] Dark mode toggle reverts to light after hot_restart
∅ Not covered: account deletion, data export — out of scope for this run
→ .marionette/sessions/settings-20260922T1601/report.md · 9 steps, 2 screenshots
Marionette · Password reset — BLOCKED: reset email never arrives
✓ Request form, validation messages
✗ [blocker] "Send reset link" succeeds but no email logged or received after 3 attempts
∅ Not covered: setting a new password, confirmation screen — flow can't proceed past the email step
→ .marionette/sessions/password-reset-20260922T1601/report.md · 11 steps, 1 screenshot

.marionette/ is gitignored by default (the server writes that .gitignore itself the first time a session is created). Committing a session directory — attaching a report to a PR, say — is a deliberate choice you make explicitly, never something to do as a matter of course.

Custom extensions

Apps can expose their own actions via registerMarionetteExtension (route navigation, seeding test data, feature-flag toggles — anything the generic tools can't express). Call list_custom_extensions before assuming an action isn't possible. Extensions that declare a scalar inputSchema show up as first-class, individually named tools (sanitized to [a-z0-9_-], e.g. appNavigation.goToPage → app_navigation_go_to_page) with validated arguments; schema-less ones are only reachable through the generic call_custom_extension, passing the real (unsanitized) name and key-value args.

Troubleshooting

SymptomLikely causeFix
"Not connected to any app"No successful connect yet in this sessionconnect before anything else — see Connecting, above
MCP connect fails with a version mismatchmarionette_mcp and marionette_flutter are different versions (the CLI doesn't check this)See Version alignment, above
Custom buttons/fields don't show up, or tap(text:)/scroll_to(text:) can't find a visible labelWidget type or text isn't recognizedSee Custom design system?, above
A gesture or enter_text hits the wrong copy of a repeated widgetThe key repeats across identical subtrees; the first match winsAdd ancestor_keys — see Repeated keys, above
Scope element with key "…" (ancestor_keys[N]) not foundThat wrapper key is misspelled, not built yet, or not inside the previous oneCheck with get_interactive_elements(ancestor_keys: [...]); off-screen rows need scroll_to first — see Repeated keys, above
get_logs says no collector configuredNo LogCollector wired upSee Logs for get_logs, above
set_device_config returns setup instructions instead of succeedingApp hasn't opted inSee Device-config sweeps, above, then hot restart
Binding assertion error on startup (often under flutter test)Two WidgetsBindings initializedSee The single-binding rule, above
Session directory lands somewhere unexpected (e.g. not the project root)No MARIONETTE_SESSION_DIR set and the server's own working directory isn't reliably the repo rootPass session_dir to connect (--session-dir on the CLI) explicitly — see Reporting what you found, above
Nothing above applies, and it's a release buildMarionette needs the VM ServiceNot supported by design — see When not to use

CLI fallback

When MCP tools aren't available, install marionette_cli and run its self-describing reference once per session before driving anything:

bash
dart pub global activate marionette_cli
marionette help-ai

That prints every command's syntax, expected output, and exit codes — treat it as the authoritative low-level reference; everything in the capability table above maps onto it one-for-one (tap ↔ tap --key/--identifier/--text/--type/--x/--y, get_interactive_elements ↔ get-interactive-elements, etc.), plus record-video, which only exists on this side. ancestor_keys is --ancestor-keys, repeated once per key, outermost first: tap --key cell.joinButton --ancestor-keys session_2 --ancestor-keys grid.cell_3. Use --uri <ws-uri> for a one-off session and register <name> <uri> + -i <name> for repeated interaction with the same app; marionette doctor checks connectivity of every registered instance and unregister cleans up stale ones. Session reports work the same way here: pass --session <title> (the CLI's equivalent of session_title) consistently across a script's invocations to log a multi-step run into one session directory instead of a fresh, untitled one per command — see Reporting what you found, above.

Keeping this skill in sync

This file must track the package release it ships with, and the capability table above should never disagree with marionette help-ai's own output — both are generated from the same command set. If a tool or CLI command is added, renamed, or changes behavior, update the table and any affected practice/troubleshooting entry, and check marionette help-ai alongside it: if one mentions a command the other doesn't, fix both together rather than picking one to trust going forward.

© leancodepl, Apache-2.0. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

Just SKILL.md in packages/marionette_flutter/skills/marionette-flutter-drive-app of leancodepl/marionette_mcp.

Open the folder on GitHubat commit 1081d7f

Compare with similar skills

Marionette Flutter Drive App next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.

Marionette Flutter Drive App compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Marionette Flutter Drive App this skillleancodepl/marionette_mcp482—~11kAutomated safety check: PassApache-2.0
Cometchat Flutter V6 Productioncometchat/cometchat-skills132—~1.4kAutomated safety check: PassMIT
OdevioOdevio/Odevio-CLI423—~7.6kAutomated safety check: PassMIT
Aaalice Dev SessionsAaalice233/Aaalice_NAI_Launcher188—~902Automated safety check: PassMIT
AI App Bridge UsemobileAiDev/ai-app-bridge227—~1.4kAutomated safety check: PassApache-2.0
OtakitOtaKit/otakit51—~857Automated safety check: PassMIT

Similar skills

  • Cometchat Flutter V6 Production

    cometchat/cometchat-skills

    Take a CometChat Flutter integration to production — server-minted auth tokens instead of the Auth Key, secret handling in a Flutter bundle, logout/session hygiene, release build config, and the…

    132 GitHub stars~1.4k tokensUpdated 4 days ago
    MobileAuto-check passed
  • Odevio

    Odevio/Odevio-CLI

    Take a Flutter project to an iPhone or the App Store with Odevio - build, sign and publish iOS apps from Windows, Linux or macOS with no Mac and no Xcode.

    423 GitHub stars~7.6k tokensUpdated 15 days ago
    MobileAuto-check passed
  • Aaalice Dev Sessions

    Aaalice233/Aaalice_NAI_Launcher

    管理 Aaalice NAI Launcher 项目专用的 Windows 与 Android Flutter 开发会话。用户提到 PC热重载、安卓热重载、启动/关闭/重建独立命令行窗口、模拟器启动异常或检查双端会话时使用。

    188 GitHub stars~902 tokensUpdated 4 days ago
    MobileAuto-check passed
  • AI App Bridge Use

    mobileAiDev/ai-app-bridge

    使用 AI App Bridge 观察、操作和验证 Android、iOS、Flutter、WebView 或 Web App;适用于真实交互、流程自动化和设备诊断。

    227 GitHub stars~1.4k tokensUpdated yesterday
    MobileAuto-check passed
  • Otakit

    OtaKit/otakit

    Set up, release, inspect, troubleshoot, or self-host OtaKit for Capacitor OTA updates.

    51 GitHub stars~857 tokensUpdated 2 days ago
    MobileAuto-check passed
  • Aaalice Hot Reload

    Aaalice233/Aaalice_NAI_Launcher

    为 Aaalice NAI Launcher 修改代码后选择并执行 Windows/Android 的 r 热重载、R 热重启或完整重建,并验证结果。已有开发会话运行时,代码修改完成后自动使用;用户要求热重载、热重启、修改后刷新或查看 Flutter 日志时也使用。

    188 GitHub stars~531 tokensUpdated 4 days ago
    MobileAuto-check passed

Questions about Marionette Flutter Drive App

What does Marionette Flutter Drive App do?

Set up and drive a running Flutter app (debug or profile) with Marionette — an AI agent's hands and eyes for the app. Marionette Flutter Drive App is an agent skill from leancodepl/marionette_mcp. Set up and drive a running Flutter app (debug or profile) with Marionette — an AI agent's hands and eyes for the app.

When should I use Marionette Flutter Drive App?

Marionette Flutter Drive App fits situations like: interact with a Flutter app; integrating marionetteflutter; registering a custom extension; after a feature.

How do I install Marionette Flutter Drive App in Claude Code?

Run `npx skills add leancodepl/marionette_mcp --skill marionette-flutter-drive-app -a claude-code`. Or copy the skill folder (packages/marionette_flutter/skills/marionette-flutter-drive-app in leancodepl/marionette_mcp) into .claude/skills/marionette-flutter-drive-app in your project. Claude Code loads it when a task matches its description.

How do I install Marionette Flutter Drive App in Codex?

Run `npx skills add leancodepl/marionette_mcp --skill marionette-flutter-drive-app -a codex`. Or copy the skill folder (packages/marionette_flutter/skills/marionette-flutter-drive-app in leancodepl/marionette_mcp) into .agents/skills/marionette-flutter-drive-app in your project. Codex loads it when a task matches its description.

Can I use Marionette Flutter Drive App in Cursor, Gemini CLI or GitHub Copilot?

Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add leancodepl/marionette_mcp --skill marionette-flutter-drive-app -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/marionette-flutter-drive-app, .gemini/skills/marionette-flutter-drive-app, .github/skills/marionette-flutter-drive-app and .opencode/skills/marionette-flutter-drive-app in your project.

What does Marionette Flutter Drive App need to run?

Going by SKILL.md and its folder, Marionette Flutter Drive App needs the command-line tools its instructions call (flutter and dart).

Does Marionette Flutter Drive App access the network?

SKILL.md names 1 domain. As links in the text: patrol.leancode.co. This is read from the text; nothing was executed.

Is Marionette Flutter Drive App safe to install?

Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.

What licence does Marionette Flutter Drive App use?

Marionette Flutter Drive App is published under the Apache-2.0 licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.

How many tokens does Marionette Flutter Drive App use?

About 11k tokens (SKILL.md is roughly 45k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.

What are the alternatives to Marionette Flutter Drive App?

Skills that share tags, products or a category with Marionette Flutter Drive App: Cometchat Flutter V6 Production (cometchat/cometchat-skills, 132 stars), Odevio (Odevio/Odevio-CLI, 423 stars), Aaalice Dev Sessions (Aaalice233/Aaalice_NAI_Launcher, 188 stars) and AI App Bridge Use (mobileAiDev/ai-app-bridge, 227 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Marionette Flutter Drive App?

leancodepl (a GitHub organization) maintains it in leancodepl/marionette_mcp, which has 482 GitHub stars. The repository was last updated on October 5, 2026.

Source: leancodepl/marionette_mcp on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.