Install
$ agentstack add skill-dakshaymehta-cardputer-claude-os-m5-onboard ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ● Filesystem access Used
- ● Shell / process execution Used
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
M5Stack Onboarding
This skill automates the full cold-start workflow for an M5Stack ESP32 device: detect on USB, identify model, flash UIFlow 2.0, and push a MicroPython app bundle onto /flash/ so the device boots into user software. The apps we ship (Claude Buddy, Snake, Hello) talk over BLE or USB. The workflow runs on macOS, Linux, and Windows; the skill was developed against an M5Stack Basic v2.6 (CH9102 bridge, ESP32-D0WDQ6-V3, 16 MB flash) and generalized to cover the rest of the Core family, with the Cardputer-Adv (ESP32-S3, native USB) as the current default target.
Installation (one-time, per machine)
The skill lives at .claude/skills/m5-onboard/ inside this repo, so Claude Code auto-discovers it whenever the repo is added as a plugin (or the repo is opened as the working directory and .claude/skills/ is on the discovery path). No symlink step is needed in the normal case — clone the repo, point Claude Code at it, and the skill is live.
git clone # anywhere — clone location doesn't matter
The buddy/ sibling directory ships in the same repo, so --apps buddy works out of the box after a single clone — the skill resolves the bundle relative to its own install location (via os.path.realpath(__file__)), so the repo can live anywhere on disk. ~/Downloads/m5stack/buddy/device and ~/Desktop/m5stack/buddy/device are checked as conventional fallbacks if the script-relative path doesn't pan out. Set M5_BUDDY_DIR only if you want to point at a different bundle than the one bundled with this clone — see the Platform notes section.
When to use
Use this when a user plugs in an M5Stack device and wants it provisioned. The decision tree:
- Fresh/unknown device → run
onboard.py --apps buddyend-to-end (detect → identify → flash → install apps). This is the default path. - Already-flashed device, user just wants apps installed/refreshed → run
install_apps.py --src buddy(or any--srcto a directory of.pyfiles). - Flashed device, something feels broken → run
smoke_test.py(I2C + LCD + speaker + button check). - User wants to know what's on the bus / what the device can do →
smoke_test.py.
If multiple devices are plugged in, ask which port to target — don't guess. If the user is provisioning a device they previously worked with (e.g. "same thing as last time" or "another Buddy"), default to --apps buddy unless they say otherwise.
Which variant to assume
The rig this skill lives on provisions Cardputer-Adv boards overwhelmingly, so onboard.py now defaults to --variant cardputer-adv. In practice that means:
- If the user says nothing about the model, go with the default. They're almost certainly holding a Cardputer-Adv.
- If the user says "Cardputer" (no "Adv"), ask — the two models share a form factor but take different firmware images, and flashing the wrong one boot-loops the device.
- If the user names any other board ("Core2", "CoreS3", "Basic", "Fire"), pass the matching
--variantexplicitly — the default won't apply. - The chip is ESP32-S3 either way, and
detect.pywon't be able to tell Cardputer from Cardputer-Adv before UIFlow is flashed (same native USB-JTAG VID, no pre-flash I2C probe). So this is a user-intent question, not a hardware-fingerprint one.
The workflow
The main orchestrator is scripts/onboard.py. It drives the sub-scripts in order and handles the handoffs between them (waiting for reboots, capturing MAC, reporting progress). Prefer calling it directly over stitching the sub-scripts yourself unless the user asks for a partial run.
The default provisioning command (fresh Cardputer-Adv, install the buddy bundle):
python3 scripts/onboard.py --apps buddy
How to invoke this from Claude Code's Bash tool. Do NOT call onboard.py as a foreground Bash command. The Bash tool captures output and does not stream it back to the assistant until the command exits — and this command runs 2–3 minutes. That silence looks identical to a hang, and the assistant will usually give up before the button-dance prompt ever reaches the user. Instead, always run with run_in_background: true, tee to a log file, and then use the Monitor tool (or periodic tail via Read) to surface stage banners, heartbeats, and prompts to the user in real time. 2>&1 is not the fix — all progress already writes to stderr, which a terminal shows fine. The fix is streaming semantics, not redirection. The pattern that works:
# Launch (background, tee log):
python3 scripts/onboard.py --apps buddy 2>&1 | tee /tmp/m5-onboard.log
# Monitor (surfaces key events without drowning in byte-progress spam):
tail -f /tmp/m5-onboard.log | grep -E --line-buffered \
"^====|heartbeat|Heads up|Enter download mode|download mode!|rebooted into UIFlow|Manual reset|DONE|ERROR|Error|Traceback|FAIL|failed|No USB|not detected|Attempt [0-9]|Device already in download|Download mode port|Post-flash port|Waiting for device"
Users running onboard.py directly in their own terminal (not via Claude Code) will see all output live — no changes needed there.
If --port is omitted, detect.py picks the most likely candidate across all three OSes: native-USB ESP32-S3 (/dev/cu.usbmodem* on macOS, /dev/ttyACM* on Linux, COMx on Windows), or a CH9102/CP210x UART bridge on older boards. Bluetooth-serial ports are filtered out. If multiple candidates are present, it asks.
The known apps name buddy resolves to the buddy/device/ directory in this repo (custom launcher + Hello + Claude Buddy BLE client + Snake). Any other --apps value is treated as a filesystem path.
To skip re-flashing and just push (or refresh) the apps onto an already-provisioned device:
python3 scripts/install_apps.py --port --src buddy
Where ` is whatever detect.py printed on the last full run — for example /dev/cu.usbmodem1101, /dev/ttyACM0, or COM3`.
Stages
- Detect (
detect.py) — enumerate serial ports, filter to USB-UART bridges (CH9102 vendor0x1A86, Silabs CP210x0x10C4, FTDI0x0403) or the ESP32-S3 native USB-JTAG interface (0x303A). Probe with esptool to confirm the chip. Port names differ per OS (/dev/cu.usbmodem*on macOS,/dev/ttyACM*/ttyUSB*on Linux,COMxon Windows) but pyserial abstracts that. - Identify (
detect.py) — alongside port discovery,detect.pyreads the factory-test partition signature and/or scans I2C once UIFlow is on, and cross-referencesreferences/hardware_signatures.mdto suggest the right firmware variant (Basic-16MB, Core2, CoreS3, Cardputer-Adv, etc.). User-facing variant choice happens viaonboard.py --variant; there is no separatedetect.py --identifyflag. - Fetch firmware (
fetch_firmware.py) — query the M5Burner manifest API and download the appropriate UIFlow 2.0 binary into the system temp dir. Cached between runs — safe to clear the cache anytime, it just re-downloads. - Flash (
flash.py) —esptool write_flash 0x0at 460800 baud for UART bridges,--no-stubat 115200 baud for native-USB S3 devices. 921600 fails intermittently on the CH9102 bridge — do not increase it. Native-USB flash can intermittently throwLost connection, retryingmid-erase; esptool recovers. The post-flashwatchdog-resetteardown step can fail even when the flash itself succeeded —flash.pyparses esptool's stdout, treats that specific failure pattern as non-fatal whenHash of data verifiedappeared, andonboard.pyfalls back toflash.native_reset()and then manual-RESET coaching if needed. - Install apps (optional,
install_apps.py) — paste-mode REPL upload of every.pyfrom a source directory into/flash/, then reboot viarepl_reset(DTR/RTS is a no-op on native USB — don't reach for it). Source layout: root*.py→/flash/,apps/*.py→/flash/apps/(UIFlow's stock launcher scans that). When the bundle ships a rootmain.py,install_apps.pyalso sets NVSboot_option=2so UIFlow's own launcher doesn't run and ourmain.pytakes over the boot flow — critical for BLE-using apps on ESP32-S3 (see gotchas below). - Smoke test (optional,
smoke_test.py) — I2C scan, LCD test pattern, speaker beep, button read.
Critical gotchas (baked into the scripts — do not second-guess)
These are things the scripts already handle correctly but which you should not override if the user asks you to "just run esptool manually" or similar:
- Native-USB ESP32-S3 boards (Cardputer, Cardputer-Adv, CoreS3) require a physical BtnG0+BtnRST dance to enter download mode. There is no software path. The chip has no DTR/RTS bridge, so nothing esptool or pyserial can do will put it into the ROM bootloader — the user has to hold GPIO0 low across a reset pulse with the hardware buttons. On Cardputer-Adv specifically both buttons (BtnG0 and BtnRST) are on the back of the device — small, flush-mounted, often easiest to press with a fingernail.
onboard.py:_wait_for_download_portprompts for this at runtime during FLASH; the exact sequence to walk the user through is:
- Press and HOLD BtnG0.
- While still holding BtnG0, briefly press BtnRST (a tap — don't hold it).
- Release BtnRST first, then keep holding BtnG0 for ~1 more second.
- Release BtnG0. The screen should now be fully dark — that's how you know the device is in download mode.
If the device reboots back into UIFlow instead, BtnG0 was released too early — the coaching retries and tells the user to hold it longer. Do NOT try to automate this with esptool --before default_reset or pyserial's DTR/RTS; both are no-ops on native USB (the pins aren't wired to EN), and adding them just hides the real prompt. Keep the SKILL.md steps above and the runtime prompt in onboard.py:_wait_for_download_port in sync — they're the same four steps in two places.
- Do not unplug the device during FLASH. Especially on native USB. A mid-flash disconnect leaves the internal flash in an inconsistent state. Mask ROM is usually reachable afterwards (press BtnG0 alone on the back, or do the full BtnG0+BtnRST dance), so the recovery is just to re-run
m5-onboard go— it's idempotent and will re-enter download mode, re-flash, re-push apps. Don't panic and don't start opening the case; the mask ROM is in silicon and survives a corrupted flash as long as the USB PHY is intact. - Baud rate is 460800 on UART bridges, 115200 with
--no-stubon native USB. Not 921600 on either. The CH9102 bridge loses sync onerase_flashat 921600 (not theoretical — it fails). Native USB's stub-baud-bump path produces "Lost connection" mid-flash; 115200 no-stub is counterintuitively faster end-to-end because it never fails. - NVS writes must use
set_str, notset_blob(relevant toinstall_apps.py'sboot_optionsetter). UIFlow's startup callsnvs.get_str()and ESP-IDF tags blob and string entries separately. A blob-tagged key returnsESP_ERR_NVS_NOT_FOUNDtoget_str, and the device boot-loops. If a prior attempt wrote a blob, callnvs.erase_key(name)beforeset_str. - REPL multi-line blocks need paste mode. Sending
try:/except:line-by-line makes the REPL accumulate indentation forever. Use Ctrl-E to enter paste mode, send the block, Ctrl-D to execute.mpy_repl.pywraps this. - Hard reset is DTR=False, RTS=True, 100ms, RTS=False — but only on UART-bridge devices. On native-USB ESP32-S3 boards the DTR/RTS lines aren't wired to EN/GPIO0, so that pulse is a silent no-op. Use
mpy_repl.repl_reset()(sendsmachine.reset()through the REPL) for post-install reboots on those devices —install_apps.pyalready does this. If you bypassinstall_apps.pyand stitch your own flow, don't reach for DTR/RTS on a usbmodem port and expect a reboot; files will be on disk but the old code will still be running. That regression bit us once. - The idle heap-debug loop is normal. UIFlow 2.0 prints asyncio diagnostics while waiting at the pairing screen. Don't interpret it as a hang.
- Cardputer-Adv (ESP32-S3) BLE peripherals require NVS
boot_option=2+ a custommain.py. UIFlow's defaultboot_option=1starts a background Flow-pairing BLE advertise that wedges the NimBLE controller — subsequentgap_advertise(adv_data=...)calls from user code hit OSError(-519) "Memory Capacity Exceeded" regardless of payload shape, and the device ends up advertising with empty AD fields that iOS and the desktop Claude Buddy app filter out. The bundle'smain.pylives at/flash/and takes over the boot flow (showing a simple menu over/flash/apps/), never touches BLE itself, and leaves the controller pristine for whichever app the user picks.install_apps.pynow setsboot_option=2automatically when the bundle ships a rootmain.py— don't regress that behavior.
After provisioning (what the user sees on the device)
Once m5-onboard go finishes at the DONE banner, the device is ready to use on its own:
- Power. Slide the switch on the right edge of the Cardputer-Adv to turn it on. Same switch turns it off. The board runs off its internal LiPo when unplugged; USB-C charges it.
- Boot. A short boot log scrolls, then the launcher menu appears automatically. The menu lists every
.pyin/flash/apps/plus the top-level/flash/*.pyentries. - Navigation. Arrow keys (or the keyboard's trackpoint-style cursor keys) scroll the menu; Enter launches the highlighted app; ESC returns to the launcher from inside an app.
- Event WiFi auto-connect. The bundle's
main.pyconnects to a hard-coded event WiFi (SSIDcardputer) on every boot and shows the result on the LCD before the launcher menu appears. Credentials live inbuddy/device/wifi_event.py; the connect is best-effort and the launcher always continues even if the connect fails. If you're using this bundle outside the event, editwifi_event.pyor remove the_connect_wifi_with_splash()call frommain.py. - Claude Buddy over BLE. First time only: in Claude Desktop, Help → Troubleshooting → Enable Developer Tools (one-time, persists across launches). Then Developer menu → Hardware Buddy → Connect. BLE works regardless of the WiFi state — the link to Claude.app is local.
- Getting back to UIFlow. The buddy bundle ships only a
main.pyat/flash/(no replacementboot.py), so the stock UIFlowboot.pyis never touched and there's noboot_uiflow.pybackup to restore. Revert by removing ourmain.pyfrom the device REPL:os.remove('/flash/main.py')followed bymachine.reset(). UIFlow's stock launcher takes over on the next boot. To start completely fresh including the firmware, re-run the skill without--apps.
Files
scripts/onboard.py— main orchestratorscripts/detect.py— port discovery + chip IDscripts/fetch_firmware.py— M5Burner API + downloadscripts/flash.py— esptool wrapperscripts/install_apps.py— push a directory of.pyfiles into/flash/via paste-mode REPL; backs upboot.pyasboot_uiflow.pybefore overwriting; also writes theboot_optionNVS key when the bundle ships a rootmain.pyscripts/smoke_test.py— I2C + LCD + speaker + buttonsscripts/mpy_repl.py— shared serial/REPL helpers (paste mode, hard reset, boot-log capture)references/hardware_signatures.md— chip + I2C fingerprints → model → firmwarereferences/uiflow2_nvs.md— NVS key reference with types and failure modes
Dependencies
pyserial— vendored at.claude/skills/m5-onboard/scripts/vendor/serial/(pinned 3.5, BSD-3-Clause).esptool— pip dependency, declared inrequirements.txt. Importable check happens viaimportlib.util.find_spec("esptool"); binary backstop search covers~/Library/Python/*/bin/on macOS,~/.local/bin/on Linux,%APPDATA%\Python\Python3XX\Scripts\on Windows.
onboard.py runs a preflight check at startup: if esptool (or, in the rare prune-vendor case, pyserial) is mi
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: dakshaymehta
- Source: dakshaymehta/cardputer-claude-os
- License: Apache-2.0
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.