tapir:screenshot
Capture a screenshot of the running Flutter app to a file.
On this page
On web targets where dowel was started with --cdp-port, the command routes through Chrome DevTools Protocol Page.captureScreenshot for a full-viewport capture (bypassing the in-isolate ext.tapir.screenshot extension, which hangs under CanvasKit+DWDS, issue #13). On native targets the VM Service handler walks the render tree to find the RepaintBoundary that TapirPlugin.install() wrapped around the app root, calls toImage, and returns a base64-encoded PNG or JPEG. The CLI decodes the payload and writes the bytes to the path supplied by --output.
JPEG is the default for size: a typical Flutter web screen renders in ~30 KB at quality 70. Switch to PNG when the agent needs lossless pixels for a diff comparison.
Scope:
tapir:screenshotcaptures the whole viewport, or one widget with--ref(and--rect). The CDP fallback applies to the CLI command only; thetapir_screenshotMCP tool dispatchesext.tapir.screenshotin-isolate and can still time out on web, so prefer the CLI for web screenshots.
Synopsis#
dart run tapir tapir:screenshot --output=<path>
[--format=jpeg|png]
[--quality=<1-100>]
[--ref=<eN|qN>]
[--rect=<x,y,w,h>]
tapir:screenshot requires a running Flutter session (CommandBoot.connected). It dials the VM Service URI. When cdpPort is set in state (a web target), the command uses CDP Page.captureScreenshot; otherwise it calls ext.tapir.screenshot, decodes the base64 payload, and writes the resulting bytes to the supplied output path.
Arguments#
| Option | Abbr | Type | Default | Required | Description |
|---|---|---|---|---|---|
--output |
-o |
string (path) | (none) | yes (mandatory: true) |
Output file path. Resolved relative to the CWD. The directory must already exist. |
--format |
none | enum | inferred from --output, else jpeg |
no | One of jpeg, png. Constrained by allowed: ['jpeg', 'png'] so any other value errors out at parse time. See Format and quality for the inference. |
--quality |
none | int (string-parsed) | 70 |
no | JPEG quality, range 1-100. Ignored for PNG. Falls back to 70 when the value fails int.tryParse. |
--ref |
none | string | (none) | no | Capture only this widget. An e<N> token from tapir:snap or a q<N> handle from tapir:find. Omit for the whole viewport. |
--rect |
none | string x,y,w,h |
(none) | no | Sub-rect in logical pixels, relative to the ref's top-left. Requires --ref. |
The --output guard fires before the VM Service call; an empty or missing path returns exit code 1 with Missing --output=<path>.. --rect without --ref returns 1 as well, rather than quietly capturing the whole frame: a sub-rect is meaningless without the widget it is relative to, and an image that looks right but answers a different question is the failure this flag exists to remove.
Returns#
tapir:screenshot returns an integer exit code via Future<int>:
| Exit code | Meaning |
|---|---|
0 |
Screenshot captured and written. The command prints Wrote <N> bytes (<K> KB, <format>) to <path> where <N> is the size of the decoded image (useful for spotting empty buffers without inspecting the file). |
1 |
--output was missing or empty, or --rect was given without --ref (CLI-side guards); OR the VM Service handler returned a response without a base64 field. |
Success line (CLI side):
✓ Wrote 30912 bytes (30.2 KB, jpeg) to /tmp/dashboard.jpeg
VM Service envelope (handler side):
{ "base64": "<base64-encoded image bytes>" }
The CLI calls base64Decode(base64Str) and writes the resulting bytes to output. No additional metadata (width, height, mime) is returned today; the agent infers shape from the file on disk if needed.
Format and quality#
When --format is not given, the format follows the --output extension: .png captures PNG, .jpg and .jpeg capture JPEG, so a .png file never holds JPEG bytes. Any other extension falls back to jpeg. An explicit --format always wins; when it disagrees with the extension (--output=a.png --format=jpeg) the command writes the file as asked and warns on stderr that the file will hold the other format's bytes.
- jpeg (default): on native targets the VM Service handler encodes via the
imagepackage's JPEG encoder at the supplied quality; on web (CDP path) theformat+qualityparams are forwarded directly toPage.captureScreenshot. Quality 70 is a Playwright-aligned default; bump to 90 for visual-regression diffs, drop to 40 for quick sanity checks. - png: lossless, larger files (typically 5x JPEG at quality 70). The
--qualityvalue is ignored.
On a hidden or minimised desktop window tapir wakes the app before the in-isolate capture (the response carries framesForced: true), so the image is a fresh frame rather than the last cached one. The CDP path on web is unaffected by the lifecycle. See Frame production.
The handler never resizes the screenshot; the captured image matches the running viewport's pixel dimensions (logical size times DPR). To capture at a controlled viewport, run tapir:resize or tapir:device first.
CDP path vs. in-isolate path#
| Condition | Path taken |
|---|---|
No cdpPort in state (native target) |
ext.tapir.screenshot (in-isolate) |
cdpPort set (web target) |
CDP Page.captureScreenshot |
The CDP path sends Page.enable first (required by Chrome before Page.captureScreenshot), then Page.captureScreenshot with fromSurface: true. The resulting dimensions reflect the Emulation.setDeviceMetricsOverride still in force, which is only one whose DevTools session is still open (tapir:resize --hold); Chrome drops it when the session that sent it detaches.
--ref works on both paths, by different routes. In-isolate, ext.tapir.screenshot rasterises the ref's own render-object region directly. Over CDP there is no notion of a Flutter ref, so the command first asks the extension for the region in geometry mode, which resolves --ref / --rect and returns the rect without rasterising (the rasterise is exactly what hangs on this target), then passes it to Page.captureScreenshot as a clip. Flutter logical pixels and CDP CSS pixels are the same unit, so the rect crosses over unscaled.
One extra round-trip, and one definition of what a ref covers rather than two. When the ref no longer resolves the command exits 1 instead of falling back to a full-frame capture.
Examples#
1. Capture the current screen as JPEG#
dart run tapir tapir:screenshot --output=/tmp/screen.jpeg
Expected output (illustrative):
✓ Wrote 30912 bytes (30.2 KB, jpeg) to /tmp/screen.jpeg
2. Capture losslessly for a visual diff#
dart run tapir tapir:screenshot --output=/tmp/screen.png --format=png
Useful when the agent compares against a baseline PNG via image_diff or pixelmatch.
3. Capture at high quality JPEG#
dart run tapir tapir:screenshot --output=/tmp/hifi.jpeg --quality=92
4. Capture after a controlled resize#
# The override lives only as long as the session that sent it: hold it.
dart run tapir tapir:resize --width=1440 --height=900 --hold &
dart run tapir tapir:screenshot --output=/tmp/desktop.jpeg
5. Capture one component instead of the whole screen#
dart run tapir tapir:snap | grep 'PricingCard'
# - region "PricingCard" [ref=e42] ...
dart run tapir tapir:screenshot --output=/tmp/card.png --format=png --ref=e42
Reach for this whenever the question is about one component. A full-screen capture of a tall page costs far more tokens than the region under review, and it buries the thing you are actually checking. It also removes the resize dance: without --ref, seeing a component below the fold means growing the viewport to something no device has, capturing, and putting the height back.
6. Capture part of a component#
# The top-left 200x60 of the card, in logical pixels relative to its own origin.
dart run tapir tapir:screenshot --output=/tmp/card-header.png --ref=e42 --rect=0,0,200,60
Related Documentation#
- tapir:snap: the structured-text counterpart; agents typically pair
tapir:snapandtapir:screenshotto read both the Semantics tree and the pixels in a single round. - tapir:hot_reload_and_snap: captures snapshot + screenshot + recent exceptions in one round trip.
- tapir:resize and tapir:device: control the viewport that
tapir:screenshotcaptures from on web targets.