Let your coding agent see the running Android app
Porthole is a debug-only Android library and an MCP server: your coding agent asks what recomposed, what dropped a frame, and what is holding the main thread — against the app in front of you. When the answer is below the app, it captures a Perfetto system trace and queries it, so nobody has to open a trace viewer.
./gradlew portholeMcpConfig portholeConnect
From zero to an agent that can answer
Install
plugins {
id("com.android.application")
id("live.gravitylabs.porthole") version "0.2.0"
}
runtime:0.2.0 on debug builds, runtime-noop:0.2.0 elsewhere
— same API, empty bodies. minSdk 26 · Gradle 8.14/9.7 · AGP 8.9/9.4.
Connect the agent
./gradlew :app:installDebug # your app, as usual./gradlew portholeMcpConfig # writes the porthole entry into .mcp.json./gradlew portholeConnect # forwards the device port to localhost
[porthole] added porthole to .mcp.json
[porthole] next: ./gradlew portholeConnect, launch the debug build, and the
tools go live in your-project.
[porthole] forwarded 127.0.0.1:8677 to the device
portholeMcpConfig merges rather than overwrites — a different existing
entry is left alone. It writes a four-line entry:
{ "mcpServers": { "porthole": {
"command": "npx", "args": ["-y", "@gravitylabsllc/porthole"],
"env": { "PORTHOLE_PORT": "8677" } } } }
portholeConnect is the whole bridge: a standard adb forward
to a socket the runtime bound on the device's loopback interface — nothing leaves the
machine. Restart your MCP client once so it picks up the new server.
Ask, in prose
"The cart screen stutters when I add an item. What is actually happening?"
Starts at findings. observed means the device saw it;
correlated means only nearby in time — not proof.
The few lines it cannot discover
import live.gravitylabs.porthole.* OkHttpClient.Builder().installPorthole().build() Room.databaseBuilder(context, AppDb::class.java, "app.db").installPorthole().build()
Ktor and SQLDelight are the same one line each; forget one and the runtime says so a few seconds after start. Everything else needs nothing at all: frames, the main thread, memory, WorkManager, device context, logs, and the semantics tree.
LaunchedEffect(navController) { Porthole.registerNavController(navController) }
One more line if you use Navigation — it is also how screen-scoped view models get
their names. Rather watch than ask? ./gradlew portholeUi — the same
session, drawn.
What comes back
findings 4 finding(s) over 225s (3 error, 1 warning). Worst: 1 database query ran on the main thread [observed]. error observed 1 database query ran on the main thread error observed main thread blocked for 295ms error observed 4 HTTP calls failed warning observed 112 frames missed their deadline (budget 8.3ms at 120Hz)
The query and the stall are yours to fix; a dropped frame whose cause sits below the
app is not. capture_system_trace + ask_system_trace go one
level down, into a Perfetto trace, when findings alone cannot say why.
./gradlew portholeTraceProcessor fetches the query engine once — pinned
and SHA-256 verified. Nobody opens ui.perfetto.dev.
Eleven lanes on one clock
Every lane shares a single monotonic clock, so a recomposition burst, the state writes that preceded it, and the frames it cost line up in the same column.
The same session, in a form an agent can read
A screen recording tells a coding agent nothing. Porthole ships an MCP server that answers against the window you are looking at — recomposition counts, the state keys written just before them, what held the main thread and the stack it was stuck in.
- Press ask agent and it copies the window as bounds, not prose, so the agent asks about the moment you actually saw.
- Every finding says how strongly it can be claimed — observed when the device said so, correlated when two things merely happened near each other.
- When the cause is below the app, it can capture a Perfetto system trace and ask it a fixed set of questions itself, instead of handing you a file to open by hand.
- Local only. The socket binds to loopback and nothing leaves the machine.
What eight seconds looked like
The sample deliberately misbehaves: a ticking value is read inside every list row, and one button blocks the main thread. This is what the window said about it.
- recompositions
- 490
- preceded by tick
- 445
- hottest node
- Cart.ItemRow
- state key
- CartViewModel.tick
- frames missed
- 158
- worst frame
- 276ms
- spent in
- swapBuffers
- main thread blocked
- 305ms
one capture from the sample app — your own numbers will differ
The usual objections
- Does it end up in my release APK?
- No. The plugin puts the runtime on debug build types and a no-op artifact with the same public API on everything else. An API parity test fails the build if an integration ever ships without its no-op.
- What does it cost at runtime?
- Per instrumented recomposition: a timestamp, a short backwards walk over the last ~32ms of state writes, an object into a ring, and a queue offer. Event fan-out happens on a writer thread, never on the thread that emitted.
- Does the device need to be rooted?
- No. Everything goes over a standard adb port forward, and the socket binds to loopback only.
Views, or only Compose? See Compatibility. Will it leak a token into a trace? See Payloads and SECURITY.md.
v0.2.2 · Maven Central · npm · Gradle Plugin Portal
What is and is not supported lives with the code: the status section of the README.