
For years, AI coding agents have written Compose UI blind — they generate the code, then wait for you to run it, look at it, and report back. Compose Hot Reload’s new MCP server changes that math. I wired one up to a fresh Compose Multiplatform desktop app, handed the agent a bug I never described, and watched it find its own way to the fix.
Picture this. You ask an AI agent to build you a small screen — a form, a list, a counter, whatever. It writes plausible-looking Compose code in about four seconds, tells you it’s done, and waits. You run the app. Something’s wrong: a button doesn’t respond, a list won’t update, text clips off the edge of the card. You paste a screenshot back. You describe what you’re seeing in words, because the agent has no eyes. It apologizes, guesses, tries again. Repeat.
Compose Hot Reload — the tool that already lets you edit a running Compose Desktop app without restarting it — now bundles an experimental MCP (Model Context Protocol) server. Point an MCP-aware agent at it, and instead of trading screenshots back and forth in chat, the agent gets direct tool calls into your actual running app:
That loop — write, run, describe, paste, guess — is the actual bottleneck in AI-assisted UI work right now, and it hasn’t gotten much attention because it isn’t a training problem. It’s a plumbing problem. The model was never given a way to look.
Compose Multiplatform 1.12.0 ships a first attempt at fixing that plumbing, and I wanted to see if it holds up outside a demo gif.
What Actually Shipped
Sixteen tools in total, but the ones that matter for a debugging loop are take_screenshot, get_semantic_tree, click, long_click, type_text, scroll, reload, and get_logs. status and get_ui_error round it out — the former tells the agent whether an app is even connected before it wastes a call, the latter surfaces a composable’s runtime exception directly instead of making the agent guess from a blank window.

Wiring it up is supposed to be almost boring. Compose Hot Reload has shipped bundled-by-default since CMP 1.10.0 for any project with a desktop target, so there’s no separate plugin to add. You drop one file in the project root:
{
"mcpServers": {
"compose-hot-reload": {
"command": "./gradlew",
"args": ["--no-daemon", "--quiet", "--console=plain", "hotMcpServer"]
}
}
}
(Full file: mcp.json gist)
And that’s it, in theory. In practice, I hit three things worth knowing about before you try this yourself.
Setting It Up: Three Real Walls
I scaffolded a minimal Kotlin Multiplatform project — a composeApp module, a single jvm(“desktop”) target, two small screens, nothing fancy — and pointed it at Compose Multiplatform 1.12.0.
Wall one: the Kotlin version. My gradle build failed immediately with Minimal supported Kotlin Gradle Plugin version is 2.2.0. I’d started from a slightly older known-good template pinned to Kotlin 2.1.21, which is fine for older Compose releases but not for 1.12.0. Bumping kotlin.version to 2.2.20 in the version catalog fixed it in one line.
Wall two: run isn’t the task you want. I first launched the app with a plain ./gradlew run. It worked — window opened, counter incremented — but the MCP server’s status tool reported “connected”: false the entire time. The actual Compose Hot Reload run task is target-qualified: hotRunDesktop, not run. Once I killed the plain run and relaunched with ./gradlew hotRunDesktop, status flipped to “connected”: true immediately. This is exactly the target-name gotcha worth checking for in your own build.gradle.kts before you assume the wiring is broken — a desktopApp target would give you hotRunDesktopApp and hotMcpServerDesktopApp instead.
Wall three (a non-wall, worth naming anyway): the kotlin-logging stdout issue. There’s a known report of a stray kotlin-logging: initializing… line hitting stdout during the MCP handshake and corrupting it for clients that don’t tolerate non-JSON lines. I watched my own client’s stdout channel for exactly this the entire session — it never happened. The line is there (I saw it in the process’s stderr), but on Compose Hot Reload 1.2.0 it’s written to stderr, not stdout, so it never touched the protocol channel. Worth checking your own version if a handshake fails mysteriously, but I can’t reproduce the corrupted version from a clean 1.12.0 project.
One more honest note on plumbing: I wasn’t running as the Claude Code CLI sitting inside this project’s directory, so .mcp.json couldn’t be picked up automatically the way it would be for you. I spoke MCP directly over stdio with a small Python client instead — same protocol, same JSON-RPC calls, same tool results, just driven by a script rather than built-in tooling.
MCP stdio client: does the initialize handshake, lists tools, and relays tools/call requests dropped into a file queue, decoding any returned image content to PNG.)
If you’re running this from an actual MCP-aware agent with .mcp.json in the project root, you can skip this part entirely — it’s already wired for you.
The Bug I Didn’t Tell It About
With the app connected, I added a second screen: a tiny task list. A text field, an Add button, a column that lists whatever’s been typed in. I planted one bug and didn’t write down what it was anywhere the agent could read.
First move: get_semantic_tree to find real node IDs instead of guessing pixel coordinates.
{"id":23,"testTag":"task_input", ...}
{"id":31,"role":"Button","text":"Add","testTag":"task_add_button", ...}
{"id":34,"testTag":"task_list_container", "bounds":{"width":0,"height":0}}
Empty container, zero-size bounds — expected, nothing’s been typed yet. So: type_text “Buy milk” into node 23, click node 31. Type “Walk the dog”, click Add again. Then get_semantic_tree one more time to see what actually landed:

The text field shows “Walk the dog” — the most recent thing typed. The list below shows exactly one item: “Buy milk.” Not zero items, not two. One. And it’s the older one. That’s a very specific, very telling shape of failure — the UI isn’t frozen, it’s exactly one interaction behind wherever the last unrelated state change left it.
That shape points at one cause: something in the screen is wired to Compose’s observation system (the text field updates instantly, every keystroke), and something else isn’t. Reading the source confirmed it — the task list lived in a plain mutableListOf<String>(), not mutableStateListOf<String>():
val tasks = remember { mutableListOf<String>() }
Mutating a plain list doesn’t tell Compose anything happened, so nothing that reads tasks recomposes because of it. The only reason the list ever appeared to update at all was that typing into the field did trigger a recomposition (because newTask is real Compose state), and that recomposition happened to re-read whatever was in tasks at that exact moment — always one click stale.
The Fix
File before fix: TaskListScreen.buggy.kt
Code after fix: TaskListScreen.fixed.kt
val tasks = remember { mutableStateListOf<String>() }
mutableStateListOf returns a SnapshotStateList — a list Compose actually watches. Save, reload:
{"success": true, "reloaded": true}
18 seconds, no restart, no lost window position. Then the actual test: type “Buy milk,” click Add once, and check the semantic tree without touching anything else first — no incidental recomposition from a second keystroke to hide behind.

Both tasks, present after their own click, nothing borrowed from a later one. status confirmed it from the runtime side too: successfulReloads: 1, failedReloads: 0.
Where This Falls Apart
It’s marked experimental, and it earns that label in a few specific ways worth knowing before you plan a workflow around it:
- It’s desktop-only. This is JVM-target Compose Hot Reload. It has nothing to do with debugging a Compose app on an Android device or emulator — don’t reach for this expecting mobile inspection.
- take_screenshot isn’t a true headless render. In my setup, a screenshot taken while the app window wasn’t actually visible on-screen returned whatever was on screen — once literally my desktop wallpaper, not an error. The tool description promises Compose-content-only, decoration-excluded capture, and once the window was frontmost that’s exactly what I got. But “frontmost” turned out to be doing real work there, which isn’t obvious from the tool description alone.
- Task names aren’t as generic as the .mcp.json example implies. The unqualified hotMcpServer in the config resolved fine for me — Gradle matched it to the one real task, hotMcpServerDesktop — but the run task has no such forgiving default. Know your target name.
- State doesn’t survive a reload if the remembered type changes. Switching mutableListOf to mutableStateListOf reset the list to empty on reload, which is correct behavior, not a bug — but it’s worth expecting if your fix changes a value’s shape, not just its logic.
None of these are fatal. All of them are the kind of thing you only learn by actually running it, which is presumably why the label says experimental and not beta.
What This Signals
The interesting part isn’t that an agent found a mutableListOf bug — that’s a Compose 101 mistake, and plenty of linters could flag it statically without any of this machinery. The interesting part is how it found it: not by reading the line and recognizing a pattern, but by clicking a real button, typing real text, and noticing that what came back didn’t match what should have come back. That’s closer to how you’d actually debug it yourself.
The edit → run → verify loop has always been the developer’s job to close by hand. Giving the agent a standing connection into the running app — not a pasted screenshot, not a described symptom, an actual take_screenshot and get_semantic_tree call it can make on its own — moves the “verify” step from something you do to the agent’s output to something the agent does before it hands the output back to you. It’s one Gradle task, marked experimental, desktop-only for now. But it’s a real instance of the thing everyone’s been gesturing at when they talk about agents needing “grounding” — and for once, you can ./gradlew your way into checking that claim yourself.
If this helped, follow me here for more real, hands-on looks at what’s actually shipping in Compose — no rewritten release notes, just what happened when I ran it. 👏
Github Repo
GitHub – Majid460/ComposeHotReloadDemo
I Gave My AI Agent Eyes Inside a Real Compose App was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.