Firmware OTA Updates skill for Codex
Updates that cannot brick the device. Codex designs OTA so a bad image, power cut or dropped download always falls back to working firmware: two slots, signatures checked by the bootloader, confirmation only after real health checks, and anti-rollback counters for security fixes. A bundled script builds and verifies release manifests, and rollouts move in stages that halt on rollback spikes.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- firmware-ota Installs to ~/.agents/skills/firmware-ota/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install firmware-ota The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project firmware-ota Installs to .agents/skills/firmware-ota/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/firmware-ota.zipunzip -o firmware-ota.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) firmware-ota PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $firmware-ota, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs firmware over-the-air update systems for microcontrollers and embedded Linux - A/B slots and swap strategies, MCUboot and ESP-IDF rollback, image signing with imgtool or espsecure, anti-rollback counters, signed release manifests, resumable downloads, power-loss safety, health checks before confirming an image, and staged fleet rollouts. Use when adding OTA to a device, a bad update bricked units, choosing between MCUboot, ESP-IDF OTA, Mender, RAUC or SWUpdate, setting up signing keys, or planning a rollout with automatic halt.
Use it when
- Adding over-the-air updates to a microcontroller or embedded Linux device
- Choosing slot layout, bootloader and swap strategy
- Setting up firmware signing keys and anti-rollback
- Planning a staged rollout with automatic halt
Use something else when
- You only need ESP-IDF's HTTPS OTA call wired into one project: use esp32-firmware
- The question is general secrets handling for CI rather than firmware: use secrets-management
How to check the result
Cut power at every update stage and confirm the device always boots, then show that unsigned, downgraded and crash-before-confirm images are refused or reverted.
The mistake it prevents
Confirming the new image at the top of main(), which makes a build that cannot reach the update server permanent.
Done well means: no single bad image, power cut or network drop can leave a device without a bootable firmware; only images signed by keys you control are accepted; a device never downgrades below a security fix; and a release reaches the fleet in stages that halt on their own when health metrics drop.
Inspect first
- Read
AGENTS.md, the bootloader config (MCUbootsysbuild.conf/mcuboot.conf, ESP-IDFsdkconfig.defaults, U-Boot environment), the partition or slot layout and the current update code. - Answer these before designing anything; they decide the architecture:
- Flash size versus image size: is there room for two full slots?
- Transport: HTTPS, MQTT, BLE, cellular with metered data, USB?
- Who holds the signing key today, and where? (A key in the repo is already compromised.)
- Is there any secure boot or eFuse state already set on shipped units?
Choosing the mechanism
| Platform | Default | Notes |
|---|---|---|
| Zephyr / nRF Connect SDK | MCUboot via sysbuild, swap-using-move | Upload with MCUmgr (SMP) over UART, BLE or UDP |
| ESP32 (ESP-IDF) | Two OTA app partitions plus otadata, app rollback enabled | Secure Boot v2 for signature checks in the bootloader |
| STM32 / other Cortex-M bare metal | MCUboot ported, or vendor SBSFU | Do not write a custom bootloader unless forced |
| Embedded Linux | RAUC, SWUpdate or Mender with A/B root filesystems | U-Boot or GRUB boot counter decides fallback |
| Flash too small for two slots | MCUboot overwrite-only or a recovery/loader partition | Power loss mid-update must fall back to the loader, not brick |
Slot strategies (MCUboot terms)
| Mode | Flash cost | Rollback | Use when |
|---|---|---|---|
| Swap using move / offset | 2 slots, no scratch | Yes | Default for internal flash |
| Swap using scratch | 2 slots + scratch area | Yes | Slots with unequal sector sizes |
| Overwrite only | 2 slots | No | Very tight flash; accept no rollback |
| Direct XIP | 2 slots, each linked for its address | Yes, by version choice | External flash or no time for swapping |
Update lifecycle (device side)
- [ ] 1. Fetch manifest; verify its signature; check hardware, version > current,
min_version <= current, security counter >= device counter
- [ ] 2. Download to the inactive slot in chunks; resume with HTTP Range after drops
- [ ] 3. Verify the image digest and signature before marking it pending
- [ ] 4. Mark pending ("test" in MCUboot), reboot
- [ ] 5. Bootloader swaps or selects; boots the new image unconfirmed
- [ ] 6. App runs health checks (peripherals init, network up, server reachable, no crash
for N minutes); only then confirm
- [ ] 7. If it never confirms (crash, watchdog, power loss), next boot reverts
- [ ] 8. Report the result with version, slot and reason to the backend
Confirming at the top of main() defeats rollback: an image that crashes later, or cannot
reach the update server, is now permanent. Confirm after the checks that prove the device
can receive the next update.
/* Zephyr + MCUboot */
#include <zephyr/dfu/mcuboot.h>
if (!boot_is_img_confirmed() && self_test_passed()) {
boot_write_img_confirmed();
}
/* ESP-IDF */
esp_ota_img_states_t st;
const esp_partition_t *run = esp_ota_get_running_partition();
if (esp_ota_get_state_partition(run, &st) == ESP_OK && st == ESP_OTA_IMG_PENDING_VERIFY) {
if (self_test_passed()) esp_ota_mark_app_valid_cancel_rollback();
else esp_ota_mark_app_invalid_rollback_and_reboot();
}
Signing
# MCUboot: generate once, offline, then move the private key to an HSM/KMS or vault
imgtool keygen -k signing-ec-p256.pem -t ecdsa-p256
imgtool getpub -k signing-ec-p256.pem > boot_pub_key.c # compiled into MCUboot
imgtool sign --key signing-ec-p256.pem --header-size 0x200 --pad-header --align 4 \
--version 1.4.0+0 --security-counter 3 --slot-size 0x67000 app.bin app.signed.bin
imgtool verify --key signing-ec-p256.pem app.signed.bin
- With Zephyr sysbuild, set
SB_CONFIG_BOOT_SIGNATURE_KEY_FILEinsysbuild.confand the build signs automatically. The default key shipped in the MCUboot tree is public; a device built with it accepts anyone’s firmware. - ESP-IDF Secure Boot v2 signs with
espsecure.py sign_data --version 2(hyphenatedsign-datain esptool v5). Enabling Secure Boot and flash encryption burns eFuses and is irreversible; get explicit user confirmation and test on sacrificial units first. - Keep private keys out of the repository and CI logs. Sign in a dedicated CI job with the
key in a KMS or HSM, or with a protected secret limited to release branches
(
secrets-management). - Plan key rotation now: MCUboot can hold several public keys; ship the next key’s public half before you need it.
Release manifest
Publish a small signed manifest next to each image so the device can decide before downloading:
python3 scripts/ota_manifest.py create build/app.signed.bin --version 1.4.0 --hw rev-b \
--min-version 1.2.0 --security-counter 3 --rollout 5 \
--url https://updates.example.com/rev-b/1.4.0.bin -o manifest.json
python3 scripts/ota_manifest.py verify build/app.signed.bin manifest.json \
--current 1.3.2 --current-counter 3 --hw rev-b
scripts/ota_manifest.py (standard library only) writes version, hardware, size, SHA-256,
minimum source version, security counter and rollout percentage, and verify runs the same
checks the device should run: digest, size, hardware match, no downgrade, min-version gate
and anti-rollback counter. It exits 1 on any failure. It does not sign; sign the manifest
with the release key so the device can trust its fields.
Anti-rollback
- Version checks stop accidental downgrades; a security counter stops deliberate ones. Raise the counter only on releases that fix vulnerabilities, because devices can never go back below it.
- Hardware-backed counters (eFuses on ESP32, OTP or a TPM on Linux) are one-way. Every increment is permanent; confirm with the user before releasing one.
Fleet rollout
| Stage | Share | Gate to continue |
|---|---|---|
| Internal / test bench | Lab units | All units confirm; soak 24 h |
| Canary | 1-5 % | Crash rate, rollback rate and check-in rate within baseline for 24-72 h |
| Wider | 25 % | Same gates, plus support tickets |
| Full | 100 % | Keep the previous image available for re-flash |
- Automate the halt: if rollbacks or missing check-ins exceed a threshold, pause the rollout without waiting for a human.
- Randomise device selection and download times to avoid stampeding the server.
- Ask for explicit confirmation before starting or widening a rollout to production devices.
Gotchas
- Downloading into the running slot or erasing the only good image before the new one is verified: one power cut bricks the unit.
- Battery devices: refuse to start an update below a charge threshold.
- Changing the partition layout or bootloader cannot be done by a normal app OTA; plan it as a separate, much riskier procedure, or never.
- A new image that changes stored data formats must migrate forward and still let the old image boot after a rollback (keep both readers for one release).
- Delta updates save bandwidth but depend on an exact base image; always keep a full-image fallback.
Verification
- Pull power at every stage (download, verify, swap, first boot): device always boots
- Image signed with an unknown key is rejected by the bootloader
- Image that crashes before confirming reverts on the next boot
- Downgrade and lower-security-counter images are refused
- Interrupted download resumes rather than restarting from zero
- Canary rollout halts automatically when a test metric breaches its threshold
Report
**Device:** nRF52840, Zephyr 4.1 + MCUboot swap-using-move, 2 x 412 KB slots
**Signing:** ECDSA P-256, key in cloud KMS, signing in release CI job only
**Confirm policy:** after network up + server check-in + 10 min without reset
**Tests:** power-cut matrix 12/12 boot; unsigned image rejected; crash-before-confirm reverted
**Rollout:** 5 % canary with auto-halt at > 1 % rollback rate What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Firmware OTA Updates"
short_description: "Updates that cannot brick the device"
default_prompt: "$firmware-ota Add OTA updates with rollback to our Zephyr device using MCUboot" scripts/ota_manifest.py
#!/usr/bin/env python3
"""Create or verify an OTA release manifest for a firmware image.
create: hash the image and write a JSON manifest (version, size, sha256, hardware,
minimum version allowed to install it, security counter, rollout percentage).
verify: re-hash the image, check size and digest, and refuse a downgrade relative to
the version currently on the device (or a security counter below the device's).
This script does not sign anything. Sign the image with your bootloader's tool
(imgtool, espsecure, a KMS) and sign the manifest separately; then the device checks
both signatures before trusting any field here.
Usage:
python3 ota_manifest.py create build/app.bin --version 1.4.0 --hw rev-b \
[--min-version 1.2.0] [--security-counter 3] [--rollout 5] [--url https://...] \
[-o manifest.json]
python3 ota_manifest.py verify build/app.bin manifest.json --current 1.3.2 \
[--current-counter 3] [--hw rev-b]
Exit codes: 0 ok, 1 verification failed, 2 bad input.
"""
import argparse
import hashlib
import json
import re
import sys
from datetime import datetime, timezone
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$")
def parse_version(text):
m = SEMVER.match(text)
if not m:
raise ValueError(f"not a semantic version: {text!r}")
major, minor, patch, pre = m.groups()
# A release sorts after any pre-release of the same version.
pre_key = (1,) if pre is None else (0,) + tuple(
(0, int(p), "") if p.isdigit() else (1, 0, p) for p in pre.split(".")
)
return (int(major), int(minor), int(patch), pre_key)
def digest(path):
h = hashlib.sha256()
size = 0
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 16), b""):
h.update(chunk)
size += len(chunk)
return size, h.hexdigest()
def cmd_create(a):
parse_version(a.version)
if a.min_version:
if parse_version(a.min_version) > parse_version(a.version):
raise ValueError("--min-version is newer than --version")
if not 0 < a.rollout <= 100:
raise ValueError("--rollout must be in 1..100")
size, sha = digest(a.image)
if size == 0:
raise ValueError("image is empty")
manifest = {
"schema": 1,
"version": a.version,
"hardware": a.hw,
"size": size,
"sha256": sha,
"min_version": a.min_version,
"security_counter": a.security_counter,
"rollout_percent": a.rollout,
"url": a.url,
"created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
text = json.dumps(manifest, indent=2) + "\n"
if a.output:
with open(a.output, "w", encoding="utf-8") as fh:
fh.write(text)
print(f"wrote {a.output}: {a.version} {size} B sha256={sha[:16]}...")
else:
sys.stdout.write(text)
return 0
def cmd_verify(a):
with open(a.manifest, encoding="utf-8") as fh:
m = json.load(fh)
failures = []
size, sha = digest(a.image)
if size != m.get("size"):
failures.append(f"size {size} != manifest {m.get('size')}")
if sha != m.get("sha256"):
failures.append("sha256 mismatch")
if a.hw and m.get("hardware") != a.hw:
failures.append(f"hardware {m.get('hardware')!r} does not match device {a.hw!r}")
new = parse_version(m["version"])
if a.current:
cur = parse_version(a.current)
if new <= cur:
failures.append(f"version {m['version']} is not newer than current {a.current} (downgrade or reinstall)")
if m.get("min_version") and cur < parse_version(m["min_version"]):
failures.append(f"device {a.current} is below min_version {m['min_version']}: needs an intermediate release")
if a.current_counter is not None and m.get("security_counter", 0) < a.current_counter:
failures.append(f"security_counter {m.get('security_counter')} below device counter {a.current_counter}")
for f in failures:
print(f"FAIL {f}")
if failures:
return 1
print(f"OK {m['version']} for {m.get('hardware')}: {size} B, sha256 matches")
return 0
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("create")
c.add_argument("image")
c.add_argument("--version", required=True)
c.add_argument("--hw", required=True, help="hardware revision this image targets")
c.add_argument("--min-version", help="oldest installed version allowed to jump to this one")
c.add_argument("--security-counter", type=int, default=0)
c.add_argument("--rollout", type=int, default=100, help="percent of fleet, 1..100")
c.add_argument("--url")
c.add_argument("-o", "--output")
v = sub.add_parser("verify")
v.add_argument("image")
v.add_argument("manifest")
v.add_argument("--current", help="version installed on the device")
v.add_argument("--current-counter", type=int, help="device anti-rollback counter")
v.add_argument("--hw")
a = ap.parse_args()
try:
return cmd_create(a) if a.cmd == "create" else cmd_verify(a)
except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.