Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Build Lifecycle

Seven tools manage a firmware build, from “what would this do?” to “go” to “stop now”.

ToolWhat it does
build (alias build.start)Start a build. Returns a task_id right away.
build.statusCheck on a task_id: pending, running, succeeded, failed, canceled.
build.cancelStop a running or queued build.
build.rust_elfBuild a flash bundle from a Rust no_std ELF (Tier-S firmware path).
set_target.runRun idf.py set-target on the build machine.
generate_build_planTell you what a build would do, without running it.
get_clean_planTell 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:

FieldTypeNotes
targetstringESP chip — esp32, esp32s3, esp32c6, etc.
profilestringdebug (default) or release.
idf_versionstring (optional)Pin a specific IDF version. Defaults to the project’s .idf-version or the build server’s default.
cleanbool (optional)If true, do a clean build instead of incremental.
paramsobject (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 esp32s3 in 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

FlagDefaultNotes
path (positional).Project directory. .espctl.toml and .idf-version are read from this path.
--targetdefault_target from .espctl.tomlChip — esp32, esp32s3, esp32c3, esp32c6, etc.
--cleanfalseClean build directory first. Local-only; ignored in remote mode.
--remote <url>from ~/.config/espctl/credentials.json, then https://esphome.cloudOverride the build server URL. Conflicts with --local.
--localfalseGenerate 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 defaultPin a specific IDF version. Written to .idf-version if the file does not exist.
--sbomfalseGenerate an SPDX SBOM at build/sbom.spdx. Remote only.
--elffalseHave 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:

  1. --local → plan-only, no compilation.
  2. --remote <url> → remote build to that URL.
  3. Otherwise: the server saved by espctl login.
  4. 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.


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:

FieldRequiredNotes
elf_pathYesAbsolute path to the Rust no_std ELF on the agent filesystem.
targetNo (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_pathNoOutput 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>]
FlagDefaultNotes
--rust-elf <ELF>Path to the Rust ELF. Mutually exclusive with --remote, --local, --git-url, --git-ref, --clean, --sbom, --idf-version.
--target <chip>esp32s3Chip — same set as the MCP tool.
--out <PATH><elf_basename>-flash-bundle.tar.gz next to the ELFOutput 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

SettingValueSource
Flash modedioMatches aegis-v3/bench/build-flash-bundle.sh.
Flash freq80mhzMatches the bench script.
Flash size16mbMatches the bench script.
Merged offset0x0espflash save-image --merge produces a single combined image (bootloader + partition + app) flashed at 0x0.
idf_version fieldn/a-rust-no_stdManifest field is required but ESP-IDF doesn’t apply to Rust no_std builds.
ref fieldgit rev-parse --short HEAD from the ELF’s parent dir, or unknownBest-effort — works when the ELF is built inside a git checkout.

Required tools

  • espflash 4.x on PATH (invoked as a subprocess for save-image --merge). Install with cargo 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" }
FieldRequiredNotes
targetYesChip — 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_id
  • idf_version_resolved
  • target
  • profile
  • command_pipeline — the ordered list of build steps
  • expected_artifacts — what files the build will produce
  • estimated_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