Firmware & Flash
Five tools handle the end of the build pipeline — listing what firmware is available, downloading it, flashing it to a real device, capturing serial output from the device after flash, and (for JTAG debugging) fetching the unstripped application ELF.
| Tool | What it does |
|---|---|
firmware.list | List completed builds that have firmware ready to download. |
firmware.download | Get download metadata for a specific build’s firmware. |
elf.download | Download the unstripped application ELF for a previous build (for openocd-esp32 / GDB). |
flash.run | Flash firmware to a locally-connected ESP device over serial. |
monitor.run | Capture serial output from a locally-connected device for a bounded duration. |
firmware.list
Shows which builds have finished successfully and have firmware you can download.
Input:
{}
Optionally filter by a specific build:
{ "job_id": "0abf...e2" }
| Field | Required | Notes |
|---|---|---|
job_id | No | Filter to one build. Without it, all succeeded builds are listed. |
Returns:
{
"builds": [
{
"task_id": "0abf...e2",
"target": "esp32s3",
"status": "succeeded",
"build_type": "release"
}
]
}
No side effects. Safe to call any time.
firmware.download
Gets the metadata you need to download a build’s firmware. The actual binary transfer happens over the firmware WebRTC DataChannel — this tool gives you the artifact information.
Input:
{
"job_id": "0abf...e2"
}
| Field | Required | Notes |
|---|---|---|
job_id | Yes | Task ID of a succeeded build. |
output_dir | No | Where to save the firmware files. |
Returns:
{
"job_id": "0abf...e2",
"status": "succeeded",
"artifact_lines": [
"build/flash_bundle.tar.gz",
"build/bootloader.bin",
"build/partition_table/partition-table.bin",
"build/<project>.bin"
],
"output_dir": "/tmp/firmware"
}
The build must have status succeeded. Calling this on a failed or
running build returns an error.
Primary artifact is
flash_bundle.tar.gz. Remote builds assemble a signed, self-describing flash bundle (manifest.json+files/containing bootloader, partition table, and app segments) and return it to the client in the same session — there is no separate fetch step. This is whatflash.runand the CLIespctl flashboth consume. The individual.binfiles are still in the listing for inspection, but you almost never pass them to the flasher directly.
elf.download
Downloads the unstripped application ELF for a previously-completed
remote C ESP-IDF build. Used to drive
openocd-esp32 +
xtensa-esp32sX-elf-gdb for JTAG-level debugging — breakpoints, register
inspection, RTOS thread view. The flash bundle (flash_bundle.tar.gz)
only carries flashable segments; debug symbols live in the ELF, which is
auto-persisted on the agent next to the bundle and fetched on demand.
Input:
{
"build_id": "0abf...e2",
"output_path": "/tmp/my_proj.elf",
"control_url": "https://esphome.cloud"
}
| Field | Required | Notes |
|---|---|---|
build_id | Yes | Same job_id used to submit the build via build / build.start. |
output_path | Yes | Where to write the ELF. Parent directory created if missing. |
control_url | No | Control plane URL. Falls back to CONTROL_PLANE_URL env var. |
Returns:
{
"build_id": "0abf...e2",
"path": "/tmp/my_proj.elf",
"size_bytes": 11230544,
"sha256": "f0a63ee2...",
"next_steps": "Use this ELF with openocd-esp32 + xtensa-esp32sX-elf-gdb."
}
Requires MCP_AUTH_SECRET in the environment (used as a Bearer token for
/mcp-session). Reuses the WebRTC firmware DataChannel and the same
chunked transport (FirmwareMetadataEnvelope → N×FirmwareChunkEnvelope
→ FirmwareCompleteEnvelope) as flash_bundle.tar.gz delivery.
Rust no_std builds have no companion ELF on the agent. Builds submitted via
espctl build --rust-elfuse the ELF as the input — you already have it locally.elf.downloadreturns an error for thosebuild_ids.
Persistence is per-workspace. The agent stores ELFs under
<ESPCTL_WORKSPACE_ROOT>/<build_id>/<app>.elf(server-side path). Workspaces are GC’d periodically; very oldbuild_ids may no longer be retrievable.
flash.run
Flashes firmware to an ESP device connected to your computer’s USB
port. Uses the pure-Rust espflash
library directly — no Python esptool.py dependency. You do not
need to pip install esptool, run a venv, or have Python in your PATH.
Input:
{
"firmware_path": "/path/to/build/flash_bundle.tar.gz",
"port": "/dev/ttyUSB0",
"baud": 460800
}
| Field | Required | Notes |
|---|---|---|
firmware_path | Yes | Path to a flash_bundle.tar.gz (produced by build + firmware.download), an extracted bundle directory, or a raw .bin / .elf file. The bundle form is preferred because it carries the bootloader, partition table, app, and signed manifest in one file. |
port | No | Serial port. Auto-detected if you have exactly one ESP device connected. |
baud | No | Flash baud rate. Default 460800. |
Returns: Status of the flash operation (success or error with details).
When given a bundle, flash.run reads manifest.json, verifies every
segment’s sha256, and writes all segments to flash in a single
espflash session (critical — per-segment writes would reboot the chip
between segments and hang). The chip reboots once at the end.
Policy: never install
esptool.py. Ifflash.runor the CLIespctl flashfails, file a bug report atdocs/espctl-flash-bugs-YYYY-MM-DD.mdin the aegis repo following the pattern ofdocs/infra-bugs-2026-04-11.md. Do NOT work around the failure by installing Python esptool — that silently hides real bugs in the build-to-flash pipeline.
Local only. This tool runs on your computer, not on the build server. It only works in local/stdio MCP mode — not in the browser. For browser flashing, use the Flash tab in the MCP Console.
monitor.run
Captures serial output from a connected ESP device for a bounded
duration. Used right after flash.run to verify a board boots and is
running the firmware you just wrote — for example, watching for the
1 Hz heartbeat log line emitted by the wizard’s Phase-0 verification
firmware.
Input:
{
"port": "/dev/cu.usbmodem1101",
"baud": 115200,
"duration_sec": 30,
"filter": "heartbeat",
"reset_on_connect": true
}
| Field | Required | Notes |
|---|---|---|
port | No | Serial port (e.g. /dev/ttyUSB0, /dev/cu.usbmodem14101, COM3). Auto-detected if you have exactly one ESP device connected. |
baud | No | Baud rate. Default 115200 (the ESP-IDF console default — different from flash.run’s 460800). |
duration_sec | No | How long to capture. Default 30, capped at 600. |
filter | No | Substring; only lines containing it appear in output. Useful for "heartbeat" verification. |
reset_on_connect | No | Default true — pulses DTR/RTS once after open so the chip boots into the application under the capture window. Set false on boards without an auto-reset circuit, or when another tool already reset the chip. Narrower than espctl probe — never enters bootloader mode. |
Returns:
{
"success": true,
"port": "/dev/cu.usbmodem1101",
"baud": 115200,
"duration_ms": 30024,
"bytes_read": 18432,
"lines_captured": 32,
"output": "I (123) heartbeat: tick 0\nI (1234) heartbeat: tick 1\n...",
"truncated": false,
"message": "Captured 18432 byte(s) over 30024 ms from /dev/cu.usbmodem1101 at 115200 baud."
}
The capture buffer is bounded at ~512 KB; if the device produces more
than that within the window, truncated is true and the tail is
dropped.
Local only. Like
flash.run, this only works in local/stdio MCP mode — not in the browser. Browser monitoring uses Web Serial via the MCP Console — Monitor tab.
No panic decoder. This is a raw UTF-8-lossy byte dump. It does not have
idf.py monitor’s ELF-aware backtrace decoding. For long interactive monitoring use the CLIespctl monitorinstead.
CLI: espctl ports
Lists every serial port the OS exposes. USB ports include their VID:PID. Run this first to find your board.
espctl ports
No flags. An empty list prints No serial ports found.
Output
Human mode (table):
PORT TYPE USB VID:PID
----------------------------------------------------------------------
/dev/cu.usbmodem1101 USB 303A:1001
/dev/cu.Bluetooth-Incoming-... Bluetooth -
JSON (--json): an array of port objects. USB entries also carry
vid, pid, vid_pid, manufacturer, product, serial_number.
# Filter to USB serial adapters
espctl --json ports | jq '.[] | select(.vid_pid != null)'
CLI: espctl probe
Opens the bootloader handshake against a real device and reports the
chip type (with revision), MAC address, and flash size. Uses the same
pure-Rust espflash connection
as espctl flash — no Python esptool.py.
espctl probe --port <port>
Inputs
| Flag | Notes |
|---|---|
--port | Required. Run espctl ports first if you don’t know it. |
Output
Human mode:
Port: /dev/cu.usbmodem1101
Chip: ESP32-S3 (revision v0.2)
MAC: 7c:df:a1:00:11:22
Flash size: 8MB
JSON (--json):
{
"port": "/dev/cu.usbmodem1101",
"chip_type": "ESP32-S3 (revision v0.2)",
"mac_address": "7c:df:a1:00:11:22",
"flash_size": "8MB"
}
Failure modes
- Port not in the OS port list → exit 1 (with a hint to run
espctl ports). - Bootloader handshake fails → exit 1.
CLI: espctl flash
Flashes a bundle to a connected device. The MCP equivalent is
flash.run — same engine, same single-session writeback.
espctl flash <bundle_path> --port <port> [--baud <rate>]
Flag matrix
| Argument | Default | Notes |
|---|---|---|
bundle_path (positional) | required | An extracted bundle directory or flash_bundle.tar.gz. |
--port | required | Serial port. |
--baud | 460800 | Flash baud rate. |
Examples
# Default baud (460800)
espctl flash ./build/flash_bundle.tar.gz --port /dev/cu.usbmodem1101
# Faster — if your USB↔serial adapter and cable can keep up
espctl flash ./build/flash_bundle.tar.gz --port /dev/ttyUSB0 --baud 921600
The bundle form (produced by espctl build) carries manifest.json
with sha256 checksums plus all segments (bootloader, partition table,
app). The flasher writes them in one espflash session — per-segment
writes would reboot the chip between segments and hang.
CLI: espctl monitor
Opens a serial monitor on a device and streams output to your terminal. Auto-reconnects on disconnect by default.
espctl monitor --port <port> [--baud <rate>] \
[--no-reconnect] [--no-reset-on-connect]
Flag matrix
| Flag | Default | Notes |
|---|---|---|
--port | required | Serial port. |
--baud | 115200 | Monitor baud rate (default IDF console). |
--no-reconnect | false | Exit on disconnect instead of waiting for the device to come back. |
--no-reset-on-connect | false | Skip the DTR/RTS pulse on open. |
About --no-reset-on-connect
By default, monitor pulses RTS once on open so the chip boots into
the application under the monitor. Use --no-reset-on-connect on
boards without an auto-reset circuit, or when another tool has already
reset the chip and you don’t want a second reset to interrupt boot.
Typical flow
Remote builds plus local flash and monitor:
espctl build . --target esp32s3
espctl flash ./build/flash_bundle.tar.gz --port /dev/cu.usbmodem*
espctl monitor --port /dev/cu.usbmodem*
The build step is remote by default — no --remote flag needed. The
server URL comes from espctl login or defaults to
https://esphome.cloud. Use --remote <url> to override, or
--local for plan-only.
CLI: espctl elf
Downloads the unstripped application ELF for a previous remote build.
The MCP equivalent is elf.download — same transport,
same auth flow.
Opt-in at build time. The agent does NOT retain ELFs by default — a multi-MB write per job adds up fast. To make the ELF fetchable later, pass
--elftoespctl build(the flag setsBuildRequest.persist_elf = trueon the wire). Builds that ran without--elfhave no ELF on the agent;espctl elfwill return “no ELF retained” against them.
espctl elf --build-id <ID> [--remote <URL>] [--out <PATH>]
Flag matrix
| Flag | Default | Notes |
|---|---|---|
--build-id | required | The job_id returned by a previous espctl build --remote --elf run. |
--remote | from credentials or https://esphome.cloud | Control plane URL override. |
--out | <build_id>.elf in cwd | Output path for the ELF. Parent directories created if missing. |
Typical flow — JTAG debug via openocd-esp32
# 1. Build remotely **with --elf** so the agent keeps the ELF; note the
# build_id printed in the output.
espctl build composite-device/firmware/my_proj --target esp32s3 \
--remote https://esphome.cloud --elf
# build_id=4f3a2c...
# 2. Fetch the ELF (mirrors `espctl build --remote` auth precedence:
# MCP_AUTH_SECRET env var → ~/.config/espctl/credentials.json).
espctl elf --build-id 4f3a2c --remote https://esphome.cloud --out my_proj.elf
# 3. Drive openocd-esp32 + GDB yourself — espctl stays out of the way.
openocd -f board/esp32s3-builtin.cfg -c 'gdb_port 3333'
xtensa-esp32s3-elf-gdb -ex 'target remote :3333' my_proj.elf
The openocd-esp32 board configs (board/esp32s3-builtin.cfg,
board/esp32-wrover-kit-3.3v.cfg, etc.) work with any standard JTAG
adapter — common rigs include the chip’s built-in USB-Serial/JTAG (S3/C3
and later) or external FTDI-based debuggers like the
ESPLink V1.2,
which exposes UART and JTAG on independent USB endpoints (so JTAG keeps
working after DIS_USB_JTAG is burned).
Failure modes.
espctl elfreturns an error if: the build was made without--elf(agent skipped the ELF copy on purpose), thebuild_idis unknown to the agent, the build has been GC’d from the agent workspace, or the build was a Rust no_std build (no companion ELF — use the local ELF you already have).
See also
- Build Lifecycle — how to start a build that produces firmware.
- Logs & Artifacts — reading build output and manifest files.
- MCP Console — Flash tab — browser-based flashing.