Apache-2.0 · debug builds only · Android

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

QUICK START

From zero to an agent that can answer

01

Install

app/build.gradle.kts
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.

02

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:

.mcp.json
{ "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.

03

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.

04

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.

WHAT IT CAPTURES

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.

recompositions
Per-composable counts and peak per frame, down to the call site.
state writes
Yours separated from framework writes, named from the owner that holds them.
dropped frames
Counted in refreshes, so a 400ms freeze is not one missed frame.
main thread
Stalls over 100ms with the stack, and queries that ran on the main thread.
navigation
Destinations with their arguments and the deep link that got you there.
http
OkHttp or Ktor, with phases, status and bodies when you ask for them.
db
Room or SQLDelight: the SQL, the bound values, and the thread it ran on.
work
One bar per WorkManager attempt, so a retry is visible rather than averaged away.
memory
Java heap against total RAM, allocation rate, and collections that paused the app.
device
Rotation, foreground and background, trim memory, battery, doze, network.
warnings
Your own logcat at warning and above, stack traces intact.
FOR THE AGENT

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.
ASK AGENT
Why does the cart screen stutter when I add an item?
CartViewModel.tick preceded 445 of 490 recompositions in this window, most often in Cart.ItemRow. The main thread was also blocked for 305ms in CartViewModel.blockTheMainThread.
observed · ordering, not proof of cause
ONE WINDOW FROM THE SAMPLE

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.

RECOMPOSITION cart screen
recompositions
490
preceded by tick
445
hottest node
Cart.ItemRow
state key
CartViewModel.tick
FRAMES AND MAIN THREAD same window
frames missed
158
worst frame
276ms
spent in
swapBuffers
main thread blocked
305ms

one capture from the sample app — your own numbers will differ

FAQ

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.

now

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.