Spyglass
Let AI agents (and you) see Compose UI without an IDE.
Spyglass renders your Compose @Preview functions to PNG with no IDE, emulator or device, then compares them with a design image and tells you exactly where they differ. It works from the command line, in CI, and as an MCP server, so a coding agent can look at the UI it just wrote, compare it with the mock and fix the gaps on its own.
Status: early development (0.1.0). Previews in Compose Multiplatform shared (
commonMain) and desktop code work today. Android-only previews are on the roadmap.
The missing piece
AI coding agents write a lot of UI code now, and they write it blind. An agent can make a Compose screen compile, but it can't see that the padding is off, the text is clipped or the button is the wrong colour. You find out when you open the IDE. In the 2025 Stack Overflow developer survey, AI output that is "almost right, but not quite" was developers' most-cited frustration with AI tools, and UI is where "almost right" is hardest to spot in code.
The ways to see a Compose preview today were built for people, or for regression tests:
Screenshot-test libraries are great at what they do: stopping regressions once the UI is right. Spyglass covers the step before that, getting the UI right in the first place, in a loop an agent can run by itself:
flowchart LR
W[Agent writes or edits<br/>Compose code] --> R[render_previews<br/>agent sees the PNG]
R --> C[compare_with_design<br/>score + regions + heatmap]
C -->|regions differ| F[Agent fixes the<br/>largest regions]
F --> R
C -->|matches| D[Done: reports the<br/>final score honestly]
Walkthrough: see, compare, fix
The three versions of the card above are real previews in samples/desktop-sample, so you can reproduce every image and number here. They play the parts of a designer's mock, a typical first attempt, and the fixed version.
1. Render. spyglass render finds every @Preview and draws it:
$ spyglass render --filter Demo
spyglass: rendered 3/3 previews
ok DemoKt.DemoDesignTarget [Design target] 720x860px .../build/spyglass/previews/DemoKt.DemoDesignTarget_Design_target.png
ok DemoKt.DemoFirstAttempt [First attempt] 720x860px .../build/spyglass/previews/DemoKt.DemoFirstAttempt_First_attempt.png
ok DemoKt.DemoAfterFix [After fix] 720x860px .../build/spyglass/previews/DemoKt.DemoAfterFix_After_fix.png
2. Compare with the design. spyglass diff scores the render and lists the regions that differ, largest first, with a rough position an agent can act on:
$ spyglass diff DemoKt.DemoFirstAttempt_First_attempt.png DemoKt.DemoDesignTarget_Design_target.png
spyglass diff: 89.81% similar (63095 of 619200 px differ)
heatmap: .../DemoKt.DemoFirstAttempt_First_attempt.diff.png
regions that differ, largest first (px, then dp at density 2.0):
1. center x=44 y=374 616x308px (22,187 308x154dp) 31.22% of it changed
2. top-left x=110 y=198 88x88px (55,99 44x44dp) 36.25% of it changed
3. top-left x=44 y=44 66x66px (22,22 33x33dp) 12.10% of it changed
4. top-right x=616 y=44 44x66px (308,22 22x33dp) 18.11% of it changed
Read with the heatmap, that is: the feature list and button (tighter spacing pushed them up, and the button lost its gradient), the price (wrong colour), and the top corners (radius 12dp instead of 28dp).
3. Fix and compare again. After fixing those, one small region is left:
spyglass diff: 99.82% similar (1090 of 619200 px differ)
regions that differ, largest first (px, then dp at density 2.0):
1. center x=242 y=440 132x44px (121,220 66x22dp) 18.77% of it changed
That region is the word "Support": the fixed version says "Priority Support", the design says "Priority support". Small enough to miss by eye, obvious in the heatmap.
Through MCP, an agent gets all of this from one compare_with_design call: a compact JSON summary plus a single image strip (render | design | heatmap), about 640 tokens in total.
What renders today
Real output from the sample project, cropped to each preview's content:
Also supported: previews inside an object or class, device = "spec:width=…dp,height=…dp" sizes, @PreviewFontScale, @PreviewScreenSizes, and multi-preview annotations declared in another module, such as a shared design system.
A preview that throws doesn't stop the run. It is reported with its error and the line in your code:
error BrokenKt.BrokenPreview IllegalStateException: Intentional failure to test error reporting (at sample.BrokenKt.BrokenPreview(Broken.kt:10))
Animations, AnimatedVisibility, dialogs fading in and fake data loaded after a delay() are shown finished, not at their first frame. A preview stuck in a loop is cut off after 30 seconds (--timeout) with the line it was stuck on, and the other previews still render. Content longer than the canvas limit is drawn whole; content that fills whatever room it gets, like fillMaxSize, is drawn at the limit with a note saying so. samples/new-uis holds these cases.
How it works
flowchart TB
subgraph You["You, CI, or an AI agent"]
CLI["spyglass render / diff"]
MCP["spyglass mcp<br/>(MCP server on stdio)"]
end
CLI --> G
MCP --> G
G["Your project's own Gradle wrapper<br/>+ init script that applies the Spyglass plugin<br/>(your build files are not changed)"]
G --> J["Compiles the module's JVM / desktop target"]
J --> R["Renderer runs on your project's classpath"]
R --> S["1. ClassGraph finds @Preview methods in the compiled classes"]
S --> I["2. Each preview is composed in an offscreen ImageComposeScene (Skia)"]
I --> P["3. PNG cropped to the content + previews.json manifest"]
P --> D["spyglass diff: perceptual pixel compare<br/>score, regions, heatmap"]
1. No build changes. The spyglass CLI carries the Gradle plugin inside it. When you run spyglass render, it starts your project's own gradlew with an init script (-I spyglass.init.gradle) that applies the plugin to every project for that run only. Nothing in your repository is edited, and your Gradle, Kotlin and Compose versions are the ones used.
2. A plugin that works anywhere. The Gradle plugin is written in Java and deliberately avoids Kotlin Gradle Plugin classes, because an init script loads it in a separate class loader. It finds your Kotlin Multiplatform JVM target (jvm() or jvm("desktop")) by reading the org.jetbrains.kotlin.platform.type attribute on Gradle configurations, then registers spyglassRender. Plain Kotlin/JVM modules work too. A module without a JVM target (Android-only, iOS-only, JS) is skipped with a message instead of failing the build. It works with the configuration cache on, and the per-build settings stay fixed so the cache is reused between runs.
3. Rendering on your classpath. spyglassRender runs the renderer in a JVM whose classpath is your module's compiled desktop jar plus its runtime dependencies, so your composables, resources and Compose version are exactly what ship. The renderer itself compiles against Compose only as compileOnly.
4. Finding previews without loading your code. ClassGraph scans the compiled classes for methods carrying @Preview. That annotation has BINARY retention, so it isn't visible to normal reflection but is in the class files. Spyglass recognises:
androidx.compose.ui.tooling.preview.Preview(the common annotation since Compose Multiplatform 1.10)- the deprecated JetBrains and desktop
@Previews - repeated
@Previews on one function - multi-preview annotations, wherever they are declared. When a preview uses an annotation that isn't in the module, such as
@PreviewLightDarkor your design system's own, Spyglass runs a second, targeted scan of just those annotation classes on the runtime classpath. @PreviewParameteron the preview's parameter: the provider is created and each of its values (up to itslimit, at most 50) becomes its own image.
5. Calling a composable by reflection. A @Composable fun Foo() compiles to a method Foo(Composer, int), or Foo(value, Composer, int) with a @PreviewParameter. Spyglass composes a host inside an offscreen ImageComposeScene and invokes the preview method with the scene's current Composer. Previews in a Kotlin object are called on its INSTANCE, and previews in a class on a new instance. During the render:
LocalInspectionModeistrue, as in the IDE.- Compose desktop's system theme is pinned: light, or dark when
uiModeasks for night.isSystemInDarkTheme()gives the same answer on every machine, and dark variants really render dark. - Time is virtual. Frames are rendered on a virtual clock, and
delay()in effects waits on that clock instead of the wall clock. Frames continue until nothing is left to recompose, no animation asks for a frame, and nodelay()ends within 2 seconds. Idle stretches jump straight to the nextdelay(). Adelay(300)before fake data appears costs no real time, and the preview shows the loaded state. An animation that never ends, like an indeterminate spinner, is captured at 2 seconds and gets awarning. - Each preview runs on its own thread with a time limit (30 s,
--timeout). A preview that loops forever is reported as stuck at its line in your code, and the run moves on. - The content is measured and the PNG is cropped to it.
widthDp/heightDpor adevice = "spec:…"size (exact withshowSystemUi) fixes the canvas instead. If content reaches the canvas limit (1024dp), it is drawn once more on a canvas four times larger:- Content that stops growing, such as a long settings page, is shown whole.
- Content that keeps filling, such as
fillMaxSizeor a lazy list, is shown at the limit with awarningsuggesting a fixed size.
fontScale,showBackgroundandbackgroundColorare applied.- Compose's own error log is captured, so a crashing preview becomes one line that points at your code (
Broken.kt:10), not a stack trace.
6. A manifest for machines. Every run writes build/spyglass/previews/previews.json with each preview's function, @Preview name, parameter value, status, image path, size, error and warning, and adds its path to a report file the CLI reads. spyglass render --json prints a compact version, with image paths relative to the project root:
{"root":"/work/app","rendered":2,"failed":1,"previews":[{"preview":"CardsKt.WeatherCardPreview","image":"build/spyglass/previews/CardsKt.WeatherCardPreview.png","px":"480x341"},{"preview":"VariantsKt.StatusChipPreview (Online)","image":"build/spyglass/previews/VariantsKt.StatusChipPreview_0.png","px":"168x61"},{"preview":"BrokenKt.BrokenPreview","error":"IllegalStateException: … (at sample.BrokenKt.BrokenPreview(Broken.kt:10))"}]}
7. Comparing images like a person would. spyglass diff flattens both images onto white and scales the design to the render's size if they differ. It then compares every pixel with the YIQ colour distance used by pixelmatch, so --threshold 0.1 means what it does there. To turn changed pixels into something an agent can act on:
- the image is split into a grid, and cells with only stray anti-aliasing changes are ignored
- touching cells are joined into regions, and regions that nearly touch are merged, so "27°" is one region rather than two digits
- each region is reported with a rough position (
top-left,center...) and the share of its pixels that changed. The human output gives px and dp; the JSON gives[x, y, w, h]in dp, the unit Compose code is written in. - if the design's aspect ratio differs from the render's by more than 2%, the result carries a warning, because stretching skews the comparison.
8. An MCP server that speaks old and new MCP. spyglass mcp serves the stateless 2026-07-28 revision of the Model Context Protocol (version in each request's _meta, plus server/discover). It also answers the older initialize handshake (2024-11-05 to 2025-11-25), so it works with clients on either. Problems the agent can fix come back as tool errors with the details:
- a compile error, with the compiler's message, file and line
- a crashing preview
- an ambiguous preview name, with the exact choices
9. Built to be cheap in tokens. An image costs an agent roughly width × height / 750 tokens, and every word of a tool schema is loaded into every session. So:
- Tool descriptions are short.
- Results are compact JSON with no empty fields.
- Images are scaled to 640 px on the long side by default (
imageMaxPxto change it). - A comparison comes back as one labelled strip instead of three full images.
- Gradle's generic advice is trimmed from error output.
UI memory: it learns as you work
Every render and design Spyglass handles is remembered in <project>/.spyglass/library/. Each one is stored once, under a fingerprint of its pixels, with:
- a thumbnail
- its preview name
- its main colours as hex codes
- how close it came to its design
- any name or note you give it
The library updates on every render and comparison. Agents use it to work faster and spend fewer tokens:
A design saved by name (designName: "plan-card") can be passed as designImage: "plan-card" from then on. From the terminal, spyglass refs lists the library. Add .spyglass/library/ to your .gitignore to keep it local, or commit it to share it with your team.
Visual regression checks
spyglass approve # store today's renders as baselines in .spyglass/baselines/
spyglass check # later: compare every preview with its baseline
check reports each preview as same, changed (with its similarity, a heatmap and the regions that moved), new (no baseline yet) or error. It exits with 1 when a preview changed, or when one that used to render now fails, so it works as a CI step. Commit .spyglass/baselines/ so CI has something to compare against.
CHANGED CardsKt.WeatherCardPreview 98.52% heatmap: build/spyglass/previews/CardsKt.WeatherCardPreview.check.png
center 110x100px at 110,130
spyglass check: 15 same, 1 changed, 0 new, 1 errors
spyglass check: FAILED (1)
Install
Requires JDK 17+. Download spyglass-0.1.0.zip from the latest release, unzip it somewhere stable, and add its bin folder to your PATH:
unzip spyglass-0.1.0.zip -d ~/tools
export PATH="$HOME/tools/spyglass-0.1.0/bin:$PATH"
spyglass version
On Windows, unzip it (for example to C:\tools) and add C:\tools\spyglass-0.1.0\bin to PATH; the launcher is spyglass.bat. Each release lists the zip's SHA-256 so you can check the download.
Or build from source:
git clone https://github.com/zainkhalid91/spyglass
cd spyglass
./gradlew :cli:installDist
The launcher is then cli/build/install/spyglass/bin/spyglass.
On macOS, apps like Claude Desktop start MCP servers without your shell's PATH. When JAVA_HOME isn't set, the launcher finds a JDK itself through /usr/libexec/java_home or Homebrew's openjdk, and finds gh in Homebrew's folders.
Compatibility
Use
cd /path/to/your/project
spyglass render # every module with a JVM target
spyglass render --module :shared # one module (inferred when run from a module folder)
spyglass render --filter Card --json # matching previews only, JSON on stdout
spyglass diff render.png design.png # compare; --fail-under 0.98 for CI
spyglass approve / spyglass check # baselines and visual regression checks
spyglass refs # the UI memory
spyglass help
Images land in <module>/build/spyglass/previews/. Render options: --density (default 2), --max-width-dp / --max-height-dp (canvas limit for previews without a fixed size, default 1024), --target (JVM target name, detected by default), --timeout (seconds one preview may take, default 30), --fail-on-error.
Use with AI agents
spyglass mcp is a standard MCP server on stdio, so any MCP client can use it. It has four tools:
The server renders the project it is started in, and only that project: projectDir must point inside it unless the server is started with --allow-any-project (see Security). Every tool also accepts module, and imageMaxPx for image size.
In the examples below, replace the path with where you put Spyglass. On Windows, use cmd as the command with ["/c", "C:\\path\\to\\spyglass\\bin\\spyglass.bat", "mcp"] as the arguments.
Claude Code (CLI, IDE extensions and the desktop app's Code tab):
claude mcp add --scope user spyglass -- /path/to/spyglass/bin/spyglass mcp
# Windows
claude mcp add --scope user spyglass -- cmd /c C:\path\to\spyglass\bin\spyglass.bat mcp
Claude Desktop (chat). In claude_desktop_config.json (Settings → Developer → Edit Config):
{
"mcpServers": {
"spyglass": { "command": "/path/to/spyglass/bin/spyglass", "args": ["mcp", "--project", "/path/to/your/project"] }
}
}
Claude Desktop doesn't start servers in your project folder, so --project sets the default. Tools can still pass projectDir to render another project.
Cursor (~/.cursor/mcp.json or .cursor/mcp.json), Windsurf (~/.codeium/windsurf/mcp_config.json) and Gemini CLI (~/.gemini/settings.json):
{
"mcpServers": {
"spyglass": { "command": "/path/to/spyglass/bin/spyglass", "args": ["mcp"] }
}
}
VS Code / GitHub Copilot (.vscode/mcp.json):
{
"servers": {
"spyglass": { "type": "stdio", "command": "/path/to/spyglass/bin/spyglass", "args": ["mcp"] }
}
}
OpenAI Codex CLI (~/.codex/config.toml):
[mcp_servers.spyglass]
command = "/path/to/spyglass/bin/spyglass"
args = ["mcp"]
Claude Code skill: copy skills/spyglass/ to ~/.claude/skills/. It tells the agent when to render, how to work through a design comparison, and what the limits are. It falls back to the CLI when the MCP server isn't connected.
The first render in a project compiles it, which can take a minute or two; later renders reuse Gradle's caches. If your client times out on the first call, render once from the terminal first.
Use the Gradle plugin directly
plugins {
id("io.github.zainkhalid91.spyglass")
}
Then run ./gradlew spyglassRender. Options are Gradle properties: -Pspyglass.filter=<text>, -Pspyglass.density=2, -Pspyglass.maxWidthDp=1024, -Pspyglass.maxHeightDp=1024, -Pspyglass.target=<jvm target>, -Pspyglass.timeout=30, -Pspyglass.failOnError=true. The plugin isn't published yet; samples/desktop-sample shows how to use it from this repository.
Limits
- Previews: they must live in code compiled for a JVM (desktop) target. Previews that exist only in Android source sets, or use Android-only APIs, don't render yet.
- Not supported yet: locales,
device = "id:…"device names (spec:sizes work), and@PreviewWrapper. - Fonts: text uses the fonts available on the machine, so renders can differ slightly between operating systems. Compare renders made on the same OS, or use a looser
--threshold. - Scores against other tools' mocks: a mock drawn in another tool will rarely score above 90%, because fonts and anti-aliasing differ. Use the regions and the heatmap, not just the number.
Roadmap
- Semantics tree output (text, sizes, touch targets) alongside each image
- Locales,
id:device names,@PreviewWrapper - Android-only previews
- Published Gradle plugin and a Claude Code plugin bundle
Project layout
Usage data (opt-in)
Spyglass can share anonymous usage numbers to help improve it. It is off unless you turn it on. The first time you run it in a terminal it asks once, with "no" as the default answer. It never asks in CI or when an agent runs it.
About once a week, the numbers are posted as a comment on the public usage-report issue using the GitHub CLI (gh), under your GitHub account. Without gh, nothing is sent. The numbers are used only to decide what to fix and improve in this repository.
spyglass telemetry show # exactly what would be sent
spyglass telemetry on|off # off also deletes the numbers collected on this machine
spyglass telemetry status
Security
Rendering runs your project's own build, so render only projects you trust. The MCP server only renders inside the project it was started in. Files in .spyglass/ are treated as untrusted. Details, and how to report a vulnerability privately, are in SECURITY.md.
Continuous testing and improvement
-
CI (
ci.yml): runs on every push and pull request, and on its own every two days. It runs:- the unit tests
- a render of both sample projects
spyglass checkagainst the committed baselines
It uploads the renders and heatmaps as artifacts. If a scheduled run fails, it opens an issue labelled
ci-failure. -
Scheduled improvement routine: every two days, a Claude routine runs the tests, reads the CI results and the usage reports, looks for bugs, performance problems and token waste, and opens a pull request with fixes and tests. It never pushes to
main; a person reviews and merges.
Development
./gradlew test # unit tests: library, check, telemetry, security, MCP protocol, diff, ...
./gradlew :cli:installDist # build the CLI
cli/build/install/spyglass/bin/spyglass render --project samples/desktop-sample # end to end
cli/build/install/spyglass/bin/spyglass check --project samples/desktop-sample # visual regression
cli/build/install/spyglass/bin/spyglass render --project samples/edge-cases
Windows note
Gradle fails with Unable to establish loopback connection when TEMP is an 8.3 short path like C:\Users\JOHNSM~1\..., which is common when the user name has spaces. spyglass render detects this and points TEMP at <project>/.gradle/spyglass-tmp for its Gradle run. If you run Gradle yourself, point TEMP, TMP and -Djdk.net.unixdomain.tmpdir at a folder without ~ or spaces.