# Tapir documentation > Tapir snapshots the Semantics tree of a running Flutter app and drives it by stable refs, from the CLI or an AI agent. # Getting Started Everything you need to add `tapir` to your project, capture your first snapshot, and drive real user gestures from an AI agent or CLI session. - [Pick your path](#pick-your-path) - [What is tapir?](#what-is-tapir) - [What problem does it solve?](#what-problem-does-it-solve) - [When to use it](#when-to-use-it) - [Requirements](#requirements) - [High-level workflow](#high-level-workflow) - [Related Documentation](#related-documentation) ## Pick your path - [**Installation**](https://anilcancakir.com/tapir/docs/getting-started/installation.md): Wire TapirPlugin into a Flutter app and connect the MCP tools to your AI client. - [**Quickstart**](https://anilcancakir.com/tapir/docs/getting-started/quickstart.md): 3-step walkthrough from zero to driving the example app with snap, tap, and screenshot. - [**MCP setup**](https://anilcancakir.com/tapir/docs/mcp/setup.md): Register the tapir MCP tools in Claude Code, Cursor, Windsurf, or any MCP-compliant client. - [**Driving real apps: gotchas for agents**](https://anilcancakir.com/tapir/docs/getting-started/driving-real-apps-gotchas.md): the recurring traps in real-app E2E sessions (stale refs, nested text fields, cumulative exceptions, stuck overlays) and the workaround each one has built in. ## What is tapir? `tapir` is a Flutter E2E driver that gives an AI agent (or a CLI session) eyes and hands over a running Flutter app. It works by registering a set of VM Service extensions under the `ext.tapir.*` namespace during debug builds, then exposing those extensions as MCP tools and CLI commands. The agent snaps a Semantics YAML to identify widget references, then drives gestures, text input, scrolling, and navigation against those references without any test instrumentation in the production widget tree. What sets it apart from `flutter_test`-based integration tests is that it operates against the live running app, not a test-hosted widget. The agent sees the same UI the user sees: real platform chrome, real routing, real auth state. This makes it suitable for exploratory workflows where the agent does not know the widget tree in advance and needs to discover it interactively. ## What problem does it solve? Writing Flutter integration tests requires knowing the widget tree structure upfront, hard-coding `find.byKey` / `find.byType` locators, and re-running the full suite every time the UI changes. That works well for regression coverage but poorly for exploratory agent-driven automation, where the agent needs to navigate an unfamiliar UI, read its current state, and take adaptive actions. `tapir` solves this by exporting the Semantics tree as a YAML snapshot with stable `e` (snapshot-frozen) and re-resolvable `q` (minted by `tapir:find` or `tapir:observe`) ref tokens. The agent reads the snapshot, locates the target element by semantic role or label, and passes the ref token to an action extension. No test harness setup, no build step, no `flutter drive` orchestration needed. ## When to use it - **AI agent walkthroughs**: give Claude Code, Cursor, or Windsurf hands-on control of your Flutter app so it can inspect, fill forms, and verify flows during development or review. - **Manual E2E scripting**: drive gestures and input from a terminal session without writing widget tests, useful for ad-hoc QA against a staging build. - **Screenshot pipelines**: capture consistent screenshots of specific app states for documentation, design review, or visual regression baselines. - **CI smoke checks**: run a small set of tapir commands in CI to confirm critical paths render without crash, complementing (not replacing) widget unit tests. ## Requirements | Dependency | Minimum Version | Notes | |:-----------|:----------------|:------| | Dart | `^3.13.0` | The floor `material_ui` needs; Flutter 3.50 deprecates `package:flutter/material.dart` in its favour. | | Flutter | `>= 3.47.0` | The Semantics APIs and `RepaintBoundary` render-tree walk used internally. | | dowel | `^0.0.21` | Provides the MCP server, CLI framework, and `registerExtensionIdempotent`. | `tapir` requires Flutter. It cannot run on a pure-Dart environment because it synthesises pointer events against a live widget tree and walks the Semantics tree at runtime. The `dowel` CLI is the recommended host for the MCP server and all tapir CLI commands; install it before adding `tapir`. ## High-level workflow The typical agent session follows three phases: ``` 1. Capture snapshot -- tapir_snap / tapir:snap Walks the Semantics tree and emits a YAML file with every visible widget, its ref token, role, label, bounds, and any enricher-contributed metadata (a `wind:` block, `magicRoute`, ...). 2. Identify a ref -- read the YAML / tapir_find The agent scans the snapshot for the target widget by label or role, reads its ref token (e1, q3, ...). tapir_find re-resolves a token without a full re-snap. 3. Drive an action -- tapir_tap / tapir_type / tapir_scroll / ... The agent passes the ref token to an action tool. The extension looks up the live element, checks the actionability gate, synthesises the event, and returns the result or a structured error. ``` After an action the agent re-snaps to observe the updated UI and continues the loop. Screenshot requests can be inserted at any point to capture a PNG of the current viewport. ## Related Documentation - [Installation](https://anilcancakir.com/tapir/docs/getting-started/installation.md): new here? Start with adding the package and wiring `TapirPlugin.install()`. - [Quickstart](https://anilcancakir.com/tapir/docs/getting-started/quickstart.md): already installed? Run a first snap, tap and screenshot. - [Commands](https://anilcancakir.com/tapir/docs/commands/index.md): every `tapir:*` command grouped by intent. - [Reference: Actionability gate](https://anilcancakir.com/tapir/docs/reference/actionability-gate.md): how tapir decides whether a widget is safe to act on. --- # Installation Add `tapir` to a Flutter app, wire `TapirPlugin.install()` behind a `!kReleaseMode` guard, and optionally register the MCP server for your AI client. The recommended path is one command, `dart run tapir tapir:install`, which patches `lib/main.dart` and registers the plugin with dowel; the manual route is described step by step below. - [Quick Reference](#quick-reference) - [Requirements](#requirements) - [Add the package](#add-the-package) - [Wire TapirPlugin](#wire-tapirplugin) - [Optional integrations](#optional-integrations) - [Register with dowel (automatic)](#register-with-dowel) - [Wire MCP tools](#wire-mcp-tools) - [Verify installation](#verify-installation) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Quick Reference | Step | Command | Result | |:-----|:--------|:-------| | 1. Add the packages | `dart pub add dowel` and `flutter pub add tapir` | Both are direct dependencies. | | 2. Wire the app | `dart run tapir tapir:install` | `lib/main.dart` gains a `!kReleaseMode` block with `TapirPlugin.install()`; dowel registers the plugin. | | 3. Register the MCP server (optional) | `dart run tapir mcp:install` | A `.mcp.json` entry at the project root. | | 4. Start the app | `dart run tapir start --device=chrome` | dowel records the session that every `tapir:*` command dials. | | 5. Take a snapshot | `dart run tapir tapir:snap` | A YAML tree with `[ref=eN]` tokens. | ## Requirements `tapir` is a Flutter package. It requires a Flutter app target for the VM Service extensions to register against. Pure-Dart environments are not supported. | Dependency | Minimum | Recommended | |:-----------|:--------|:------------| | Dart | `^3.13.0` | latest stable | | Flutter | `>= 3.47.0` | latest stable | | dowel | `^0.0.21` | latest | Install `dowel` first if it is not already in your project. It provides the MCP server, the CLI framework, and the `registerExtensionIdempotent` helper that tapir uses internally for hot-restart safety. ```bash dart pub add dowel ``` ## Add the package Add `tapir` using the Flutter CLI: ```bash flutter pub add tapir ``` Alternatively, add it manually to `pubspec.yaml`: ```yaml dependencies: tapir: ^0.0.24 ``` Then fetch dependencies: ```bash flutter pub get ``` ## Wire TapirPlugin The recommended path is the CLI installer. From your project root, run: ```bash dart run tapir tapir:install ``` This patches your `lib/main.dart` automatically: it adds the `kReleaseMode` import, wraps `TapirPlugin.install()` in a `!kReleaseMode` guard, injects `WidgetsFlutterBinding.ensureInitialized()` when missing, and detects Magic-stack apps so `MagicTapirIntegration.install()` lands AFTER `Magic.init(...)` when the resolved `magic_devtools` ships `tapir.dart` (an older `magic_devtools` gets a warning and no Magic lines instead). The command is idempotent; re-running it is safe. See [`tapir:install`](https://anilcancakir.com/tapir/docs/commands/tapir-install.md) for the full sub-step list and the anchor strings the injector searches for. ### Manual wiring (when you'd rather edit `main.dart` yourself) Skip the CLI installer and edit `lib/main.dart` directly. Call `TapirPlugin.install()` inside a `!kReleaseMode` guard, after `WidgetsFlutterBinding.ensureInitialized()` and before `runApp()`. The guard is mandatory: release builds tree-shake the entire subsystem, so tapir never ships to end users. It is `!kReleaseMode` rather than `kDebugMode` so a profile build carries tapir too, which is the build `tapir:perf_run` measures; an app wired under `kDebugMode` keeps working in debug, and `tapir:install` leaves that block alone, but it registers no `ext.tapir.*` in a profile build until the guard is changed by hand. ```dart import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:tapir/tapir.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); if (!kReleaseMode) { TapirPlugin.install(); } runApp(const MyApp()); } ``` `TapirPlugin.install()` is idempotent: calling it more than once (for example during hot restart) is safe and registers each VM Service extension only once. ## Optional integrations Call additional `install()` methods inside the same `!kReleaseMode` block to enrich snapshots with framework-specific metadata. Both integrations are independent; install either, both, or neither depending on your stack. | Integration | Package | Enrichment | |:------------|:--------|:-----------| | `MagicTapirIntegration.install()` | `magic_devtools` (a release built on tapir) | MagicForm field values, validation state, named route per node. | | `Wind.installDebugResolver()` (in `package:wind_kit/wind_kit.dart`) | `wind_kit` (any release that ships `Wind.installDebugResolver()`) | Wind state surfaces through the neutral `WindDebugRegistry` bridge; tapir emits the six core fields (breakpoint, brightness, platform, states, bgColor, textColor) automatically without enricher registration. | ```dart import 'package:magic_devtools/tapir.dart'; // magic_devtools only import 'package:wind_kit/wind_kit.dart'; // wind_kit only // ... if (!kReleaseMode) { TapirPlugin.install(); MagicTapirIntegration.install(); // magic-stack only (from magic_devtools) Wind.installDebugResolver(); // wind UI only (from wind_kit) } ``` `package:magic_devtools/tapir.dart` exists only in a `magic_devtools` release built on tapir; an older release has no tapir integration yet, so upgrade `magic_devtools` first. `tapir:install` writes the Magic lines only when the resolved package ships `tapir.dart` and otherwise warns. See [Magic integration](https://anilcancakir.com/tapir/docs/plugins/magic-integration.md) and [Wind integration](https://anilcancakir.com/tapir/docs/plugins/wind-integration.md) for the full enricher field reference. ## Register with dowel (automatic) `tapir:install` already handles this for you as Phase 2 of its run. Right after the `lib/main.dart` patch lands, it chains `dart run tapir install` (scaffolds `bin/dispatcher.dart` + `./bin/dowel` fastcli, ~50ms warm AOT) followed by `dart run tapir plugin:install tapir` (registers `TapirDowelProvider` and auto-purges the AOT bundle cache). Both sub-process calls skip when their idempotency markers already exist (`bin/dispatcher.dart` and `.dowel/installed/tapir.json` respectively), so re-running `tapir:install` is a fast no-op. If the chained calls fail (no `dart` on PATH, partial pub-cache, restricted sandbox), `tapir:install` falls through with a warning and exits 0; the `lib/main.dart` patch already landed, so `dart run tapir ` still works. You can finish the setup manually: ```bash dart run tapir install # only if Phase 2 was skipped dart run tapir plugin:install tapir # idempotent; refreshes barrels on re-run ``` After Phase 2 lands, `./bin/dowel list` shows every `tapir:*` command and `./bin/dowel mcp:serve` exposes every `tapir_*` tool. The optional `mcp:install` step in the next section writes the plugin-aware `.mcp.json` payload by default. When fastcli (`./bin/dowel`) is present, the entry uses `./bin/dowel mcp:serve`; when it is absent, the wrapper's `--invocation=tapir` pass-through causes `mcp:install` to write `dart run tapir mcp:serve` instead. Either way, no manual `.mcp.json` edit is needed. ## Wire MCP tools With dowel registered, expose the tapir MCP tools to your AI client by writing the `.mcp.json` entry: ```bash dart run tapir mcp:install ``` This writes (or updates) a `.mcp.json` file at the project root. Claude Code, Cursor, and Windsurf all pick up `.mcp.json` automatically from the working directory. The payload depends on your scaffold state: - **fastcli present** (`./bin/dowel` exists, POSIX): writes `./bin/dowel mcp:serve`, the fastest startup (~50ms warm AOT). - **fastcli absent**: the wrapper injects `--invocation=tapir` automatically, so the entry writes `dart run tapir mcp:serve` (~3s startup, no scaffold required). See [Fallback invocations](https://anilcancakir.com/tapir/docs/mcp/setup.md#fallback-invocations) for the full precedence table. ## Verify installation Start your Flutter app through dowel, so the session (the VM Service URI every `tapir:*` command dials) is recorded for this project: ```bash dart run tapir start --device=chrome ``` Then capture the first Semantics snapshot: ```bash dart run tapir tapir:snap ``` A successful snap prints a YAML tree of lines such as `- button "Sign in" [ref=e2]`. Each interactive node carries a `[ref=eN]` token (`e1`, `e2`, ...) that you pass to action commands. If the command exits with an error, confirm that `TapirPlugin.install()` is reachable in your `main.dart` and that the app runs in a debug or profile build (a release build tree-shakes tapir, so no `ext.tapir.*` extension registers). Stop the app with `dart run tapir stop` when you are done. ## Gotchas | Mistake | Fix | |:--------|:----| | `TapirPlugin.install()` sits outside a `!kReleaseMode` guard. | Wrap it: `if (!kReleaseMode) { TapirPlugin.install(); }`. A `kDebugMode` guard works in debug but registers no `ext.tapir.*` in a profile build. | | `MagicTapirIntegration.install()` runs before `Magic.init(...)`. | Call it after `Magic.init(...)`: it queries `Magic.find()`, which only resolves once the container is ready. `tapir:install` places it correctly. | | The app was started with a plain `flutter run`, and `tapir:snap` finds no app. | Start it with `dart run tapir start`, which records the session the `tapir:*` commands dial. | | `./bin/dowel` is missing after `tapir:install`. | Phase 2 was skipped with a warning. Run `dart run tapir install` and `dart run tapir plugin:install tapir`; `dart run tapir ` works meanwhile. | | The snapshot has no `wind:` block. | Call `Wind.installDebugResolver()` inside the same `!kReleaseMode` block (`tapir:install` does not add it), then snap with `dart run tapir tapir:snap --includeEnrichers`: enrichers are off by default, so a plain snapshot never carries the block. | | `.mcp.json` points at the wrong launcher. | Re-run `dart run tapir mcp:install` rather than editing the file; it picks `./bin/dowel mcp:serve` or `dart run tapir mcp:serve` from the scaffold state. | ## Related Documentation - [Quickstart](https://anilcancakir.com/tapir/docs/getting-started/quickstart.md): a first snap, tap and screenshot against the bundled example app. - [tapir:install](https://anilcancakir.com/tapir/docs/commands/tapir-install.md): the sub-steps and anchor strings behind the installer. - [MCP setup](https://anilcancakir.com/tapir/docs/mcp/setup.md): wire the server into an AI client and the fallback invocations. - [Magic integration](https://anilcancakir.com/tapir/docs/plugins/magic-integration.md) and [Wind integration](https://anilcancakir.com/tapir/docs/plugins/wind-integration.md): the enricher fields each integration adds. --- # Quickstart A three-step walkthrough from zero to driving the bundled `example/` Flutter app with a snapshot, a tap and a screenshot through the tapir CLI. Prerequisites: Flutter 3.47 or newer (Dart 3.13, the package and example floor) and a clone of the repository. The `example/` app already calls `TapirPlugin.install()` inside `!kReleaseMode` and resolves `tapir` from the parent directory. To drive your own app instead, follow [Installation](https://anilcancakir.com/tapir/docs/getting-started/installation.md) first. - [1. Start the example app and capture a snapshot](#1-start-the-example-app) - [2. Tap a widget using its ref token](#2-tap-a-widget) - [3. Capture a screenshot](#3-capture-a-screenshot) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) --- ## 1. Start the example app and capture a snapshot Start the app through dowel, so the session (the VM Service URI every `tapir:*` command dials) is recorded. The `--cdp-port` makes web screenshots reliable in step 3; on a native target (`--device=macos`, for example) leave it out: ```bash cd example flutter pub get dart run tapir start --device=chrome --cdp-port=9333 ``` Once the app is up, run `tapir:snap` in the same directory to walk the live Semantics tree. The example has many widgets, so narrow the walk with `--grep` to the counter button: ```bash dart run tapir tapir:snap --grep=Counter ``` The command connects to the running app via the VM Service and prints the snapshot to stdout. An illustrative output (ancestor lines trimmed): ```yaml - button "Counter: 0" [ref=e1] ``` Each interactive node carries a `[ref=eN]` token (here `e1`). Tokens are tied to the snapshot that issued them: a node that appears again in a later `tapir:snap` may keep its token, a node that was rebuilt gets a new one, so read the refs from the latest snapshot. They are what you pass to action commands in the next step. --- ## 2. Tap a widget using its ref token Pass the ref token from the snapshot to `tapir:tap`: ```bash dart run tapir tapir:tap --ref=e1 ``` The extension looks up `e1` in the snapshot registry, runs the actionability gate (see [Reference: Actionability gate](https://anilcancakir.com/tapir/docs/reference/actionability-gate.md): the widget must be enabled, have a non-zero rect, be on-viewport, stable, and receive pointer events), then synthesises a pointer-down and pointer-up pair at the widget's centre. The app responds as if a real user tapped the button. A successful tap prints one line: ``` ✓ Tapped e1 ``` When nothing observable changed, the line reads `Tapped e1 (no observable change)`; read it before concluding the tap worked. Add `--json` for the full envelope, which carries the `effect` block. If the widget fails the actionability gate, the command exits with an error message: ``` Widget ref=e1 is not actionable: not enabled ``` Re-snap after a tap to confirm the UI updated. The refreshed snapshot shows the button as `Counter: 1`; read its ref again, because it may or may not match the earlier one: ```bash dart run tapir tapir:snap --grep=Counter ``` --- ## 3. Capture a screenshot Capture an image of the current viewport to verify the app state visually or to save a baseline: ```bash dart run tapir tapir:screenshot --output=counter_after_tap.png ``` On web with `--cdp-port` the command captures through Chrome DevTools Protocol; on a native target it asks the app's `RepaintBoundary` for a frame. The file lands relative to your current working directory, and the format follows the `--output` extension. Expected output: ``` ✓ Wrote 48213 bytes (47.1 KB, png) to counter_after_tap.png ``` Open the file to confirm the button reads `Counter: 1`. You now have the full snap, tap and screenshot loop working end to end. Stop the app with `dart run tapir stop`. --- ## Gotchas | Mistake | Fix | |:--------|:----| | Running `flutter run` by hand, then `tapir:snap` finds no app. | Start the app with `dart run tapir start`, which records the session the `tapir:*` commands dial. | | Tapping an `eN` ref from an earlier snapshot after the screen changed. | Re-snap and use the ref from that snapshot, or mint a `qN` handle with `tapir:find` that survives rebuilds. | | The web screenshot hangs or returns an error. | Start with `--cdp-port=` so the CLI captures through Chrome DevTools Protocol. | | The tap printed `no observable change`. | Nothing changed in the target's own subtree or the route; that does not prove the handler did not fire. Re-snap and check the intended result before retrying. | ## Related Documentation - [Commands](https://anilcancakir.com/tapir/docs/commands/index.md): every `tapir:*` command with its flags. - [MCP setup](https://anilcancakir.com/tapir/docs/mcp/setup.md): let Claude Code or Cursor call the tapir tools directly during a conversation. - [Magic integration](https://anilcancakir.com/tapir/docs/plugins/magic-integration.md) and [Wind integration](https://anilcancakir.com/tapir/docs/plugins/wind-integration.md): enrich snapshots with framework-specific metadata (`MagicTapirIntegration.install()`, `Wind.installDebugResolver()`). - [Reference: Actionability gate](https://anilcancakir.com/tapir/docs/reference/actionability-gate.md): how tapir decides whether a widget is safe to interact with. --- # Driving real apps: gotchas for agents The recurring traps of a long real-app E2E session, each with the workaround `tapir` has built in. Every agent re-discovers these the hard way. Read the page once before driving a non-trivial app; it will save you a dozen dead-end round trips. - [Quick Reference](#quick-reference) - [1. Refs go stale on rebuild](#1-refs-go-stale-on-rebuild) - [2. Text fields may snapshot nested](#2-text-fields-may-snapshot-nested) - [3. tapir:console captures debugPrint in-package now](#3-tapirconsole) - [4. tapir:exceptions is cumulative](#4-tapirexceptions-is-cumulative) - [5. restart preserves the CDP port](#5-restart-preserves-the-cdp-port) - [6. Overlays get stuck](#6-overlays-get-stuck) - [7. A green tick is dispatch, not effect](#7-a-green-tick-is-dispatch-not-effect) - [8. A hidden desktop app](#8-a-hidden-desktop-app) - [Related Documentation](#related-documentation) ## Quick Reference | Trap | Use | |:-----|:----| | An `e` ref fails after a rebuild or navigation. | Re-snap with `tapir:snap`, or mint a `q` handle with `tapir:find` / `tapir:observe`. | | A text field snapshots as two nested `textbox` nodes. | Target the node marked `typeable: true`, or use `tapir:fill`. | | `tapir:console` misses `debugPrint` output. | Nothing to do: `TapirPlugin.install()` captures it in-package. | | `tapir:exceptions` keeps reporting an old error. | Pass `--since=` recorded before the action. | | A web restart loses the CDP port. | Nothing to do: `restart` reuses the recorded `cdpPort`. | | A dialog, sheet or menu blocks the next screen. | Run `tapir:reset_overlays`. | | A green tick with no visible result. | Read the `effect` block on the response. | | A hidden desktop app reports no change. | Nothing by default; read `framesForced` and `warnings` on the response. | --- ## 1. Refs go stale on rebuild `e` refs from `tapir:snap` freeze the widget at snapshot time. A navigation, a modal open/close, a `setState`, or any significant rebuild invalidates them: the next action on a stale `e` fails with a not-found or stale envelope. **Workaround:** - Re-snap (`tapir:snap`) after any action that changes the screen, and use the fresh refs. - For a target that survives re-renders (a stable `Text`, accessibility label, or `Key`), prefer `tapir:find` / `tapir:observe`, which mint a re-resolvable `q` handle that re-walks the live tree on every action call. `q` handles survive rebuild, route push, and snapshot disposal as long as the predicates still match. - Pointer verbs (`tapir:tap`, `tapir:hover`, `tapir:drag`, ...) now dispatch at the element's LIVE rect, not the cached snapshot rect, so a target that merely shifted slots (same `Element`/`RenderObject`) is still hit correctly. The false-success class (gate passes, pointer lands on the stale position, `onTap` never fires) is gone for slot shifts. - An `e` ref whose widget was rebuilt is relocated by its label when the live tree still has the same number of nodes with that label on the same route, and the response names it under `reresolved`. When the count differs, or you have navigated to another route, the ref still fails as `defunct (element no longer mounted)`; re-snap. --- ## 2. Text fields may snapshot nested A wind `WInput` (and any `TextField` wrapped in `Semantics(textField: true)`) historically snapshotted as TWO nested `textbox` nodes, because `RenderEditable` unconditionally owns its own `textField` Semantics node and `MergeSemantics` cannot absorb it. Agents naturally targeted the inner leaf, where `tapir:type` threw a `-32000`. **Workaround:** - `tapir:snap` now collapses the nested pair (by render-object containment, never label/value equality, so two sibling fields sharing a label stay distinct) and emits a single ref for the outer node, marked `typeable: true`. Target the node carrying `typeable: true`. - Better: use `tapir:fill --ref= --text=`, which focuses, clears, types, and settles in one call (and retries once on a stale handle). It resolves the right editable for you and composes the gated focus/clear/type handlers, so you do not re-build the focus + clear + type + settle dance. --- ## 3. tapir:console captures debugPrint in-package now `tapir:console` historically surfaced full structured logs only when `crumbs` was installed and wired. **Workaround:** - `TapirPlugin.install()` now chains a `debugPrint` override that records every `debugPrint(...)` / `print(...)` call into a bounded in-package ring buffer, so those entries appear in `tapir:console` even without crumbs. - When crumbs IS installed it enriches the output with `Logger.root.onRecord` entries and its other watchers. Direct `dart:developer log()` calls that bypass `debugPrint` still require the crumbs `LogWatcher`. --- ## 4. tapir:exceptions is cumulative `tapir:exceptions` returns the full exception history by default, so a single pre-existing error keeps re-appearing after every action and produces false positives when you are checking whether YOUR action raised something new. **Workaround:** - Record the current time before the action, then pass `tapir:exceptions --since=` afterwards to get only exceptions raised strictly after that timestamp. Unparseable `since` values are treated as absent (full list). --- ## 5. restart preserves the CDP port When dowel was started with `--cdp-port` (the web path that backs `tapir:screenshot` CDP fallback, `tapir:resize`, `tapir:device`), a naive restart used to drop the port, breaking those CDP-routed tools until you restarted with the flag again. **Workaround:** - `dowel restart` (and bare `start`) now re-read the prior `cdpPort` from this project's session state (`state.json` under `~/.dowel/sessions/`) and reuse it as the default, so CDP stays wired across restarts. An explicit `--cdp-port` still wins. --- ## 6. Overlays get stuck A left-over dialog, bottom sheet, dropdown menu, or barrier modal blocks the next screen from rendering, and a single `tapir:modal` (dismiss-modals) does not clear overlays that are not `PopupRoute`s. **Workaround:** - Use `tapir:reset_overlays`, which runs three idempotent layers: dismiss every `PopupRoute`, press `Escape` (for shortcut-driven overlays), then tap a Cancel/Dismiss/Close/OK/Done affordance as a last resort. Safe to call speculatively between flows; the response (`popped`, `escaped`, `dismissTapped`) tells you which layer cleared the screen. --- ## 7. A green tick is dispatch, not effect `✓ Tapped e7` and `✓ Filled e5` say the event left tapir, not that the widget took it. A number field can swallow a fill and keep nothing; a button the gate accepted can have an `onTap` that never fires. **Workaround:** - Read the `effect` block on the response: `effect.changed` for `tap` and `scroll`, `effect.verified` and `effect.value` for `type`, `fill` and `clear`. - In summary mode `tapir:fill` and `tapir:type` exit `1` when `effect.verified` is false, and `tapir:navigate`, `tapir:navigate_back` and `tapir:scroll` say so when nothing happened. With `--json` or `--includeSnapshot` you get the raw envelope and read the field yourself. --- ## 8. A hidden desktop app A hidden, minimised or occluded window moves the app to `AppLifecycleState.hidden` and stops frame production, so a tap used to report "no observable change", a snapshot went stale and a screenshot returned the last cached frame. This is not specific to a browser tab. **Workaround:** - Nothing, by default: tapir delivers `resumed` and forces a frame at the start of each verb, and a response that did so carries `framesForced: true`. Verified on macOS with the window hidden. The app's own lifecycle observers see `resumed`. - If a response still carries a `warnings` block, tapir could not force frames (`--dart-define=TAPIR_FORCE_FRAMES=false`, a `detached` app, a wake that produced no frame, or any hidden web tab). Bring the window to front and retry. - An asleep macOS display can still starve frames; `caffeinate -u -t 3` wakes it (unverified for tapir). See [Frame production](https://anilcancakir.com/tapir/docs/reference/frame-production.md). --- ## Related Documentation - [tapir:fill](https://anilcancakir.com/tapir/docs/commands/tapir-fill.md) and [tapir:reset_overlays](https://anilcancakir.com/tapir/docs/commands/tapir-reset-overlays.md): the two built-in workarounds for nested text fields and stuck overlays. - [tapir:tap](https://anilcancakir.com/tapir/docs/commands/tapir-tap.md): the `effect` block and `--until`. - [tapir:exceptions](https://anilcancakir.com/tapir/docs/commands/index.md#console-and-exceptions) and [tapir:find](https://anilcancakir.com/tapir/docs/commands/tapir-find.md): the `--since` delta and the re-resolvable `q` handle. - [Reference: Actionability gate](https://anilcancakir.com/tapir/docs/reference/actionability-gate.md): why an action is refused and with which reason. - [Reference: Frame production](https://anilcancakir.com/tapir/docs/reference/frame-production.md): the hidden-window and backgrounded-tab failure mode. --- # Commands Every `tapir` command, grouped by intent, with its boot mode and the VM Service extension behind it. Every command is invoked as `dart run tapir ` (Flutter-free wrapper at `bin/tapir.dart`), or via the consumer-side dowel dispatcher (`./bin/dowel ` / `dart run dowel `) once the project has run `tapir:install`. Commands are auto-discovered through `TapirDowelProvider`; nothing wires by hand. Need a quick reminder of what a command does without leaving the terminal? Run `dart run dowel list` for the full registry grouped by namespace, or `dart run dowel help ` for the per-command flag surface. This page exists for the deeper view: the boot mode, the backing VM Service extension, and the grouping rationale. - [How to read this page](#how-to-read-this-page) - [Snapshot and screenshot](#snapshot-and-screenshot) - [Gestures](#gestures) - [Inputs](#inputs) - [Navigation](#navigation) - [Find](#find) - [Diagnostics](#diagnostics) - [Install](#install) - [CDP](#cdp) - [Click variants](#click-variants) - [Focus and blur](#focus-and-blur) - [Console and exceptions](#console-and-exceptions) - [Observe](#observe) - [Hot reload and snap](#hot-reload-and-snap) - [Batch](#batch) - [Performance](#performance) - [Boot mode and deep-dives](#boot-mode-and-deep-dives) - [Related Documentation](#related-documentation) ## How to read this page Each group section ships a single table with four columns: - **Command** is the canonical name you type after `dart run tapir` (or via the dowel dispatcher). - **Description** is a one-line summary of the command; `dart run dowel list` prints each command's own wording, which differs for a few. - **Boot Mode** is the `CommandBoot` value the dispatcher reads before invoking `handle()`. `none` means pure CLI: no VM Service connection. `connected` means the command dials the VM Service URI recorded for this project's dowel session and fails fast if no app is running. - **VM Extension** is the `ext.tapir.*` method the command calls over the VM Service. `none` for commands that operate purely on the consumer filesystem. Deep-dive pages exist for the commands whose flag surface, return shape, or composition rules outgrow a single table row; the tables below link them. The other commands share this index page; reach for `dart run dowel help ` for their full flag surface. ## Snapshot and screenshot The two foundational read commands. `tapir:snap` walks the Semantics tree and emits YAML with `[ref=eN]` tokens that every subsequent action command consumes. `tapir:screenshot` captures a frame of the running app and writes it to a file. | Command | Description | Boot Mode | VM Extension | |---------|-------------|-----------|--------------| | [`tapir:snap`](https://anilcancakir.com/tapir/docs/commands/tapir-snap.md) | Capture Semantics tree YAML of the running Flutter app with [ref=eN] tokens. | connected | ext.tapir.snap | | [`tapir:screenshot`](https://anilcancakir.com/tapir/docs/commands/tapir-screenshot.md) | Capture a screenshot of the running Flutter app to a file. | connected | ext.tapir.screenshot | ## Gestures Pointer-driven actions that synthesise touch, mouse, or pen events at a widget located by ref token. `tapir:tap`, `tapir:hover` and `tapir:drag` route through the actionability gate (see [Reference: Actionability gate](https://anilcancakir.com/tapir/docs/reference/actionability-gate.md)) before the event leaves the VM. `tapir:scroll` skips the gate on purpose: it acts on the parent scrollable, so it can bring an off-viewport target into view (see [Intentional gate skips](https://anilcancakir.com/tapir/docs/reference/actionability-gate.md#intentional-gate-skips)). | Command | Description | Boot Mode | VM Extension | |---------|-------------|-----------|--------------| | [`tapir:tap`](https://anilcancakir.com/tapir/docs/commands/tapir-tap.md) | Tap a widget by ref token (from prior tapir:snap). | connected | ext.tapir.tap | | `tapir:hover` | Hover the pointer over a widget by ref token (from prior tapir:snap). | connected | ext.tapir.hover | | `tapir:drag` | Drag from one widget to another using ref tokens from a prior tapir:snap. | connected | ext.tapir.drag | | `tapir:scroll` | Scroll inside a scrollable widget by ref token from a prior tapir:snap. | connected | ext.tapir.scroll | ## Inputs Keyboard-shaped actions. `tapir:type` emits a character sequence; `tapir:press_key` synthesises a single hardware-key event; `tapir:clear` empties the focused text field; `tapir:select_option` calls the `onChanged` of a `DropdownButton`; `tapir:set_checkbox` sets a checkbox, switch or radio from its Semantics checked or toggled state. | Command | Description | Boot Mode | VM Extension | |---------|-------------|-----------|--------------| | `tapir:type` | Type text into a focused widget by ref token. | connected | ext.tapir.type | | [`tapir:fill`](https://anilcancakir.com/tapir/docs/commands/tapir-fill.md) | Focus, clear, type, and settle a text field by ref in one call (retries once on a stale handle). | connected | ext.tapir.fill | | `tapir:press_key` | Synthesise a hardware-key event on the currently focused widget. | connected | ext.tapir.press_key | | `tapir:clear` | Empty the text content of the focused widget by ref. | connected | ext.tapir.clear | | `tapir:select_option` | Select an option in a DropdownButton by ref token (calls its `onChanged`). | connected | ext.tapir.select_option | | `tapir:set_checkbox` | Set the checked state of a checkbox, switch or radio by ref, from any library. | connected | ext.tapir.set_checkbox | | `tapir:wait` | Wait for a text, text-gone, or expression condition in the running app. | connected | ext.tapir.wait_for | | `tapir:wait_for_network_idle` | Wait until the running app reports zero in-flight HTTP requests for a contiguous idleMs window. | connected | ext.tapir.wait_for_network_idle | ## Navigation Route-table manipulation against the active `Navigator`. `tapir:modal` dismisses any open modal, sheet, or dialog. `tapir:close_app` ends the session via `SystemNavigator.pop()`. | Command | Description | Boot Mode | VM Extension | |---------|-------------|-----------|--------------| | `tapir:navigate` | Navigate the running app to a named route via the active Navigator. | connected | ext.tapir.navigate | | `tapir:navigate_back` | Pop the topmost route off the active Navigator (mirrors browser back). | connected | ext.tapir.navigate_back | | `tapir:get_routes` | Print the mounted Router's location (`uri`), the top page name and a title hint as JSON. | connected | ext.tapir.get_routes | | `tapir:modal` | Dismiss all open modals, bottom sheets, and dialogs in the running app. | connected | ext.tapir.dismiss_modals | | [`tapir:reset_overlays`](https://anilcancakir.com/tapir/docs/commands/tapir-reset-overlays.md) | Reset to a clean screen: dismiss modals + Escape + Cancel-tap fallback (idempotent). | connected | ext.tapir.reset_overlays | | `tapir:close_app` | Gracefully close the running app via SystemNavigator.pop(). | connected | ext.tapir.close_app | ## Find The Playwright Locator surface: mint a re-resolvable `q` handle backed by text, semanticsLabel, or key predicates. Every action call against a `q` handle re-walks the live tree, so the handle survives intermediate rebuilds. | Command | Description | Boot Mode | VM Extension | |---------|-------------|-----------|--------------| | [`tapir:find`](https://anilcancakir.com/tapir/docs/commands/tapir-find.md) | Mint a re-resolvable q-handle by text / semanticsLabel / key (Playwright Locator pattern). | connected | ext.tapir.find | ## Diagnostics Pure-CLI checks that verify the consumer wiring and the running session health. Neither command dials the VM Service. | Command | Description | Boot Mode | VM Extension | |---------|-------------|-----------|--------------| | [`tapir:doctor`](https://anilcancakir.com/tapir/docs/commands/tapir-doctor.md) | Verify tapir plugin runtime + consumer wiring health | none | none | ## Install The one-shot bootstrap. Patches the consumer's `lib/main.dart`, then chains `dowel install` and `plugin:install tapir` on a best-effort basis; `tapir` ships its own Flutter-free CLI entry point, so the chain only adds the faster `./bin/dowel` path. Idempotent on re-run. | Command | Description | Boot Mode | VM Extension | |---------|-------------|-----------|--------------| | [`tapir:install`](https://anilcancakir.com/tapir/docs/commands/tapir-install.md) | Wire TapirPlugin.install() into lib/main.dart AND chain dowel install + plugin:install so ./bin/dowel surfaces every tapir:* command (idempotent on re-run; Phase 2 chain is best-effort). | none | none | ## CDP Chrome DevTools Protocol commands that manipulate the browser viewport directly. Web target only; desktop and mobile no-op gracefully. | Command | Description | Boot Mode | VM Extension | |---------|-------------|-----------|--------------| | `tapir:device` | Emulate a device profile (viewport + DPR + touch + user agent) via Chrome DevTools Protocol. The emulation lasts only as long as the command's CDP session, which closes on exit; only the window size stays. | none | none (CDP direct) | | `tapir:resize` | Resize the running Flutter web app viewport via Chrome DevTools Protocol. Chrome drops the override when the command exits; `--hold` keeps it until Ctrl-C or Chrome exits. | none | none (CDP direct) | ## Click variants Pointer gestures that go beyond the primary tap. All three route through the actionability gate. | Command | Description | Boot Mode | VM Extension | |---------|-------------|-----------|--------------| | `tapir:dblclick` | Fire a double-click at the widget identified by a snapshot ref. | connected | ext.tapir.dblclick | | `tapir:triple_click` | Fire three primary clicks (~100ms apart) at the widget identified by --ref. | connected | ext.tapir.triple_click | | `tapir:right_click` | Fire a right (secondary mouse button) click at the widget identified by --ref. | connected | ext.tapir.right_click | ## Focus and blur Keyboard-focus shaping. `tapir:focus` requests focus on a ref; `tapir:blur` releases the currently focused widget. | Command | Description | Boot Mode | VM Extension | |---------|-------------|-----------|--------------| | `tapir:focus` | Request keyboard focus on the widget identified by --ref. | connected | ext.tapir.focus | | `tapir:blur` | Remove keyboard focus from the currently-focused widget. | connected | ext.tapir.blur | ## Console and exceptions Diagnostics readers backed by tapir's own in-package capture plus an optional crumbs augmentation. `tapir:console` reads from tapir's in-package `debugPrint` ring buffer (populated by `installLogCapture()`, wired by `TapirPlugin.install()`). This buffer captures every call that routes through the `debugPrint` global callback (`debugPrint(...)`, `print(...)`, and any Flutter framework path that calls `debugPrint`). It does NOT capture direct `dart:developer` `log()` calls that bypass `debugPrint`; those require the crumbs `LogWatcher`. When crumbs is wired the two sources are merged and deduped; crumbs also adds `package:logging` (`Logger.root.onRecord`) and any other watcher it ships. `tapir:exceptions` is similar: tapir's own `FlutterError.onError` capture (non-fatal errors including overflow) is always present; crumbs augments when wired. The `--since=` flag on `tapir:exceptions` (and the matching `since` param on `tapir_exceptions` MCP) lets agents compute true before/after deltas: record the time before an action, then call `tapir:exceptions --since=