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.

  • Embedded & IoT
  • $firmware-ota
  • 173 lines
  • 3 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- firmware-ota

Installs to ~/.agents/skills/firmware-ota/ for every project on this machine.

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.

~/.agents/skills/firmware-ota/SKILL.md

--- name: firmware-ota description: Designs firmware over-the-air update systems for microcontrollers and embedded Linux - A/B slots and swap strategies, MCUboot and ESP-IDF rollback,… license: MIT ---

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

  1. Read AGENTS.md, the bootloader config (MCUboot sysbuild.conf/mcuboot.conf, ESP-IDF sdkconfig.defaults, U-Boot environment), the partition or slot layout and the current update code.
  2. 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

PlatformDefaultNotes
Zephyr / nRF Connect SDKMCUboot via sysbuild, swap-using-moveUpload with MCUmgr (SMP) over UART, BLE or UDP
ESP32 (ESP-IDF)Two OTA app partitions plus otadata, app rollback enabledSecure Boot v2 for signature checks in the bootloader
STM32 / other Cortex-M bare metalMCUboot ported, or vendor SBSFUDo not write a custom bootloader unless forced
Embedded LinuxRAUC, SWUpdate or Mender with A/B root filesystemsU-Boot or GRUB boot counter decides fallback
Flash too small for two slotsMCUboot overwrite-only or a recovery/loader partitionPower loss mid-update must fall back to the loader, not brick

Slot strategies (MCUboot terms)

ModeFlash costRollbackUse when
Swap using move / offset2 slots, no scratchYesDefault for internal flash
Swap using scratch2 slots + scratch areaYesSlots with unequal sector sizes
Overwrite only2 slotsNoVery tight flash; accept no rollback
Direct XIP2 slots, each linked for its addressYes, by version choiceExternal 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_FILE in sysbuild.conf and 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 (hyphenated sign-data in 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

StageShareGate to continue
Internal / test benchLab unitsAll units confirm; soak 24 h
Canary1-5 %Crash rate, rollback rate and check-in rate within baseline for 24-72 h
Wider25 %Same gates, plus support tickets
Full100 %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.yamlgenerated from this page

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.py141 lines, 5.3 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem