Advanced Agent Workflows
Once you stop thinking “one board, one firmware”, espctl unlocks two patterns that change what an ESP32 development bench can do. Both are fully agent-driven — the user describes intent in natural language and the AI assistant orchestrates the MCP tool calls. The user’s job is limited to plugging in the boards and watching the dashboard.
| Pattern | Axis | What it gives you |
|---|---|---|
| One board, many roles | Time | A single ESP32-S3 rotates through several firmwares as the day’s task changes. |
| Many boards, one operator | Space | A mini-PC or Pi drives four boards in parallel, each running a different role. |
Time: one board, many roles
A single ESP32-S3 with USB has enough flash and RAM to host any of:
- a USB logic analyzer (sigrok-compatible firmware — see PulseView & Sigrok Firmware)
- a Wi-Fi gateway with MQTT or HTTP telemetry
- a USB-to-serial debug bridge for probing another target’s UART
- whatever custom project you’re iterating on right now
espctl makes the role swap a single conversational step. The agent fetches the right project, builds it, flashes the bound port, and verifies the new firmware booted — typically 60–120 seconds end to end for a small project that is already cached on the build server.
Conversation transcript
User: Switch the board on /dev/cu.usbmodem1101 to logic-analyzer mode.
Agent: → build { target: "esp32s3",
repo_url: "https://github.com/.../sigrok-fx2lafw-esp",
git_ref: "v0.2",
build_type: "release" }
← { task_id: "8a2f...", status: "running" }
→ build.status { task_id: "8a2f..." } (poll every 2s)
← { status: "succeeded", firmware_path: ".../firmware.bin" }
→ flash.run { firmware_path: ".../firmware.bin",
port: "/dev/cu.usbmodem1101",
baud: 460800 }
← { success: true, chip: "ESP32-S3", flashed_bytes: 487424 }
→ monitor.run { port: "/dev/cu.usbmodem1101",
duration_sec: 10,
filter: "fx2lafw" }
← { lines_captured: 4, output: "I (321) fx2lafw: ready, 24 MHz max\n..." }
Agent: ✓ Logic analyzer is running on /dev/cu.usbmodem1101 at 24 MHz max
sample rate. Open PulseView and select that serial port to capture.
What the agent uses
| Step | MCP tool | Why |
|---|---|---|
| Fetch + compile the new project | build | Needs no local toolchain; build server has every IDF version cached. |
| Track progress | build.status | Returns pending → running → succeeded/failed. |
| Write the new image | flash.run | Pure-Rust espflash; no Python. |
| Confirm boot | monitor.run | Optional but cheap — 10 s capture proves the role swap took effect. |
The same ESP32-S3 can rotate through several roles in a single working
session without any rewiring. If you keep a list of project repos
(sigrok-fx2lafw-esp, your-esphome-gateway, usb-uart-bridge,
my-current-project) the agent learns the rotation after the first
prompt and you can shorthand subsequent swaps:
User: Now make it a serial bridge.
Agent: [refers to its prior context, picks the bridge project, builds, flashes]
When this is the right pattern
- Hardware-poor environments (workshops, classrooms, single-board labs).
- Field demos where you carry one board and reconfigure on demand.
- Bring-up sequences where you need to probe a behavior with a logic analyzer firmware, then switch the same board into the production role.
When it isn’t
- Anything that needs two roles simultaneously. Use the spatial pattern below.
- Production fleets — flashing is destructive to the previous role’s state. The temporal pattern is for development, not deployed workloads.
Space: many boards, one operator
A mini-PC or a Raspberry Pi 4/5 has enough USB ports to host four ESP
boards. Each board enumerates as a distinct serial port
(/dev/ttyUSB0..3 on Linux, COM3..6 on Windows, /dev/cu.usbmodem*
on macOS). Because every espctl tool that touches a board accepts an
explicit port argument, the agent treats each port as an independent
target.
A useful four-role bench:
| Slot | Role | Firmware project |
|---|---|---|
/dev/ttyUSB0 | USB logic analyzer | sigrok-compatible (see PulseView & Sigrok Firmware) |
/dev/ttyUSB1 | Target under test (latest build) | your project, branch main |
/dev/ttyUSB2 | Target under test (previous build, A/B compare) | your project, branch release-1.4 |
/dev/ttyUSB3 | Gateway + carrier-wave sampler | ESPHome Wi-Fi sink with ADC sampling |
Conversation transcript
User: Spin up the four-board bench. Latest main on USB1, last release
on USB2, gateway on USB3, logic analyzer on USB0.
Agent: → espctl ports (CLI invoked via shell)
← /dev/ttyUSB0..3 enumerated, all ESP32-S3
(parallel — two builds dispatched concurrently)
→ build { target: "esp32s3", git_ref: "main", ... }
→ build { target: "esp32s3", git_ref: "release-1.4", ... }
← { task_id: "main-9c1a...", status: "running" }
← { task_id: "rel--7b22...", status: "running" }
→ build.status { task_id: "main-9c1a..." }
→ build.status { task_id: "rel--7b22..." }
← both succeeded
(parallel — four flashes, each pinned to its port)
→ flash.run { firmware_path: <sigrok>, port: "/dev/ttyUSB0" }
→ flash.run { firmware_path: <main.bin>, port: "/dev/ttyUSB1" }
→ flash.run { firmware_path: <rel.bin>, port: "/dev/ttyUSB2" }
→ flash.run { firmware_path: <gateway>, port: "/dev/ttyUSB3" }
← all succeeded
(parallel — four monitors capturing first 30 s of boot)
→ monitor.run { port: "/dev/ttyUSB0", duration_sec: 30, filter: "fx2lafw" }
→ monitor.run { port: "/dev/ttyUSB1", duration_sec: 30, filter: "heartbeat" }
→ monitor.run { port: "/dev/ttyUSB2", duration_sec: 30, filter: "heartbeat" }
→ monitor.run { port: "/dev/ttyUSB3", duration_sec: 30, filter: "wifi" }
Agent: ✓ Bench is up.
USB0: logic analyzer, 24 MHz ready
USB1: main @ a4f2c1e, 1 Hz heartbeat
USB2: release-1.4 @ 9d20b5a, 1 Hz heartbeat
USB3: gateway online, joined Wi-Fi as "lab-bench-gw"
PulseView is configured to read USB0; Wireshark over USB3 will
show MQTT publishes from USB1 and USB2.
What the agent uses
| Step | MCP tool / CLI | Why |
|---|---|---|
| Enumerate boards | espctl ports | Confirms which ports actually have an ESP attached and their VID:PID. |
| Compile two firmwares in parallel | two concurrent build calls | Each returns its own task_id; the agent polls both. Build server runs them on independent sandboxes. |
| Flash four boards in parallel | four flash.run calls, each with port: pinned | Local USB serial is per-port; no contention as long as ports differ. |
| Verify each role | four monitor.run calls, each filter:-scoped | Same parallelism rule — different ports, no contention. |
Operator’s job
Watching, not driving:
- Plug in the four boards.
- Tell the agent the role for each port.
- Watch the agent’s stream-summary as it works.
- Open the application surfaces — PulseView, Wireshark/MQTT explorer, ADC visualizer — that consume what the bench produces.
The user never types espctl build, flash, or monitor themselves.
When this is the right pattern
- A/B firmware regression testing (USB1 vs USB2).
- Active capture during bring-up (analyzer + DUT in the same session).
- Lab benches where one operator coordinates several test rigs.
- Continuous-integration mules — a Pi running this pattern as a scheduled task can validate every PR against multiple boards unattended.
When it isn’t
- Production manufacturing flashing — for that, scale espctl across hosts and use a queue, not one Pi orchestrating four ports.
- Anything requiring sub-millisecond synchronization between boards. USB serial is not real-time.
Why this works in espctl
The two patterns rely on three properties of the espctl MCP surface:
- Stateless tools.
flash.runandmonitor.runaccept theportas an explicit argument; nothing in the agent’s history binds a tool call to a particular board. Calling the same tool twice with two different ports is a clean parallel operation. - Independent build tasks.
buildreturns atask_idimmediately and runs in the background. Twobuildcalls produce twotask_ids and the agent polls eachbuild.statusindependently. - Local execution for hardware.
flash.runandmonitor.runrun on the user’s machine (or the operator’s mini-PC/Pi), talking directly to USB. Build is remote; hardware is local. The agent moves between the two transparently.
See also
- Typical 8-Step Workflow — single-board, single-build flow. Read this first if you haven’t.
- PulseView & Sigrok Firmware — the project used for the logic-analyzer role.
- Firmware & Flash — every tool referenced in the transcripts above.
- Build Lifecycle — how
buildandbuild.statuswork in detail. - System Overview — why building runs remote and flashing runs local.