Build Lifecycle
Seven tools manage a firmware build, from “what would this do?” to “go” to “stop now”.
| Tool | What it does |
|---|---|
build (alias build.start) | Start a build. Returns a task_id right away. |
build.status | Check on a task_id: pending, running, succeeded, failed, canceled. |
build.cancel | Stop a running or queued build. |
build.rust_elf | Build a flash bundle from a Rust no_std ELF (Tier-S firmware path). |
set_target.run | Run idf.py set-target on the build machine. |
generate_build_plan | Tell you what a build would do, without running it. |
get_clean_plan | Tell you what idf.py clean or fullclean would delete. |
build / build.start
Starts a firmware build on the build server and returns right away. The
build itself runs in the background inside a sandbox; you follow along
with build.status or by reading build://log/{task_id}.
Typical inputs:
| Field | Type | Notes |
|---|---|---|
target | string | ESP chip — esp32, esp32s3, esp32c6, etc. |
profile | string | debug (default) or release. |
idf_version | string (optional) | Pin a specific IDF version. Defaults to the project’s .idf-version or the build server’s default. |
clean | bool (optional) | If true, do a clean build instead of incremental. |
params | object (optional) | Recipe-specific overrides. |
Returns:
{
"task_id": "0abf...e2",
"status": "pending"
}
The task_id is what you’ll use to follow the build. Save it.
Example dialogue:
Build the firmware for
esp32s3in release mode.
Your assistant calls build with
{"target": "esp32s3", "profile": "release"}, then watches
build.status until it finishes.
Plan-only mode: In the CLI, build goes remote by default. Pass
--local to get a build plan without compilation. In the MCP server,
if CONTROL_BASE_URL or MCP_AUTH_SECRET is missing, build returns
a plan with "status": "planning". Use generate_build_plan to
explicitly get a plan without side effects in either mode.
CLI: espctl build
When you’d rather drive a build by hand instead of through MCP. Same build server, same sandbox — just a CLI in front instead of your AI assistant.
espctl build [path] [--target <chip>] [--clean] \
[--remote <url> | --local] \
[--git-url <url> [--git-ref <ref>]] \
[--idf-version <ver>] [--sbom] [--elf]
Remote build is the default. See Plan-only vs Remote Build for the long form.
Flag matrix
| Flag | Default | Notes |
|---|---|---|
path (positional) | . | Project directory. .espctl.toml and .idf-version are read from this path. |
--target | default_target from .espctl.toml | Chip — esp32, esp32s3, esp32c3, esp32c6, etc. |
--clean | false | Clean build directory first. Local-only; ignored in remote mode. |
--remote <url> | from ~/.config/espctl/credentials.json, then https://esphome.cloud | Override the build server URL. Conflicts with --local. |
--local | false | Generate a build plan without compiling. Conflicts with --remote. |
--git-url <url> | — | Have the agent clone this repo instead of receiving a project bundle. Remote mode only. |
--git-ref <ref> | (default branch) | Branch, tag, or commit to check out. Used with --git-url. |
--idf-version <ver> | .idf-version → [idf_version] in .espctl.toml → server default | Pin a specific IDF version. Written to .idf-version if the file does not exist. |
--sbom | false | Generate an SPDX SBOM at build/sbom.spdx. Remote only. |
--elf | false | Have the agent retain the unstripped application ELF in its workspace so a later espctl elf can pull it back for JTAG debugging. Remote only (C-project flow); off by default — most builds don’t need ELF retention, and skipping the multi-MB copy keeps the agent workspace lean. |
Mode resolution
The CLI picks a mode in this order:
--local→ plan-only, no compilation.--remote <url>→ remote build to that URL.- Otherwise: the server saved by
espctl login. - Otherwise:
https://esphome.cloud(the built-in default).
Common invocations
# Default: remote build using saved credentials
espctl build . --target esp32s3
# Remote build with SPDX SBOM
espctl build . --target esp32s3 --sbom
# One-shot server override (no login persisted)
espctl build . --target esp32 --remote https://staging.example.com
# Build directly from a git ref (agent clones — no project upload)
espctl build --remote https://esphome.cloud \
--git-url https://github.com/espressif/esp-idf \
--git-ref v5.3.1 --target esp32c3
# Pin an IDF version explicitly
espctl build --target esp32s3 --idf-version v5.3.1
# Remote build + retain the ELF on the agent (so `espctl elf` can fetch
# it later for JTAG debugging)
espctl build . --target esp32s3 --elf
# Plan-only (offline / pre-flight)
espctl build --local --target esp32s3
# Local clean rebuild
espctl build --local --target esp32s3 --clean
Output and exit codes
Human mode prints staged progress (clone, configure, compile, link) and
a manifest summary. --json emits a stream of PipelineEvent JSON
objects, one per line, ending with the manifest.
On success: exit 0 and build/flash_bundle.tar.gz in the project
directory. With --sbom, also writes build/sbom.spdx.
On compile or runtime failure: exit 1.
On config or invalid-target error: exit 2.
Related MCP tools
build/build.start— same build, started programmatically.generate_build_plan— what--localends up doing internally.sbom.create— SBOM-only over an existingtask_id, useful when adding an SBOM after the fact.
Rust no_std bundle — build.rust_elf / espctl build --rust-elf
ESP-IDF C projects funnel through the build tool above and emit a
flash bundle automatically. Rust no_std builds (e.g.,
aegis-v3/firmware/tier-s-bench-m7m8/) only produce an ELF — the
bootloader + partition table + app must be merged into a single image
before the result is flash-ready. This pair of interfaces wraps
espflash save-image --merge and packages the result in the same
flash-bundle format the flash.run tool and
espctl flash CLI consume.
MCP: build.rust_elf
Input:
| Field | Required | Notes |
|---|---|---|
elf_path | Yes | Absolute path to the Rust no_std ELF on the agent filesystem. |
target | No (default esp32s3) | Chip — one of esp32, esp32s2, esp32s3, esp32c2, esp32c3, esp32c6, esp32h2 (build.rust_elf does not accept esp32p4 / esp32c5 / esp32c61 because the Rust no_std Xtensa/RISC-V toolchain doesn’t cover them yet; the C ESP-IDF path via build supports the full 10-chip list). |
out_path | No | Output bundle path. Default: <elf_basename>-flash-bundle.tar.gz next to the ELF. |
Returns:
{
"bundle_path": "/.../handshake-full-flash-bundle.tar.gz",
"bundle_size_bytes": 119675,
"manifest": {
"schema_version": 1,
"build": {
"job_id": "20260505T025256Z-handshake-full",
"project": "handshake-full",
"ref": "unknown",
"idf_version": "n/a-rust-no_std",
"target": "esp32s3",
"created_at": "2026-05-05T02:52:56Z"
},
"flash": {
"segments": [
{ "offset": "0x0", "file": "files/firmware.bin", "sha256": "e1d36f..." }
],
"flash_mode": "dio",
"flash_freq": "80mhz",
"flash_size": "16mb"
}
}
}
The bundle is suitable for flash.run or espctl flash without
further conversion.
CLI: espctl build --rust-elf
espctl build --rust-elf <ELF> [--target <chip>] [--out <bundle>]
| Flag | Default | Notes |
|---|---|---|
--rust-elf <ELF> | — | Path to the Rust ELF. Mutually exclusive with --remote, --local, --git-url, --git-ref, --clean, --sbom, --idf-version. |
--target <chip> | esp32s3 | Chip — same set as the MCP tool. |
--out <PATH> | <elf_basename>-flash-bundle.tar.gz next to the ELF | Output bundle path. Requires --rust-elf. |
Typical flow (Tier-S firmware):
cargo +esp build --bin handshake-full \
--target xtensa-esp32s3-none-elf --release
espctl build --rust-elf .../release/handshake-full --target esp32s3
espctl flash .../release/handshake-full-flash-bundle.tar.gz \
--port /dev/ttyACM0
espctl monitor --port /dev/ttyACM0
Defaults & assumptions
| Setting | Value | Source |
|---|---|---|
| Flash mode | dio | Matches aegis-v3/bench/build-flash-bundle.sh. |
| Flash freq | 80mhz | Matches the bench script. |
| Flash size | 16mb | Matches the bench script. |
| Merged offset | 0x0 | espflash save-image --merge produces a single combined image (bootloader + partition + app) flashed at 0x0. |
idf_version field | n/a-rust-no_std | Manifest field is required but ESP-IDF doesn’t apply to Rust no_std builds. |
ref field | git rev-parse --short HEAD from the ELF’s parent dir, or unknown | Best-effort — works when the ELF is built inside a git checkout. |
Required tools
espflash4.x on PATH (invoked as a subprocess forsave-image --merge). Install withcargo install espflash --locked.
Replaces
aegis-v3/bench/build-flash-bundle.sh — the canonical implementation
now lives in espctl; the bash script is a thin wrapper preserved for
backward compatibility with bench-tree default paths.
build.status
Checks the state of a previously-started build.
Input:
{ "task_id": "0abf...e2" }
Returns:
{
"task_id": "0abf...e2",
"status": "running",
"progress": 0.42,
"started_at": 1712340000,
"updated_at": 1712340060,
"phase": "compiling"
}
status is one of pending, running, succeeded, failed, or
canceled. Some assistants also show progress (0.0–1.0) and a
free-form phase (e.g. cmake-configure, compiling, linking,
flashing).
Common pattern: Most assistants check every 1–3 seconds with a
timeout. Don’t hammer the server — there’s a build://log/{task_id}
resource that pushes new lines as they happen, which is more efficient
than asking over and over.
build.cancel
Stops a pending or running build. Doesn’t error if the build has
already finished — it’s a no-op in that case.
Input:
{ "task_id": "0abf...e2" }
Returns:
{ "task_id": "0abf...e2", "status": "canceled" }
The cancel is best-effort — the server asks the build to stop, then forces it after a short wait. Compile steps already in progress may take a few seconds to wind down.
set_target.run
Runs idf.py set-target on the build machine for a project. Creates
a pending task that the build agent picks up and executes.
Unlike set_target (which updates local config only), this tool
actually runs the set-target command on the remote build machine.
Input:
{ "target": "esp32c3" }
| Field | Required | Notes |
|---|---|---|
target | Yes | Chip — esp32, esp32s3, esp32c3, esp32c6, etc. |
Returns:
{
"task_id": "d1e2...f3",
"target": "esp32c3",
"recipe_id": "idf_set_target"
}
generate_build_plan
Tells you what a build would do, without running it. Useful for:
- Reviewing what’s about to happen before you click “go”.
- Plan-only mode (no build server set).
- Capturing a reproducible build description for CI or audit.
Input: Same as build (target, profile, etc.).
Returns: A structured plan. Exact fields depend on the recipe, but typically include:
recipe_ididf_version_resolvedtargetprofilecommand_pipeline— the ordered list of build stepsexpected_artifacts— what files the build will produceestimated_duration_secs— best-effort guess from past runs
No side effects. Safe to call as many times as you want.
get_clean_plan
Tells you what idf.py clean (incremental clean) or idf.py fullclean
(full wipe) would delete from the build directory, without actually
deleting anything.
Input:
{ "scope": "clean" } // or "fullclean"
Returns: A list of files and directories that would be removed, plus totals.
{
"scope": "clean",
"would_delete": [
"build/esp-idf/main/...",
"build/esp-idf/CMakeFiles/...",
"build/.../*.o"
],
"total_files": 1342,
"total_bytes": 187654321
}
Useful before doing a destructive cleanup, especially in CI.
See also
- Logs & Artifacts — once a build finishes, read its output files.
- Typical Workflow — end-to-end script that uses most of these tools.
- Troubleshooting — when builds fail.