# Meshtastic Device Ops

> Discover, connect to, drive, configure, observe, and flash Meshtastic devices through the Meshtastic MCP server. Use when an agent needs to bring up a radio (serial or TCP), read or change device/channel config, send messages, inspect the node DB, watch live packets/telemetry/logs via the recorder, recover a bricked device, or build/flash firmware. Covers the non-e2e MCP tool surface; for cross-plane device↔app testing use the `meshtastic-e2e` skill.

- Skill: `meshtastic/meshtastic-device-ops` (Agent Skill)
- Install (CLI): `npx skillmds@latest add meshtastic/meshtastic-device-ops`
- Raw SKILL.md: https://api.skillmd.com/api/skills/meshtastic/meshtastic-device-ops/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: GPL-3.0-only
- Author: meshtastic (https://skillmd.com/u/meshtastic)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/meshtastic/meshtastic-device-ops

---


# Meshtastic device operations (via the MCP server)

The Meshtastic MCP server exposes a large tool surface, plus resources and prompts. This
skill maps common operator intents to the right tools and the safe order to use them.

## First: know your environment

- **`doctor`** (tool) or the **`meshtastic://doctor`** resource — what's installed, what's
  missing, and the exact command to acquire it. Call this first if any tool fails with a
  missing-prerequisite error, or before an e2e/flash run.
- **`meshtastic://capabilities`** resource — one line: which capability groups are active
  (`core`, `firmware`, `android`, `apple`).
- Prereq binaries are gated: `firmware` tools (build/flash/boards/userprefs) register only
  when `MESHTASTIC_FIRMWARE_ROOT` + `pio` are present. Core (admin/recorder/transport) is always on.

## Connect

Two transports, same admin surface:
- **Serial:** a USB port from `list_devices` (e.g. `/dev/cu.usbmodem101`).
- **TCP:** `tcp://host:port` (a networked node, or a virtual `meshtasticd` at `127.0.0.1:4403`).

**Prefer the CLI subcommands for read-only discovery** — they cost no MCP schema tokens
and work without the server running:
```bash
meshtastic-mcp devices                  # find ports (bash, ~0 schema tokens)
meshtastic-mcp devices --all            # include non-Meshtastic serial ports
meshtastic-mcp info <port>              # firmware version, region, node identity
meshtastic-mcp nodes <port>             # mesh peers (long/short name, SNR, last-heard)
meshtastic-mcp boards                   # list all PlatformIO board envs
meshtastic-mcp boards --arch esp32s3    # filter by architecture
meshtastic-mcp boards get heltec-v3     # full metadata for one board
# All accept --json for structured output.
```

Fall back to MCP tools when already in an MCP session or when you need board metadata
for a follow-on `build_start`:
```
list_devices                     # find candidates (include_unknown=true to see every port)
device_info <port>               # firmware version, node identity, channel summary
list_nodes <port>                # the node DB (long/short name, SNR, last-heard, position)
```

One MCP call per serial port at a time — the port lock is **exclusive and non-blocking**:
contention fails fast with "busy … Retry shortly" (it does not queue). Open → act → close.

## Configure (mutating — confirm-gated)

```
get_config <port> <section>      # lora | device | position | power | network | display | ...
set_config <port> <section> <field>=<value> ...
get_channel_url <port>           # the shareable channel URL (keys)
set_channel_url <port> <url>
set_owner <port> --long ... --short ...
```
After a write, **reboot then re-read** to prove it persisted to NVS, not just RAM
(`reboot <port>` → `get_config`). Region (`lora.region`) and `network.enabled_protocols`
are the two that bite — see `meshtastic-e2e` `topology.md`.

## Vanity identity (chosen node id / app colour)

A node's number is `crc32(x25519_public_key)` and every app paints it with the low 24 bits
read as RGB, so both are chosen by grinding keys, not by setting a field.

```
vanity_grind_start color=crimson tol=6        # or pattern=dc80, or both; returns job_id
vanity_grind_poll <job_id>                    # hits[]; NEVER use one with verified=false
vanity_preview <private_key>                  # what any key gives you — no device, no GPU
vanity_apply <private_key> <port> confirm=true
```

- `tol` is free and finds a hit orders of magnitude sooner — always offer it.
- Grinding needs `mvgrind` (`doctor` prints how to build it); preview/apply do not.
- `vanity_apply` **replaces the identity**, drops the old NodeNum from the node's own DB, and
  reboots the board. It needs `lora.region` set (keygen is skipped while UNSET) and refuses an
  unclamped key. Keep the previous key if a way back matters.
- Hits are private keys. Don't echo them further than needed; see `docs/vanity.md`.

## Message + observe

```
send_text <port> <text> [--dest <nodeId>]   # broadcast (^all) or directed
```
The **recorder** is always capturing to JSONL; query windows instead of tailing:
```
packets_window     # recent RX/TX packets (portnum, from/to, payload) — wire truth
telemetry_timeline # device/environment metrics over time
logs_window        # firmware log lines
events_window      # recorder-marked events
mark_event         # drop a labeled marker to anchor a later query
recorder_status / recorder_pause / recorder_resume / recorder_export
```
For app-visible delivery vs wire truth (broadcast shows an error icon in a flat mesh even when
delivered) see `meshtastic-e2e` `references/loop-outbound.md`.

## Recover + flash (firmware capability)

```bash
# Board lookup — use the CLI (no schema overhead):
meshtastic-mcp boards --query <slug>                     # find the env name
meshtastic-mcp boards get <env>                          # confirm arch + upload_speed
```
```
# Build + flash via MCP (async to avoid 60 s client timeout):
build_start <env>                                        # returns build_id immediately
build_poll <build_id>                                    # poll until status=done
pio_flash <env> <port> / erase_and_flash <env> <port> / update_flash <env> <port>
touch_1200bps <port>          # bounce into the bootloader (nRF/RP2040)
```
Chip-specific escape hatches when pio can't help: `esptool_*`, `nrfutil_*`, `picotool_*`
(raw passthroughs; destructive ones are confirm-gated). For a wedged USB device, power-cycle
the hub port with `uhubctl_list` / `uhubctl_power` / `uhubctl_cycle`.
> **Linux:** `uhubctl` requires udev rules to work without root. Run
> `meshtastic-mcp doctor` — it will detect the permission issue and print
> the exact `sudo curl … && sudo udevadm trigger` command to fix it.

### BLE OTA DFU (manual — no MCP tool covers this yet)

Every `flash`/`pio_flash`/`nrfutil_dfu` path above is USB (serial or UF2). There is
no MCP tool for the *wireless* leg — the Nordic legacy BLE DFU that the Meshtastic
Android app uses for its in-app bootloader/firmware upgrade, and that an
Adafruit/OTAFIX-family nRF52 bootloader (e.g. `meshtastic/Adafruit_nRF52_Bootloader_OTAFIX`)
speaks natively. To validate that path from a dev machine instead of a phone:

1. **Buttonless jump from app mode is one of two GATT services, chosen at compile
   time — check which before assuming a UUID.** `NRF52Bluetooth.cpp` picks
   `BLEDfuSecure` (Nordic Secure DFU, service `0xFE59`, control characteristic
   `8ec90003-f315-4f60-9fb8-838830daea50`) only when the board's `variant.h`
   defines `BLE_DFU_SECURE` — today that's `wio-t1000-s` alone. Every other
   nRF52 board, **including RAK4631**, falls through to plain `BLEDfu`
   (Adafruit's Bluefruit library): legacy service `00001530-...`, control
   characteristic `00001531-...`. Either way: enable notifications/indications
   on the control characteristic, write `0x01` to it, and the node disconnects
   and reboots into the bootloader, advertising under a new BLE name (OTAFIX
   boards use `<BOARD>_DFU`, e.g. `4631_DFU` for RAK4631 — see that repo's
   README "BLE advertising names" table).
2. **Legacy DFU transfer in bootloader mode.** The bootloader's own GATT
   service is always the older Nordic Legacy DFU (`00001530-...`, control
   point `...1531`, data `...1532`) regardless of which service the app used
   to jump there. [`recrof/nrf_dfu_py`](https://github.com/recrof/nrf_dfu_py)
   (pure Python + `bleak`) speaks **only** this legacy service (its
   `DFU_SERVICE_UUID` constant is the `00001530-...` one, full stop) — it has
   no path for a `BLE_DFU_SECURE` board like `wio-t1000-s`, so it's a match
   for RAK4631 and most other nRF52 targets, not a universal tool. Clone it,
   `pip install bleak`, then from that checkout:
   `python3 dfu_cli.py --scan <firmware-or-bootloader.zip> <device-name-or-addr>`.
   Use the `*-ota.zip` release asset (not the `.uf2`/`.hex`) — that's the format
   this DFU protocol expects. One call does both legs unassisted — its
   `jump_to_bootloader()` sends the exact same 2-byte legacy opcode write
   described in step 1, then it rescans and transfers — so giving it the
   **app-mode** name/address up front is usually enough; you don't need a
   separate manual jump. Do the jump as its own step only when you need to
   debug the jump in isolation (its post-jump bootloader rescan matches by
   substring against a literal `"DFU"`/MAC-increment heuristic, not the
   board's exact advertised name, so once already in bootloader mode,
   re-running against the bootloader's own `<BOARD>_DFU` name is the more
   reliable retry).
3. **Finding the device's BLE name is a scan-and-match, and a name prefix can be
   ambiguous — resolve it to exactly one device before acting.** The app's
   advertised name is `<short_name>_<hex><hex>` where the hex suffix is the
   last two bytes of the nRF52's FICR `DEVICEADDR` (`getDeviceName()` in
   `firmware/src/main.cpp`) — **not** derived from `my_node_num`/`device_info`'s
   node id in any way you can compute offline. Scan (`BleakScanner.discover`)
   and match by the known `short_name` prefix, but confirm the scan turned up
   exactly one match before connecting: `nrf_dfu_py` (and most such tools)
   connects to the first match among the names/addresses you give it, so an
   ambiguous prefix on a mesh with more than one device sharing it can jump or
   flash the wrong node.
4. **`bluetooth.mode = RANDOM_PIN` needs a human (or the app) watching for the
   passkey — an unattended agent session can't complete pairing, screen or no
   screen.** The firmware sends the 6-digit passkey to `BluetoothStatus` (the
   app's pairing UI reads it from there) and additionally shows it on-screen
   `#if HAS_SCREEN` — some RAK4631 builds do have one (e.g. `rak_wismesh_pocket`
   is explicitly "rak4631 pin map + OLED" in `platformio.ini`; don't assume
   `hw_model: RAK4631` alone tells you whether the running build has a
   display). `capture_screen` (see *Hardware UI checks* above) can read an
   OLED when one's present — worth reaching for on a future attempt, since
   neither it nor a live debug log was actually checked in real time here;
   the passkey/display behavior below is reasoned from firmware source
   after the fact, not confirmed against what this device actually showed.
   Whether the passkey is visible in a live debug log (`set_debug_log_api`)
   depends on the exact firmware build — `onPairingPasskey`'s `LOG_INFO`
   included the passkey digits in
   `v2.7.26.54e0d8d` (what this was tested against) and still does on
   `develop`, but a firmware security fix logged only `match_request` for a
   stretch of the 2.7.x line in between (redacting pairing secrets from
   logs) — don't assume the log line carries it on an arbitrary build; the
   app's pairing UI is the one path guaranteed to receive it regardless.
   Neither was being watched in a scripted `bleak` session here, so the OS
   pairing prompt sat with nothing to type in and the connection was
   dropped. This is
   expected `RANDOM_PIN` behavior, not a bug — switch to `FIXED_PIN` (a value
   you already know) for scripted/headless testing instead of chasing it.
   `get_config`-read the current `bluetooth.mode`/`fixed_pin` *before* changing
   anything, and restore them (`set_config` + `reboot` + `get_config` to
   confirm) once testing is done — a device left in `FIXED_PIN` carries a
   known, reusable pairing credential indefinitely otherwise.
   Separately, firmware `develop` (2.8) does carry two real nRF52 BLE-pairing
   fixes not yet in 2.7.x that are worth knowing about if pairing looks
   flaky on a 2.7.x build: a passkey callback that wasn't restored after a
   BT disable/re-enable cycle without a reboot (#11027), and a BLE-task
   stack overflow that could crash the device mid-pairing on nrf52840
   targets (#11190).

**macOS-specific friction**, all one-time per machine/device pair, not per session:
- Bluetooth must be explicitly on (Control Center) — `bleak`/CoreBluetooth error
  clearly (`BleakBluetoothNotAvailableError: POWERED_OFF`) when it isn't, so this
  fails fast rather than silently.
- The terminal app driving the script needs Bluetooth permission granted
  (System Settings → Privacy & Security → Bluetooth) — without it, scanning
  either errors or (confusingly) just finds nothing.
- A newly-enumerated USB device can trigger a silent macOS "accessory" permission
  popup that hides the port from `list_devices`/`ls /dev/cu.*` until approved —
  if a device that was just flashed or reset seems to vanish from USB entirely,
  check for that popup before assuming a bad flash.

## Hardware UI (OLED) checks

`send_input_event` drives the device's buttons; `capture_screen` grabs the OLED (camera/OCR
optional — see `doctor` for the `[ui]` extra). This is device-only; for app UI use `meshtastic-e2e`.

## Grounded answers

- **`android_docs_search` / `android_docs_fetch`** — Android/Compose/API questions answered from
  the official Knowledge Base (no guessing) when working with the Android app.

## Prompts (slash workflows)

- **`bringup_device`** — connect + verify a healthy baseline (firmware, region, node count).
- **`inbound_loop`** / **`triage_e2e_failure`** — e2e workflows (see the `meshtastic-e2e` skill).

## Hard rules

1. One call per serial port at a time (exclusive non-blocking lock).
2. Mutations are confirm-gated and reversible-by-reboot only for RAM writes — re-read after reboot.
3. `factory_reset(full=true)` wipes BLE bonds + the identity key; `full=false` keeps them.
4. Prefer the recorder windows over ad-hoc reads — they're timestamped and align with app snapshots.
5. Never apply a ground key whose `verified` is false, and never apply one without telling the
   operator the old node id first — the change is not reversible without the previous key.

