Skip to main content

echolot

Turns a huge Android trace into 20 rows of facts an AI agent can actually use.

checks PyPI Python versions License


From a huge trace to the line to fix: echolot turns the trace into twenty rows of facts, and your agent follows one of them to the line in the code

  • Finds the line to fix. From a slow screen to a file and line, with the numbers that prove it.
  • Same answer, every run. A pinned trace_processor: the same trace always gives the same report.
  • No tracing code needed. Works on apps with zero trace {} calls; it places temporary markers itself.
  • Works with your agent. A plugin for Claude Code and Codex; Cursor and others via echolot guide.

A condensed /echolot session: the question, three rounds of recording and reading, and the answer with its place in the code

Contents · Quick start · How it works · What it saves · What you get · What changed

Reference · Detectors · Commands · Requirements · Project layout · Documentation · Status

Quick start

You need Python 3.10+, adb, and a phone or emulator with USB debugging on. The full list is under Requirements.

1. Install the tool

pipx install echolot

echolot --version names what you got. Installed with pip into an environment whose scripts are not on PATH, python -m echolot is the same command — except to Codex's sandbox rule, which lets out only a line that starts with echolot.

2. Add the plugin to your agent

The plugin works in Claude Code and in Codex. It comes from the marketplace in this repository, and it drives the echolot you installed in step 1:

claude plugin marketplace add grishan0v/echolot && claude plugin install echolot@echolot   # Claude Code
codex plugin marketplace add grishan0v/echolot && codex plugin add echolot@echolot         # Codex

Inside a Claude Code session the same two steps are /plugin marketplace add grishan0v/echolot and /plugin install echolot@echolot. Once the marketplace is added, Codex also installs the plugin from /plugins in the CLI or from the Plugins tab of the ChatGPT desktop app. The plugin is not in Anthropic's or OpenAI's catalogs yet.

Without the plugin: the .claude/ layer in Claude Code, and other agents
cd ~/my-app && echolot init

In Claude Code this installs the .claude/ layer into the project: a skill, the perf-hunter agent and three commands. Other agents get a pointer to the tool instead, and echolot guide tells them how to work with it. A plain init points only at agents whose files the project already has: for Codex without the plugin that is a .codex/ folder or an AGENTS.md, and elsewhere echolot init --for agents,codex (with claude and any other agent the project uses) writes the pointer and the rule that lets echolot out of Codex's sandbox — run it outside the sandbox, as the codex line of echolot says. The guide hands the loop to a subagent where the host can start one; where it cannot, the loop runs in your main context, so keep the passes short.

On a repository that is not yours to change, echolot init --private installs the same files and keeps every one of them out of git status, for this clone only. The details are under The agent layer.

3. Open the agent and type one word

/echolot                          reads the state, does whatever is next
/echolot why is cold start slow   hunt, with that as the question
/echolot init | setup | hunt | reflect | doctor

The word depends on how your agent got echolot: /echolot:echolot with the plugin in Claude Code, $echolot with the plugin in Codex, and /echolot with the .claude/ layer. This page writes it as /echolot. In Codex a plain question about slow startup reaches it as well.

The first run sets the project up, checks that this machine computes traces correctly, and builds echolot.yml from your repository and a probe trace, asking you four questions along the way. Every run after that hunts down the regression you describe.

What to ask

Describe the problem the way you would to a colleague:

the problem what to type what it needs
Cold start got slower /echolot why is cold start slow —
Scrolling stutters /echolot the feed janks on scroll a scroll scenario in echolot.yml, which holds one scenario at a time; frame_jank needs Android 12+
"App isn't responding" /echolot the app froze, here is anr.txt the report: an export from Crashlytics or Play Console, or dumpsys dropbox
The nightly benchmark got slower one step after the benchmark in its workflow: uses: grishan0v/echolot@v0.11.0 it analyzes the traces, compares them with the report the last good run kept, and keeps both. The inputs, and why it never fails a build over what moved, are in Comparing

Coming back later

echolot on its own prints where the project stands and the next step.

To upgrade, run pipx upgrade echolot, then update what your agent uses:

claude plugin marketplace update echolot && claude plugin update echolot@echolot   # Claude Code, applied after a restart
codex plugin marketplace upgrade echolot && codex plugin add echolot@echolot       # Codex
cd ~/my-app && echolot init                                                        # the .claude/ layer

A new plugin comes out with each release of the tool, so the two stay in step. echolot init brings the .claude/ layer up to date and leaves the files you edited alone.

Without an agent

The same work, by hand or in CI:

echolot collect -c echolot.yml -n 5                                       # 5 repeats of the scenario
echolot analyze .echolot/traces/coldStart_iter*.perfetto-trace -c echolot.yml  # build the report from them
echolot compare before.json .echolot/out/report.json                      # what changed since
echolot doctor -q                                                         # is this environment sane? exit 0 yes, 1 no or not checked, 2 trace_processor not downloaded

coldStart is the config's scenario.name. .echolot/traces/ keeps the repeats of every scenario you have recorded, so the report is built from one scenario's: a glob over the whole directory would take medians across two.

Results land in .echolot/out/ — report.md for you, report.json for the agent.

How it works

echolot runs thirteen SQL detectors over a Perfetto trace and returns about twenty rows: where the time went, how much of it, and the evidence behind each claim. Same trace in, same report out: the trace_processor version is pinned.

The hunt as a loop: echolot records, analyzes and compares the same way every time; the agent reads the rows, marks one blind spot a round, and names the place in the code

The recording, the detectors and the comparison are scripts that run the same way every time. In each round the agent makes one decision: where to look next. The loop ends when the report names a place in the code, or when the rounds run out.

where you run it how
Claude Code or Codex, with the plugin the full loop, in a subagent in both: the agent records, reads the report and walks down to the code. The plugin sets the project up itself; installing it is under Quick start
Claude Code, with the .claude/ layer the same loop: echolot init, then /echolot
Cursor, other agents, Codex without the plugin echolot init points them at the tool, and echolot guide tells them how to work with it. A plain init finds only agents whose files the project already has; echolot init --for agents,codex sets up Codex anywhere else, with the rule that lets echolot out of its sandbox. The guide hands the loop to a subagent where the host can start one; where it cannot, the loop runs in your main context, so keep the passes short
a shell or CI the pipeline commands under Without an agent

The investigation

Each question you bring gets an investigation. echolot hunt "cold start was 3s, now 7s" --since "the tab redesign" opens one, sets the previous traces of the config's scenario aside without deleting them, and files every round of traces and every report under it, so a question asked weeks ago still knows what was measured to answer it. /echolot opens and closes them for you. The commands, and what each one keeps, are in The agent layer.

What it saves

One bug, one model: a hunt costs less with echolot than without it, and follows one fixed loop instead of a new approach every run

Cost is the measure because it includes the work of the agent's subagent: token counts leave that out, and time went both ways across the four models.

What you get

A Marker Report: what was measured, then one section per detector that fired. The silent ones are named too.

Example report (click to expand)
# Marker Report

Runs: **5**, numbers are medians across them
Traces: `coldStart_iter000`, `coldStart_iter001`, `coldStart_iter002`, `coldStart_iter003`, `coldStart_iter004`
Process: `com.example.app` (pid 12903)
Scenario window: **1136.05 ms** (from 1084.25 to 1214.18)
Main thread: 40% on a CPU · 1% waiting for a CPU · 9% blocked in the kernel · 49% sleeping
Covered by the findings below: **42%** of the main thread's window, each moment counted once
Startup: cold, **1174 ms** from the launch to the first frame: 42% sleeping · 17% `choreographer_do_frame` · 17% `inflate` · 6% blocked in the kernel · 5% `monitor_contention` · 3% `launch_delay` · 2% `bind_application` · 2% `activity_start`
That is the platform's measure rather than the window: the startup began 38 ms before the window opened, and ended with it.
Device: clock **1478 MHz** (from 1325 to 1574 across repeats), peak 54 °C, 1514 MB free at the low point
Detectors fired: **5 of 13**
Config: `/home/you/my-app/echolot.yml` (sha 3d78738defec) · thresholds: built-in defaults

## Markers
_the names `domains` lists and the `AGENTTMP_` ones; medians per run, self time with children subtracted_

| Marker | Runs | N | Self, ms | Total, ms | Max, ms | Threads |
|---|---|---|---|---|---|---|
| collection_load | 5/5 | 1 | 923.8 | 923.8 | 923.8 | (async) |
| collection_mapping | 5/5 | 1 | 96.0 | 118.3 | 118.3 | arch_disk_io_1 |

## Frames that missed their deadline
_the platform classifies every frame itself, and says whose fault it was. The only detector that answers "which frames stuttered" rather than "where did the total go" — and it needs no instrumentation in the app at all._

| Where | Runs | N | Total, ms | Max, ms | Evidence |
|---|---|---|---|---|---|
| App Deadline Missed | 5/5 | 4 | 427.51 | 130.89 | Self Jank · 4 of 19 frames · longest 188.7 ms |

<sub>detector `frame_jank`, params: {'min_frames': 3, 'min_overrun_ms': 4}</sub>

## Where the main thread spent its time
_measured as SELF time, children subtracted — otherwise one event lands in the report several times at different depths and the agent counts it twice_

| Where | Runs | N | Self, ms | Total, ms | Max, ms | Evidence |
|---|---|---|---|---|---|---|
| inflate | 5/5 | 15 | 222.68 | 241.38 | 49.28 | com.example.app |
| draw | 5/5 | 7 | 120.8 | 120.8 | 34.0 | com.example.app |
| TextLayout:initLayout | 5/5 | 51 | 73.95 | 73.95 | 1.45 | com.example.app |
| Lock contention on a monitor lock (owner tid: 12931) | 5/5 | 9 | 61.6 | 61.6 | 22.4 | com.example.app |

<sub>detector `main_thread_block`, params: {'min_slice_ms': 16}</sub>

## Single occurrences far longer than the same work usually takes
_the pair to main_thread_block. That one asks where the main thread's time went in total; this one asks which single occurrence was out of line with its own history. A heavy tail hides from every sum._

| Where | Runs | N | Total, ms | Max, ms | Evidence |
|---|---|---|---|---|---|
| inflate | 2/5 | 1 | 86.2 | 86.2 | median 12.1 ms of 15 · worst 7.1× |

<sub>detector `main_thread_outlier`, params: {'factor': 4, 'min_abs_ms': 40, 'min_occurrences': 5}</sub>

## Monitor contention
_ART writes a "Lock contention on ..." slice with the owner's tid — ready-made evidence_

| Where | Runs | N | Total, ms | Max, ms | In the code | Evidence |
|---|---|---|---|---|---|---|
| com.example.app | 5/5 | 9 | 61.6 | 22.4 | owner at StoreRepository.kt:30 · blocked at StoreRepository.kt:61 | monitor contention with owner DefaultDispatcher-worker-3 (12931) at void com.example.app.data.StoreRepository.update(com.example.app.data.Item)(StoreRepository.kt:30) waiters=0 blocking from com.example.app.data.Item com.example.app.data.StoreRepository.find(long)(StoreRepository.kt:61) |

<sub>detector `monitor_contention`, params: {'min_block_ms': 8, 'max_total_ms': 50, 'name_glob': 'Lock contention on a monitor lock*', 'name_glob_alt': 'monitor contention with owner*'}</sub>

## Blind spots: threads burning CPU with no instrumentation
_names a thread that burns CPU with no slice around it. The agent does not guess — it is handed the fact "thread T ran for 340 ms, zero slices". That is exactly where adding trace{} pays off._

| Where | Runs | N | Total, ms | Instrumented, ms | Evidence |
|---|---|---|---|---|---|
| DefaultDispatch | 5/5 | 0 | 522.09 | 0.0 | 100.0% of CPU outside slices |

<sub>detector `uninstrumented_cpu`, params: {'min_running_ms': 50, 'max_covered_pct': 50}</sub>

**Silent:** anr, anr_risk, app_init, binder_txn, gc_pressure, io_wait, repeated_work, runnable_starvation

<sub>trace_processor v56.1</sub>

The example is what analyze prints, number for number, for the demo that comes with echolot: five cold starts of an app that does not exist, built by code.

report.md is for you, report.json for the agent. An 81 MB trace with 475k slices comes out as a 14 KB report.json in about five seconds.

What changed

The report says where the time went in one set of traces. The question people arrive with has a second half — it was 3 s, now it is 7 s — and that needs two sets.

echolot compare                       # inside an investigation: previous round vs latest
echolot compare old.json new.json     # or name them

One table, sorted by how far each row moved. The top row is usually the answer.

The sample comparison: a thread that is new and has no slices, a function that got slower inside and holds, and one called more often whose move does not hold yet

column what it says
Δ how far the median moved, and by what factor
N how many times it ran, before and after: called more often is a different bug from slower inside
Holds the range the move lies in, 95% sure. yes when the range stays on one side of zero; no asks for another round of collect, and says about how many runs a side would settle it

A report built against other thresholds, or on a device whose clock moved or throttled between the rounds, is named above the table. See Comparing.

Detectors

detector what it catches
main_thread_block where the main thread spent its time, by self time
app_init what ran after the Application was created, before the first Activity: initializers by name, and the ContentProviders and Application.onCreate nobody traced
gc_pressure frequent or expensive GC, and waits on allocation
monitor_contention lock contention, with the owner's tid as evidence
binder_txn long synchronous IPC, and death by a thousand cuts
runnable_starvation thread ready to run but preempted on CPU
uninstrumented_cpu threads burning CPU with no instrumentation, and what they ran when the recording sampled callstacks
frame_jank frames that missed their deadline, and whose fault it was
anr_risk stretches where the main thread never got back to the message queue
anr ANRs the system recorded during the trace, with its own reason
main_thread_outlier one occurrence far longer than that work usually takes
repeated_work the same work reached from more than one caller, costing about the same both times
io_wait threads the kernel parked waiting for a block device

uninstrumented_cpu, io_wait and frame_jank find a problem where nobody wrote a trace{} call, as Analysing explains. What every detector sees, with its default thresholds, is what echolot explain prints, and writing your own is in Detectors.

Commands

echolot --help lists every command in four groups. Every verb after /echolot is an echolot command of the same name, except setup, which only the agent has. The pipeline — collect, analyze, compare — is under Without an agent.

Yours

command what it does
echolot where this project stands, and the next step
echolot init install or update the .claude/ layer, add echolot's two lines to .gitignore, and check the environment. With the plugin, its door runs echolot init --for plugin, which leaves .claude/ out. --private keeps everything it writes out of git, for this clone only
echolot hunt "<what regressed>" open an investigation — see The investigation
echolot doctor environment + self-check on a synthetic trace; exit 0 when every check passes, 1 when one fails or the self-check cannot run, 2 when trace_processor cannot be downloaded; -q for three lines (four where Codex is used)
The agent's, behind /echolot — you do not call these
command what it does
guide how to work with this tool, printed by the package — what an agent without the .claude/ layer reads instead of it
report views of the last report without opening the json: what fired, one detector's rows with the evidence kept short, the window, the markers — --json for any of them
scan what the repository says about itself, read as text: the app module and its applicationId, the variants and which one to measure on, the macrobenchmark with its tests and the sections it measures, the gradle task that runs it, the devices attached — and an echolot.yml to start from
anr an ANR report from the field — the lock chain, the few threads that were not idle, and where their frames are in this checkout. Crashlytics and Play Console exports, and the device's own dumpsys dropbox record
probe processes, threads by CPU, scenario anchor candidates — the threads' sections and the process's async ones
names slice name inventory and detector mask coverage
domains slice-to-code map and instrumentation coverage — literals, and names kept in a const val and passed through the project's own wrapper
mark the first temporary markers for a project with none, from the manifest and the SDK, or from an ANR report's own frames with --from-anr — --apply / --remove. --pools lists where a thread or pool is created with the JDK's default name instead
calibrate thresholds derived from known-healthy runs
explain list the detectors and their parameters
Improving the tool
command what it does
reflect the same kind of report, over an agent session — how the tool was used, where it got in the way. Full detail for Claude Code and Codex; from any other client, built from the run log and honest about what it could not see

Requirements

Python 3.10 or newer
curl on PATH — the one download in the next row goes through it
trace_processor (fetched once) downloaded by the first command that needs it, usually echolot init: 10–14 MB, checked against its SHA-256. Behind a proxy or offline, see Determinism
adb on PATH — ships in the Android SDK platform-tools
Device a phone or emulator with USB debugging on
Agent (optional) Claude Code or Codex with the plugin, or Claude Code with the .claude/ layer, for the full workflow; Cursor and others via echolot guide
git (for the plugin) the marketplace fetches the plugin with it. The install failed with git 2.33 and works with 2.50; the versions in between are not checked yet (#205)
Android 12+ (for one detector) frame_jank reads SurfaceFlinger's frame timeline. Older devices do not have it, and the detector is then silent — which reads exactly like "no bad frames"

Validated on Android 14 (emulator) and Android 13 (Galaxy A51).

Project layout

android-project/
├── echolot.yml       ← the project half, committed
├── local.yml         ← device serials, binary path; in .gitignore
└── .echolot/         ← traces, reports, run log, reflect reports; in .gitignore

echolot init adds the last two to .gitignore at the root of a git checkout, and otherwise prints the two lines to add: a trace is tens of megabytes.

Documentation

The documentation is a site with search across every page: grishan0v.github.io/echolot. The same pages are on GitHub, starting at the documentation index. Or jump straight in:

document about
🎬 Collecting collect, the three modes, callstack sampling, merging repeats
🔎 Analysing probe, names, domains — from a trace to a place in the code
🔀 Comparing compare — what changed between two reports, and when the repeats support saying so
🧊 ANRs anr — reading a report from the field, and measuring a freeze
🏷️ Marking mark — first markers for a project with no instrumentation
⚙️ Detectors writing your own, the context views, self time versus total
📏 Calibrating thresholds from healthy runs, why rank beats percentile
🔒 Determinism the pinned trace_processor, doctor, the self-check
🤖 The agent layer the plugin and the .claude/ layer, and why the loop lives in a subagent
🪞 Reflect the report over an agent session, for improving the tool

Agent-facing reference material ships inside the package under echolot/claude/skills/echolot/references/ — the report schema, the config schema, how ART names things, and how to capture a trace by hand.

Status

v0. Everything planned for it is in place. What each detector was checked against, from the synthetic fixture to live traces, is in Determinism.

A failed detector never fails the run: the error goes to stderr and into report.json. Working on the tool itself starts at CONTRIBUTING.md, which GitHub shows as a tab beside this README.

License

Apache 2.0

Metadata

Release files for echolot 0.11.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for echolot 0.11.0
File Size Uploaded
echolot-0.11.0.tar.gz 810.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for echolot 0.11.0
File Interpreter ABI Platform
echolot-0.11.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.4 MB

Release files / echolot-0.11.0.tar.gz

Download URL echolot-0.11.0.tar.gz
Size 810.2 kB
Tags Source
SHA-256 checksum
How to use checksums
e47a137d8fe869cbac1a2acbb12df31a7a4afca22061a3d5fe2871f6fe683c97
BLAKE2b-256 checksum
How to use checksums
8f0f51c67ef81d60869eb67dbd48775cb6dc9d55bef358733a0be2149d2430cc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.

Transparency log

Release files / echolot-0.11.0-py3-none-any.whl

Download URL echolot-0.11.0-py3-none-any.whl
Size 565.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
176b5252ac2c4f51a9f63af3e5175447dfea2574f9319d34823f27161c739084
BLAKE2b-256 checksum
How to use checksums
fb4a5c5591acb3413d9e308737f21033235ba97502942ca6f4518e6adb9e8638
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.

Transparency log

Release history Release notifications | RSS feed

0.13.0

2 release files

0.12.0

2 release files

This release

0.11.0 This release

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.3

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page