Run Meshtastic (Desktop & Emulator)
SkillDev toolsLets your agent launch the Meshtastic app on desktop or an Android emulator, drive its UI, and take screenshots.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Run Meshtastic (Desktop & Emulator) skill
About this capability
Run, launch, drive, and screenshot the Meshtastic app — the Compose Desktop app via hot reload (semantic clicks, live reload, window screenshots) or the Android app on an emulator (scripted deeplink bring-up, uiautomator taps, screencap). Use when asked to run the app, verify a UI change in the real
What this skill tells your AI
The instructions your AI receives, as published by meshtastic/meshtastic-android in .claude/skills/run-meshtastic-android/SKILL.md and read by ahel’s review.
Two binaries, two drivers, one simulated radio. All paths are relative to the repo
root. Both drivers are Python 3, stdlib only, and print --- <cmd> done --- per
step on stderr.
- Desktop (
:desktopApp, Compose/JVM, runs on this machine): launch with the hot-reload run task, drive through.claude/skills/run-meshtastic-android/driver.py, which speaks MCP JSON-RPC to:desktopApp:hotMcpServer— semantic tree, clicks by node id,reload(recompile + hot-swap), window screenshots. - Emulator (
:androidAppfdroid debug): drive through.claude/skills/run-meshtastic-android/driver_emulator.py— scripted deeplink bring-up, uiautomator-based taps, screencap. - Radio: neither app does much without one.
mcp__meshtastic__replay_start(meshtastic MCP) serves a simulated Meshtastic TCP radio; the desktop app reaches it at127.0.0.1:<port>, an AVD at10.0.2.2:<port>. One client per session — run the desktop and emulator against different ports (e.g. 4403 and 4404).
Prerequisites
- Gradle runs go through the machine-wide queue:
~/.claude/bin/gradle-queue. Everything after its--is Gradle arguments — it runs./gradlewitself (gradle-queue -- ./gradlew tasksfails withTask './gradlew' not found). - The JetBrains 25 JDK Gradle provisioned at
~/.gradle/jdks/jetbrains_s_r_o_-25-*/…/Contents/Home(the drivers find it themselves). - Emulator leg: a running AVD (
adb devices) with the fdroid debug build installed (./gradlew :androidApp:installFdroidDebugvia the queue if missing). - A simulated radio, e.g.
replay_start(source="meshcon", sim_nodes=30, port=4403, rate=2, loop=true, sim_profile={"traceroute_pairs_per_hour": 0})— mute the traceroutes or their modals bury whatever you are testing.
Run: Desktop (agent path)
Kill stray instances first — two apps fight over the pid file and the MCP server
reports connected:false forever:
pgrep -fl "MainKt|devtools.Main" # kill any hits before launching
Launch (the Nix dev shell's Darwin stdenv breaks the MapLibre FFI — strip it):
env -u DEVELOPER_DIR -u SDKROOT -u CC -u CXX -u LD -u AR -u NM -u RANLIB -u STRIP -u NIX_CC \
JAVA_HOME=$(ls -d ~/.gradle/jdks/jetbrains_s_r_o_-25-*/*/Contents/Home | tail -1) \
PATH=/usr/bin:/bin:/usr/sbin:/sbin \
~/.claude/bin/gradle-queue -- :desktopApp:hotRunAsync
BUILD SUCCESSFUL + desktopApp/build/run/main/main.pid on disk means the app is up.
Drive it. Each driver invocation spawns a fresh hotMcpServer, auto-waits for it to
attach (asynchronous — the driver polls status for you), runs the commands in
order, and exits:
python3 .claude/skills/run-meshtastic-android/driver.py tree # semantic tree (JSON, node ids)
python3 .claude/skills/run-meshtastic-android/driver.py click=170 sleep=1.5 tree
python3 .claude/skills/run-meshtastic-android/driver.py raise ss=/tmp/app.png
python3 .claude/skills/run-meshtastic-android/driver.py reload # recompile + hot-swap edits
Run driver.py with no arguments for the full command list (type=NODEID:TEXT,
scroll_to=NODEID:IDX, restart, err, logs, …). tools prints the server's
live tool schemas if they've drifted.
Verified flow (connect to a sim and see its mesh): nav-rail tabs are semantic
Tab nodes — Connect opened via click=<its id from tree>, then the Network
radio button, then the device row for 127.0.0.1 under Recent Network Devices.
The sim's replay_status flips to connected:true within seconds and the Nodes
tab fills with the sim's mesh (RPLY Replay Observer, …).
The connection card can sit on "Reconnecting…" while packets already flow — the
label lags the config download. Trust replay_status and the Nodes list, not the
card text.
Screenshots capture the window's on-screen region, so the window must be
frontmost: always raise before ss. If ss shows your terminal, that's why.
hotMcpServer and reload compile outside gradle-queue (a long-lived stdio
server can't hold a slot) — check ~/.claude/bin/gradle-queue --status before a
reload if other sessions may be building, and keep those runs short.
Desktop deeplink launch (no clicking — but no hot reload)
The desktop app parses the same Meshtastic deeplink URIs from its program args
(Main.kt accepts meshtastic:// and https://meshtastic.org/...), so a connected
app is one command:
env -u DEVELOPER_DIR -u SDKROOT -u CC -u CXX -u LD -u AR -u NM -u RANLIB -u STRIP -u NIX_CC \
JAVA_HOME=$(ls -d ~/.gradle/jdks/jetbrains_s_r_o_-25-*/*/Contents/Home | tail -1) \
PATH=/usr/bin:/bin:/usr/sbin:/sbin \
~/.claude/bin/gradle-queue -- :desktopApp:run --args="https://meshtastic.org/connections?address=t127.0.0.1:4403"
Verified against a sim the app had never connected to before, so it is the deeplink acting, not last-device auto-reconnect. Caveats, all observed:
hotRunAsyncdoes not accept--args(its option list: --auto, --className, --funName, --mainClass, --stdout/--stderr only) — deeplink launch means the plainruntask, which trades away hot reload. Long driving session →hotRunAsync+ the driver's click path; quick "get me a connected app" →run --args=….runblocks, so it holds a gradle-queue slot for the app's whole lifetime. Keep such runs short, or other sessions' builds will queue behind your app.- The deeplink races last-device auto-reconnect: the app can connect to its remembered device first, then switch to the deeplink's target a moment later — if the remembered device is another sim, that sim briefly shows a client too.
- No trust dialog blocked the localhost connect in testing (unlike the Android build, which pops one for a never-seen device).
Run: Emulator (agent path)
Scripted bring-up only — never hand-walk onboarding or the manual-IP dialog:
python3 .claude/skills/run-meshtastic-android/driver_emulator.py -s emulator-5554 \
connect=t10.0.2.2:4404 wait_text=RPLY ss=/tmp/emu.png
connect force-stops the app, relaunches org.meshtastic.app.MainActivity with the
debug-only skip_onboarding extra and the /connections?address= deeplink
(t = TCP, x = BLE, s = serial, n = disconnect — full path list in
docs/en/developer/navigation-and-deep-links.md), then waits for the trust dialog
newer builds pop and taps its Connect button. Success looks like the Connection
screen showing RPLY Replay Observer with a Disconnect button, and
replay_status reporting connected:true.
Other commands: dump, find=TEXT, tap_text=TEXT, tap=X,Y, text=, key=,
swipe=, launch, stop — run with no arguments for the list. Default package is
com.geeksville.mesh.fdroid.debug (-p to override).
Run (human path)
./gradlew :desktopApp:run (via the queue, same env hygiene) opens the window
without hot reload; Ctrl-C to stop. The emulator app is just the launcher icon —
but a debug build launched by icon lands on onboarding; the deeplink path above is
faster even for humans.
Stopping
-
Desktop: take the pid from the app's own pid file — it is a Java properties file (not a bare pid) and self-deletes on clean exit:
kill $(sed -n 's/^pid=//p' desktopApp/build/run/main/main.pid)If the pid file is gone but a process lingers,
pgrep -af "MainKt|devtools.Main", check each match's path for this checkout, and kill that specific PID — a barepkillon the pattern can take down another checkout's or session's app. -
Emulator:
driver_emulator.py -s <serial> stop. -
Sim:
replay_stop. Sessions the sim created are real user data in the app's DB; the app's last-selected device is now the sim — switch back on the Connect screen if a real radio should reconnect.
Gotchas
gradle-queue -- ./gradlew …fails: args after--go to./gradlew, which the wrapper runs itself. And piping its output (| tail) eats the exit code — check forBUILD SUCCESSFULin the text, not$?.tap_textmatches substrings: bareConnectalso matches "Stop Connecting" and "Reconnecting…". The driver tries exact text first; wait on the trust dialog's title ("Connect to this device"), not its button.- The MCP server attaches asynchronously — a
treefired immediately after spawn returns "No application is currently connected". The driver auto-waits; if it times out, the app isn't running (or a stray instance holds the pid file). take_screenshotneeds the window visible —raisefirst (System EventsAXRaisetargeting the window literally named "Meshtastic Desktop"; with two java processes, pid-based frontmosting picks the wrong one).- One client per simulated node. Two apps pointed at the same sim don't
queue — they fight, stealing the connection back and forth so both flap
between Connected and Reconnecting. The desktop app holding port 4403 means
the emulator needs its own
replay_starton 4404. adb shell input textcan leave a trailing space; dialogs' Add buttons silently no-op on it. And don't press BACK to dismiss the keyboard — it closes the dialog.- Swipe near x≈30 in lists; mid-screen swipes get eaten by embedded maps.
Never busy-loop adb — pace with
adb shell sleep 2or the emulator drops offline. - The desktop app auto-reconnects to its last device on launch — it may already be connected to a real radio when you attach; check the Connect screen before assuming the sim.
Troubleshooting
Task './gradlew' not found in root project→ you passed./gradlewaftergradle-queue --; drop it.BUILD FAILED in 1sfromhotRunAsyncwith slots free → read the full output; the queue wrapper's exit code vanishes behind pipes.- Screenshot is your terminal →
raisebeforess(window wasn't frontmost). connected:falseforever fromstatus→ strayMainKtfrom another checkout or worktree;pgrep -fl MainKt, kill, relaunch.- Trust dialog never tapped, app stuck on dialog → older driver matched
"Reconnecting…"; re-run
tap_text=Connect(exact match wins now). - UI card stuck "Reconnecting…" but sim says
connected:true→ not stuck; config download in progress. Check the Nodes tab for the sim's nodes. - Connection flapping →
desktopApp/build/run/main/hotRun.stderr.txtcarries the transport-level story ("Handshake stall detected at Stage 1 … requesting forced transport restart" is the app self-recovering, not a crash). Also check that a second app isn't fighting for the same sim (one client per simulated node). - A bare
statusright after spawn can reportconnected:falsewhile the app is fine — the server attach is asynchronous;wait(or any UI command, which auto-waits) is the truth.
Signals
- GitHub stars
- 2k
- Forks
- 512
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
run-meshtastic-android- Source
- github.com/meshtastic/meshtastic-android