Tapir logo

Tapir

Give your AI agent eyes and hands on a running Flutter app.
Snapshot the Semantics tree, tap, type, scroll, screenshot and profile the live app from one CLI and one MCP server, with no flutter_test harness and no test file between actions.

pub package CI codecov License: MIT pub points

Documentation · pub.dev · Issues

At a glance

Fact Value
Package tapir
Install dart pub add tapir, then dart run tapir tapir:install
Import package:tapir/tapir.dart (TapirPlugin.install(), behind if (!kReleaseMode))
Entry points dart run tapir <command>, ./bin/dowel <command>, TapirPlugin.install(), TapirPlugin.enrichers, TapirDowelProvider (package:tapir/cli.dart), dart run tapir mcp:serve
Platforms Any Flutter target that exposes a VM Service in a debug or profile build: Android, iOS, web, macOS, Windows, Linux; viewport and device emulation need Chrome with a CDP port; tapir_hot_reload_and_snap writes to the flutter run stdin FIFO, so it needs a POSIX host; on a Windows host mcp:install writes a dart run entry, never ./bin/dowel
Requires Dart 3.13, Flutter 3.47; a plugin of dowel (^0.0.21)
Dependencies dowel, image, meta, wind_kit_diagnostics_contracts, yaml, the Flutter SDK
License MIT

Install

dart pub add tapir
dart run tapir tapir:install
dart run tapir start --device=macos   # dowel records the session every tapir:* command dials
dart run tapir tapir:snap

tapir:install patches lib/main.dart (the import, WidgetsFlutterBinding.ensureInitialized() when missing, and TapirPlugin.install() inside a !kReleaseMode guard), then scaffolds the fast ./bin/dowel wrapper and registers tapir as a dowel plugin. Re-running it changes nothing. The installation guide also adds dowel as a direct dependency (dart pub add dowel). Wired by hand, main.dart looks like this:

import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:tapir/tapir.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  if (!kReleaseMode) {
    TapirPlugin.install();
  }
  runApp(const MyApp()); // MyApp is the host app's root widget.
}

The guard is !kReleaseMode, not kDebugMode: debug and profile builds carry tapir (tapir:perf_run measures a profile build) and a release build tree-shakes it. Without the guard tapir ships in release, and anyone who reaches that app's VM Service can drive it. --dart-define=TAPIR_DISABLE=1 skips registration in a build that must not expose the extensions. The example/ app gives each verb a live target widget.

Snapshot and refs

dart run tapir tapir:snap --interactiveOnly
# - textbox "Email" [ref=e1]
#   typeable: true
# - button "Sign in" [ref=e2]
Token Minted by Lifetime
e<N> tapir:snap The same Semantics node keeps its token across snapshots, and a token outlives its widget. A ref whose element left the tree is relocated by label (reresolved on the response) only when the snapshot recorded a binding for it, the live tree has the same number of nodes with that label and the match sits on the route the ref was minted on; otherwise it fails as defunct and you re-snap
q<N> tapir:find, tapir:observe Re-resolved against the live tree on every action; survives rebuilds

Narrow a read with --within=<eN>, --interactiveOnly and --grep=<regex>; they compose. --includeEnrichers adds Wind state (the host calls Wind.installDebugResolver() from wind_kit, read through wind_kit_diagnostics_contracts) and Magic state (MagicTapirIntegration.install() from package:magic_devtools/tapir.dart, after Magic.init(); it exists only in a magic_devtools release built on tapir, and tapir:install warns and skips it otherwise; see Magic integration). A ref line ending in [offscreen] or [occluded] names why an action would fail before you try it.

Actions and the actionability gate

dart run tapir tapir:tap --ref=e2 --snapshot=diff
dart run tapir tapir:fill --ref=e1 --text="ada@example.com" --json

Gestures (tap, hover, drag, scroll, dblclick, right_click, triple_click) and text verbs (type, clear, fill, press_key) dispatch real pointer and key events into the widget tree. tap, hover, drag, the click variants, type and fill first pass the gate (clear, scroll, press_key, select_option and set_checkbox skip it), in a fixed order, and a refusal names the reason an agent can branch on:

Check Refusal substring Recovery
Defunct (preflight) defunct (...) Re-snap
Enabled not enabled Re-snap; the widget may enable later
Zero rect zero rect Re-snap or re-find
Off-viewport (scrolls into view first) off-viewport tapir:scroll into view, then retry
Stable across two frames not stable Wait for the animation; --no-checkStable
Receives events (hit test) obscured by Dismiss the overlay (tapir:reset_overlays); --no-checkReceivesEvents

A green tick is dispatch, not effect. Add --json to print the whole response and read the effect block: effect.changed for tap and scroll, effect.verified and effect.value for type, fill and clear. When the hit test could not answer (routine on a web debug build) the response of a gated verb such as tap or type carries a checks block with receivesEvents: indeterminate instead of a silent pass; fill reports only its effect. tapir:run "tap --ref=e5" "snap --grep Counter" batches verbs into one round trip.

Waiting and observing

dart run tapir tapir:observe --roles=button,textbox   # q<N> handles, re-resolved per action
dart run tapir tapir:wait --text="Welcome back" --timeoutMs=5000
Verb Returns
tapir:wait Waits for --text, --textGone or --expression
tapir:wait_for_network_idle Waits until in-flight HTTP requests stay at zero for --idleMs
tapir:observe A flat candidate list of interactive widgets with q<N> handles: observe once, act many times (no model call inside tapir)
tapir:console, tapir:exceptions In-app debugPrint output and FlutterErrors, overflow included; --since=<iso8601> scopes errors to one action
tapir:get_routes The router's location and the top page (tapir:navigate and tapir:navigate_back change it)

When the app is not producing frames (a hidden window or a backgrounded tab) tapir forces frames outside the web and reports framesForced: true; where it cannot, every response carries a warnings block rather than a stale answer that looks fresh. See frame production.

Screenshots and web

dart run tapir tapir:screenshot --output=shot.jpg
dart run tapir start --device=chrome --cdp-port=9333
dart run tapir tapir:device --preset=pixel-8
Surface Behaviour
tapir:screenshot JPEG (default) or PNG of the viewport, or of one widget with --ref (and a sub-rect of it with --rect)
Web screenshot With --cdp-port, the CLI captures through CDP Page.captureScreenshot; the tapir_screenshot MCP tool stays in-isolate and can time out under CanvasKit, so prefer the CLI on web
tapir:device Presets iphone-x, iphone-13, iphone-15-pro, pixel-5, pixel-8, ipad-pro-12.9, desktop-1440, desktop-1920 (viewport, DPR, touch, user agent); the emulation ends with the command, the window size stays. tapir:resize --width --height overrides the viewport until exit, or longer with --hold
tapir:hot_reload_and_snap Hot reload, then snapshot, screenshot and recent exceptions in one round trip

Performance tracing

dart run tapir tapir:perf_begin --mode=attribution
dart run tapir tapir:tap --ref=e7
dart run tapir tapir:perf_end
Verb Does
tapir:perf_begin, tapir:perf_end Bracket one interaction; attribution ranks the widget builds that cost time next to Wind and Magic counters, timing reports frame timings you can quote. A session in which the engine rendered nothing is refused (refused: true, exit 1), never reported as zeros that read as fast
tapir:perf_insight The rows behind one insight of the last report
tapir:perf_run, tapir:perf_compare, tapir:perf_campaign Repeat a scenario from a clean start and write medians, judge one run against another, run a campaign of scenarios from cold starts
tapir:perf_trace --out=trace.json The last session as a Chrome Trace JSON for ui.perfetto.dev

MCP server and clients

The tapir_* tools ride on the dowel MCP server, so the client config key is dowel. dart run tapir mcp:install writes the entry into .mcp.json: ./bin/dowel mcp:serve when the fast wrapper exists on a POSIX host, and dart run tapir mcp:serve otherwise (always on Windows, where ./bin/dowel is not used). Standard config for any client that reads .mcp.json:

{ "mcpServers": { "dowel": { "command": "./bin/dowel", "args": ["mcp:serve"], "cwd": "." } } }
Client Install
Claude Code claude mcp add dowel -- ./bin/dowel mcp:serve, then /mcp reconnect dowel after a change
VS Code code --add-mcp '{"name":"dowel","type":"stdio","command":"dart","args":["run","tapir","mcp:serve"]}'
Cursor .cursor/mcp.json (project) or ~/.cursor/mcp.json (user)
Windsurf ~/.codeium/windsurf/mcp_config.json
Goose, Warp Paste the standard config into Goose (Advanced settings, Extensions, Add custom extension) or Warp (Settings, AI, Manage MCP Servers)
Claude Desktop claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/, Windows: %APPDATA%\Claude\)
Codex codex mcp add dowel ./bin/dowel mcp:serve
Without tapir With tapir
You hot reload, screenshot, drag the PNG into the chat, paste widget source and ask why the button does nothing. The agent calls tapir_snap, then tapir_tap on e7; the gate checks the button, the tap lands, a fresh snap confirms it.
You add Future.delayed and re-run to tell a timing issue from a bug. The gate refuses with a reason (not stable, obscured by, off-viewport) that names the cause.

For AI coding agents

Do Because
Install tapir only behind if (!kReleaseMode) Anyone who reaches the VM Service of a build that carries tapir can drive the app
Start the app with dart run tapir start, not flutter run Every tapir:* command dials the session dowel records
Snap again after a navigation or rebuild, or use a q<N> handle from tapir:find e<N> refs are frozen to the snapshot that minted them
Narrow a snapshot with --within, --interactiveOnly or --grep A full tree costs context, and a sidebar that repeats page labels misleads an exact-label lookup
Read effect (and checks when present) before trusting a green tick A tick says the event left tapir, not that the widget took it
Branch on the leading refusal substring, never on the trailing detail The substrings and the gate order are a frozen contract; the detail is not
Read framesForced and warnings on a response that looks empty A hidden window or tab stops frames, and the tree then reads as a broken screen
Check refused first on a tapir:perf_end report A refused session carries no metrics
Point the MCP client at the dowel key and reconnect after mcp:install The tools ride on the dowel server, and clients do not reload an entry on their own

Dependencies

dowel for the CLI, the MCP server and the VM Service connection; wind_kit_diagnostics_contracts to read Wind state without depending on Wind; image for screenshot encoding; yaml and meta. The rest is the Flutter SDK.

License

MIT, see LICENSE. Built by Anılcan Çakır.

Libraries

cli
CLI-side barrel; intentionally Flutter-free.
tapir
tapir: E2E driver for Flutter apps (LLM agent + integration test surfaces).