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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- micropython Installs to ~/.agents/skills/micropython/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install micropython 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 micropython Installs to .agents/skills/micropython/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/micropython.zipunzip -o micropython.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))) micropython 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 $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.
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
| Clue | Runtime | Deploy with |
|---|---|---|
main.py, boot.py, REPL banner “MicroPython v1.2x” | MicroPython | mpremote |
code.py, a CIRCUITPY USB drive, lib/adafruit_*.mpy | CircuitPython | Copy 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
| Symptom | Fix |
|---|---|
MemoryError on import | Precompile modules with mpy-cross (version must match the firmware’s .mpy format) or freeze them into firmware |
| Free memory shrinks over hours | Allocations in the loop: preallocate bytearray buffers, use readinto, avoid string concatenation |
Fragmentation, large allocations fail though mem_free is high | Allocate 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_bme280thencircup update(needs network on host). - Wi-Fi credentials go in
settings.tomlasCIRCUITPY_WIFI_SSIDandCIRCUITPY_WIFI_PASSWORD; read other keys withos.getenv. - The drive auto-reloads on save; writing files from code needs
boot.pyto remount (storage.remount("/", readonly=False)), which makes the drive read-only to the host. - Use the
asynciolibrary from the Adafruit bundle (circup install asyncio) or atime.monotonic()scheduler instead oftime.sleeploops.
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.yaml
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.