MicroPython and CircuitPython skill for Codex

Python on a Pico that never needs a reflash. Codex tells MicroPython and CircuitPython apart before writing a line, deploys with mpremote or circup, and structures code as asyncio tasks with a watchdog and a safe-start button so a bad main.py never bricks the REPL. Memory is watched with gc.mem_free() during soak tests, and ISRs defer work instead of allocating.

  • Embedded & IoT
  • $micropython
  • 199 lines
  • 2 files
  • ~1,750 tokens
  • MIT
  • Updated

Install

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

Installs to ~/.agents/skills/micropython/ 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 $micropython, 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

Writes and deploys MicroPython and CircuitPython code on microcontrollers (Raspberry Pi Pico/Pico W, ESP32, RP2350, nRF, SAMD) - mpremote and circup workflows, boot.py/main.py layout, asyncio tasks, ISR-safe callbacks with micropython.schedule, memory and gc tuning, watchdog and deep sleep. Use when a Pico or ESP32 runs out of memory, a script stops after hours, files need copying to the board, Wi-Fi drops, or a CircuitPython code.py must be structured beyond a single loop.

Use it when

  • Writing or debugging MicroPython on a Pico, Pico W or ESP32
  • A CircuitPython code.py has outgrown a single while loop
  • The board hits MemoryError or stops after running for hours
  • You need repeatable deploys instead of dragging files in an IDE

Use something else when

  • The firmware is written in C with ESP-IDF: use esp32-firmware
  • The board is a Linux Raspberry Pi rather than a microcontroller: use raspberry-pi

How to check the result

Run a soak test of an hour or more logging gc.mem_free(), toggle Wi-Fi mid-run, and confirm the safe-start button drops the board to the REPL.

The mistake it prevents

An infinite loop in main.py with no escape hatch locks out the REPL and forces a firmware erase.

~/.agents/skills/micropython/SKILL.md

--- name: micropython description: Writes and deploys MicroPython and CircuitPython code on microcontrollers (Raspberry Pi Pico/Pico W, ESP32, RP2350, nRF, SAMD) - mpremote and circup… license: MIT ---

Done well means: the code is deployed with a repeatable command, main.py cannot lock the user out of the REPL, the board recovers on its own from Wi-Fi loss and exceptions, and memory stays flat over a long run.

Identify the runtime first

ClueRuntimeDeploy with
main.py, boot.py, REPL banner “MicroPython v1.2x”MicroPythonmpremote
code.py, a CIRCUITPY USB drive, lib/adafruit_*.mpyCircuitPythonCopy to the drive; circup for libraries

The two share syntax but not APIs: MicroPython uses machine.Pin, CircuitPython uses board plus digitalio/busio. Never mix examples. Read AGENTS.md and the repo layout, then ask the user for the board and firmware version (import sys; sys.implementation).

MicroPython workflow with mpremote

pip install mpremote                       # network
mpremote devs                              # list serial devices
mpremote connect /dev/ttyACM0 fs ls
mpremote cp -r lib/ :                      # copy the lib folder to the board root
mpremote cp main.py :main.py
mpremote run scratch.py                    # run without saving to the board
mpremote mount .                           # board sees the local folder; fast iteration
mpremote mip install aioble                # install from micropython-lib (board needs no network; host does)
mpremote reset
mpremote repl                              # Ctrl-] or Ctrl-x to exit

Codex rarely has the serial port in its sandbox. Prepare files and the exact commands; let the user run them and paste the REPL output.

Flashing firmware itself: .uf2 drag-and-drop for RP2040/RP2350 (hold BOOTSEL); for ESP32 use esptool (esptool.py --chip esp32 erase_flash, then write_flash 0x1000 firmware.bin; the offset is 0x0 for S3 and C3). Erasing wipes the file system; confirm with the user that nothing on the board needs saving first.

Project layout

boot.py      # minimal: nothing that can hang
main.py      # tiny entry point: import app; app.run()
app.py       # real code, importable and testable
lib/         # drivers, ideally precompiled .mpy
config.py    # pins and constants; secrets.py kept out of git

Give main.py an escape hatch so a crashing loop never needs a reflash:

# main.py
import time
from machine import Pin

if Pin(0, Pin.IN, Pin.PULL_UP).value() == 0:   # hold button at boot to skip
    print("safe start: app not launched")
else:
    time.sleep(2)                              # window to Ctrl-C from the REPL
    import app
    app.run()

Structure with asyncio

import asyncio
from machine import Pin, WDT

led = Pin("LED", Pin.OUT)          # Pico W naming; ESP32 uses a number

async def blink():
    while True:
        led.toggle()
        await asyncio.sleep_ms(500)

async def sample(q):
    while True:
        q.append(read_sensor())
        await asyncio.sleep(10)

async def main():
    wdt = WDT(timeout=8000)            # cannot be stopped once started; feed it
    q = []
    asyncio.create_task(blink())
    asyncio.create_task(sample(q))
    while True:
        wdt.feed()
        await asyncio.sleep(1)

def run():
    try:
        asyncio.run(main())
    finally:
        asyncio.new_event_loop()       # clean state if run() is called again from the REPL

On firmware older than 1.21 the module is uasyncio. Any blocking call (time.sleep, synchronous socket I/O) inside a task freezes every task.

Interrupts

import micropython
micropython.alloc_emergency_exception_buf(100)   # tracebacks from ISRs

count = 0
def _bump(_):
    global count
    count += 1

def on_edge(pin):                                # hard ISR on some ports: no allocation
    micropython.schedule(_bump, None)

Pin(15, Pin.IN, Pin.PULL_UP).irq(trigger=Pin.IRQ_FALLING, handler=on_edge)

No allocation (no lists, floats, string formatting) inside a hard ISR. Defer real work with micropython.schedule or set a flag that a task checks.

Memory

SymptomFix
MemoryError on importPrecompile modules with mpy-cross (version must match the firmware’s .mpy format) or freeze them into firmware
Free memory shrinks over hoursAllocations in the loop: preallocate bytearray buffers, use readinto, avoid string concatenation
Fragmentation, large allocations fail though mem_free is highAllocate big buffers once at start; call gc.collect() before them
Slow numeric loop@micropython.native or @micropython.viper on that function only
import gc
gc.collect()
print("free", gc.mem_free(), "alloc", gc.mem_alloc())

Log gc.mem_free() every minute during soak tests; a downward trend is a leak.

Wi-Fi (Pico W, ESP32)

import network, time

def connect(ssid, pw, timeout=20):
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        wlan.connect(ssid, pw)
        t0 = time.ticks_ms()
        while not wlan.isconnected():
            if time.ticks_diff(time.ticks_ms(), t0) > timeout * 1000:
                raise OSError("wifi timeout")
            time.sleep_ms(200)
    return wlan.ifconfig()[0]

Re-check isconnected() before every send and reconnect with backoff. Use time.ticks_diff, never raw subtraction, because tick counters wrap.

CircuitPython specifics

  • Libraries: circup install adafruit_bme280 then circup update (needs network on host).
  • Wi-Fi credentials go in settings.toml as CIRCUITPY_WIFI_SSID and CIRCUITPY_WIFI_PASSWORD; read other keys with os.getenv.
  • The drive auto-reloads on save; writing files from code needs boot.py to remount (storage.remount("/", readonly=False)), which makes the drive read-only to the host.
  • Use the asyncio library from the Adafruit bundle (circup install asyncio) or a time.monotonic() scheduler instead of time.sleep loops.

Deep sleep

machine.deepsleep(60_000) resets the board on wake; state lives only in RTC memory (machine.RTC().memory() on ESP32) or flash. Check machine.reset_cause() at boot. Deep sleep disconnects USB, so the REPL disappears; keep a safe-start path.

Verification

  • Files deployed with one documented mpremote (or copy) command
  • Safe-start path tested: board boots to REPL with the button held
  • Soak test of at least an hour with gc.mem_free() logged: no downward trend
  • Wi-Fi off and on during the run: device reconnects without reset
  • Unhandled exception path leads to a watchdog reset, not a silent hang

Report

**Board:** Pico W, MicroPython v1.24.1
**Change:** loop rewritten as 3 asyncio tasks; ISR defers via micropython.schedule; WDT 8 s
**Memory:** free heap stable at 142-146 KB over 2 h (was falling 3 KB/min)
**Deploy:** `mpremote cp -r lib/ : + cp app.py main.py :`
**Open:** BME280 driver still .py; precompile with mpy-cross if heap gets tight

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

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: "MicroPython and CircuitPython"
  short_description: "Python on a Pico that never needs a reflash"
  default_prompt: "$micropython My Pico W script dies with MemoryError after a few hours, find the leak"

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