I spent two weeks debugging a MIPI CSI-2 camera bring-up. The sensor is an AR0234CS, the receiver is a Lattice CrossLinkPlus FPGA bridging into an i.MX RT1176, and the symptom was that 85% of CSI-2 packets failed ECC validation in the FPGA’s integrated parser. Diagnosis took cross-referencing 700+ pages of ON-Semi datasheets, dozens of sensor and FPGA configuration permutations, and forensic byte traces from a debug peripheral inside the FPGA itself.

I did not rebuild firmware once during that diagnosis.

The entire debug loop ran at REPL speed. A Claude agent would form a hypothesis (something like maybe the wire data type isn’t actually RAW8), issue a Python command over USB serial to read the sensor’s MIPI_CNTRL register, watch the readback, refine the hypothesis, try the next experiment. All within seconds. None of it touching the firmware build system.

This post is about why that workflow is the shape AI agents need. And why MicroPython is the only mainstream embedded stack that makes it possible.

What “agentic hardware development” actually looks like

The pattern is concrete:

  1. Agent has a hypothesis about hardware state (“maybe the PLL is misconfigured”, “maybe T_HS_TRAIL is too short”, “maybe lane 1 P/N is swapped”).
  2. Agent generates a Python snippet that tests the hypothesis: read a register, sweep a value, capture a byte trace, change a setting and re-measure.
  3. The snippet runs on the live device over USB serial via mpremote:
    1
    2
    
    mpremote connect $SKYDECK resume exec "i2c.writeto_mem(0x18, 0x3036, b'\x00\x14'); \
        print(hex(i2c.readfrom_mem(0x18, 0x3036, 2)[0]))"
    
  4. The result comes back as text in seconds.
  5. Agent observes, updates its model of the system, generates the next experiment.

The loop is type-Python → see-result → think → type-next at the cadence of a conversation. No compile step. No flash step. No reset. The device keeps running with peripherals in their current state while the agent pokes at them.

The mechanism that lets this work is AIOREPL: an asyncio-aware Python REPL running in parallel with the application. The dashboard keeps drawing. The BLE service keeps advertising. The agent attaches to the same USB CDC the user sees as a virtual COM port, types Python, watches things happen.

For the AR0234 bring-up this loop ran through eight hypotheses about the MIPI configuration (bit rate matched and mismatched, settle period swept across the spec window, lane FIFO depth tweaked, parser variant flipped between WFS and NWFS, sensor timing registers overridden one at a time) over a single afternoon. In a traditional C/Zephyr workflow each of those eight experiments would have been a flash cycle, a serial-log capture, and a reflash. Eight afternoons collapsed into one.

The same loop in Linux or Zephyr

Linux SBCs do have Python. You can ssh into a Raspberry Pi and run a Python REPL. But every hardware access (every I²C transaction, every register poke) is mediated by a kernel driver. To probe an undocumented peripheral you write a kernel module. To bring up a new sensor you write a device-tree fragment and an i2c-client driver that takes hours to compile and load. The kernel is in the way.

Zephyr is bare-metal with direct hardware access. Every debug interaction is a code change though. Want to read a different sensor register? Edit the driver, recompile (60–120 seconds for a typical app), flash (30 seconds), reset, observe one value, repeat. The build system structures the feedback loop. Not the speed of thought. AI agents struggle with this. Context windows fill with build logs. Iteration cadence makes interactive exploration impractical. The agent ends up writing batches of experiments. They fail together because feedback only comes after the batch completes.

MicroPython’s REPL erases the loop. The Python interpreter is already running on the device. machine.I2C and machine.mem32 are already imported. The agent types Python, hardware responds, agent types more.

Why this matters more for AI agents than for humans

A human hardware engineer with an oscilloscope and a JTAG probe can make a debug loop work in any stack. They develop intuition, they remember context, they tolerate long iteration cycles by reading datasheets in between cycles. The cost-per-iteration is high but the human absorbs it.

AI agents pay a different cost:

  • Context is rate-limited. Long compile/flash cycles fill the agent’s context window with build logs. An agent that spends 5 KB of context per experiment cycle can only do 100 experiments before it loses memory of the earlier results. At REPL speed an experiment costs ~200 bytes of context. The agent can run hundreds before needing to reflect.

  • Iteration latency breaks the loop. When each experiment takes a minute the agent forgets why it was doing the experiment and the human gets impatient. When experiments take seconds the agent develops momentum: chains of related hypotheses without breaking state.

  • LLMs are best at code generation. Toolchain operation, not so much. An LLM can write five lines of i2c.writeto_mem flawlessly. It struggles much more with multi-file Zephyr device-tree changes spread across overlays, Kconfig, and driver source.

The advantage shows up in the kinds of tasks the agent can actually finish. With the AR0234 work I had Claude generating, executing, and interpreting dozens of experiments per hour. With a Zephyr project the same kinds of experiments would take days.

Concrete patterns from real bring-up work

These are the techniques that stack up when MicroPython and an AI agent work together. None of them are deep. All of them are out of reach in stacks that lack the live REPL.

Direct register access from Python

machine.mem32 is a Python-level interface to the MCU’s physical memory map. Reading and writing peripheral configuration registers, clock dividers, IOMUX settings all happen at REPL speed:

1
2
3
4
5
6
import machine
m = machine.mem32
# LPI2C3 control + status (RT1176)
print('MCR:', hex(m[0x4010C010]), 'MSR:', hex(m[0x4010C014]))
# Reconfigure the CSI parallel-data formatter on the fly
m[0x402BC018] = (m[0x402BC018] | (1 << 3)) & ~(1 << 4)

For the FPGA-bridge work this let me reconfigure the RT1176 CSI2RX controller while the camera was streaming. I could observe the effect on the frame buffer in real time. The same investigation in Zephyr would have meant editing the SDK driver, recompiling, and re-flashing for each value of one register field.

Early in board bring-up an LPSPI5 peripheral was failing because JTAG was silently stealing its pin. The agent found it by sweeping the IOMUXC pad-mux ALT modes 0–7 in a single REPL loop and watching the LPSPI status register:

1
2
3
4
5
m = machine.mem32
pad_addr = 0x400E8000 + 0x0C  # GPIO_AD_03
for alt in range(8):
    m[pad_addr] = alt
    print(f"ALT{alt}: LPSPI_SR=0x{m[0x4011D024]:08x}")

The output showed which ALT mode the SPI peripheral was on and which one was being overridden by JTAG. Time to discovery: about 90 seconds. Time to fix (writing IOMUXC_LPSR_GPR14 = 0x80 to disable the JTAG override): another 30 seconds. In a Zephyr workflow the equivalent investigation would have spanned multiple build-flash cycles across an afternoon.

Live I²C bus exploration

machine.I2C is a Python class that scans the bus, reads chip-ID registers, and tabulates devices on the spot. No driver. No device tree. No I²C client struct. Just:

1
2
3
4
5
6
from machine import I2C
i2c = I2C(1, freq=100_000)
print('devices:', [hex(a) for a in i2c.scan()])
# Read AR0234 chip ID:
i2c.writeto(0x18, b'\x30\x00')
print('chip_id:', hex(int.from_bytes(i2c.readfrom(0x18, 2), 'big')))

That snippet identifies the sensor model, confirms the I²C address, and verifies the bus is healthy in under a second of REPL interaction. In a traditional flow you’d write a driver, point the device tree at the right bus and address, and rebuild before you could read that one chip-ID register.

Hex dumps and forensic byte traces

When the FPGA was failing to decode CSI-2 packets I added a 32-byte byte trace buffer in RTL (accessible via I²C debug-register slave at address 0x58) and read it from the REPL:

1
2
3
4
5
6
i2c.writeto_mem(0x58, 0x10, b'\x01')  # arm trace
time.sleep_ms(20)
bd0 = bytes(i2c.readfrom_mem(0x58, 0x20, 32))
bd1 = bytes(i2c.readfrom_mem(0x58, 0x40, 32))
print('lane 0:', bd0.hex())
print('lane 1:', bd1.hex())

The output was lane 0: 2a 2a 07 5c 5c 5c 5c .... The AR0234’s data identifier byte (0x2A) appears twice on lane 0 before the expected next header byte. That single observation was the breakthrough that unblocked two weeks of failed-decode investigation. It cost approximately 30 seconds of REPL interaction.

Sweep-and-measure register tuning

This is where the agent really shines. Given a register whose correct value is uncertain, the agent generates a sweep loop, executes it, and tabulates the results:

1
2
3
4
5
6
for val in (0x0808, 0x0A08, 0x0A0A):
    i2c.writeto_mem(0x18, 0x31AC, val.to_bytes(2, 'big'))
    time.sleep_ms(500)
    sync_rate = read_fpga_counter(0x10, 0x11)
    decode_rate = read_fpga_counter(0x12, 0x13)
    print(f'DATA_FORMAT_BITS=0x{val:04x}: sync={sync_rate}/s decode={decode_rate}/s')

When that sweep finished the agent could see immediately which register value gave the best decode rate. No sensor driver. No firmware recompile. No device reset.

A similar pattern unlocked a CSI clock issue earlier in the project. The agent flipped the RT1170’s CSI receiver clock divider from 2 (400 Mbps) to 1 (800 Mbps) with a single register write, watched the FPGA’s frame counter jump from 0 to 47, then flipped it back when it became clear the higher rate was unsupported by the source. Two minutes total. No Kconfig changes. No device-tree edits. No DCD regeneration.

Net-new drivers from schematic + REPL

The pattern works for greenfield driver development too. Not just debugging existing code. On a separate project an agent brought up a new touchscreen display (RVT70HSM + ILI2132A touch controller) from a starting state of “schematic PDF and a 200-page datasheet.” The loop:

  1. Agent extracted the 7-bit I²C address (0x41) and the lane-config register (0xB2, 0x50) directly from the datasheet PDF.
  2. Agent instantiated the touch controller class at the REPL on the live device: ILI2132ATouch(i2c_bus=1, addr=0x41).
  3. Agent caught a schematic-to-pin-binding mismatch (pin_DISP_nRST was missing the _DIG suffix in the board pin file) before the first firmware build by cross-referencing the schematic against the generated pin list.
  4. Agent ported the touch protocol (8-byte I²C report, byte 1 bit 0x40 = touch present) from the existing Apache-licensed Zephyr driver, validating each byte against the datasheet at the REPL as the implementation went in.

A 164-line display driver and a 190-line touch driver landed ready-to-ship in a single development session. The C/Zephyr equivalent would have been multi-week. Crucially, the schematic bug in step 3 would have been discovered only after the first firmware build failed at runtime.

Same code on the laptop and the device

MicroPython’s unix port runs the same application files in a desktop process. For agent workflows that means hardware-independent logic (protocol parsers, state machines, formatters, business rules) iterates on the agent’s host before any of it touches the device. Fast and parallel.

Building a dashboard application driven by an aio-statechart is a recent real example. The chart and the UI rendering run identically under the unix port and on the device. Claude iterates on chart transitions in a desktop test loop (under one second per iteration), validates the state machine against a generated test fixture, then mpremote cps the final file to the device’s filesystem. The device picks up the new logic on next reload. No firmware rebuild.

The same property lets unrelated work happen in parallel. Agent A drives the camera-bring-up REPL session on the hardware. Agent B runs the dashboard UI in a desktop harness with a browser-served framebuffer viewer. Neither blocks the other. The shared codebase runs in both places.

UI iteration in the browser

LVGL on the unix port renders into a bytearray framebuffer. A small HTTP server (the live-dev-lvgl harness) exposes that framebuffer as a stream. A JavaScript viewer hot-reloads on file save and forwards click coordinates back to LVGL as virtual touches.

For agent-driven UI work that’s a complete development loop. Edit dashboard.py. Fetch /screen.bin. See what the user would see. Propose the next change.

A recent session: rebranding an existing dashboard for a different customer. The agent fetched logo PNGs from the customer’s website, ran them through lvgl_image_convert.sh (a PNG → LVGL .bin converter shipped with the harness), edited the dashboard Python to swap header text and dropdown options, captured the rendered framebuffer, iterated. Twelve cycles of brand polish in a single session. No firmware rebuilds. No flashes.

Datasheet → LLM context → live test

Vendor PDFs are LLM-hostile (multi-column layouts, tables that come out as ragged text). The pattern that worked: extract the relevant PDFs to Markdown once, then have an Opus agent cross-reference the live register dump from the device against the documented bit-fields, proposing experiments grounded in citations.

For the AR0234 work three PDFs (datasheet, developer guide, register reference) were extracted to ~67 KB of Markdown by a Haiku agent in five minutes. An Opus agent then read all three plus the live register dump and produced a prioritised list of ten experiments to try, each with citation, rationale, and the exact i2c.writeto_mem call to execute. Eight of them were tried at the REPL in 30 minutes. Two surfaced bugs in the existing driver that had been there for months.

Building forensic peripherals into the FPGA itself

The FPGA bridge included a custom I²C slave at address 0x58 exposing 100+ debug registers: clock telltales, hs_sync_cnt, remap_cnt, framebuffer pointers, sticky error flags, a byte trace buffer. Building those into the RTL took an afternoon. Every subsequent diagnostic session became Python-readable though. The pattern generalises: any peripheral you can imagine should expose its state over a register interface that MicroPython can read at the REPL.

The multi-agent forensic pattern

The AR0234 bring-up converged on a workflow that’s worth naming:

  1. Live device exposes state via I²C debug peripheral + Python REPL.
  2. Haiku agent extracts vendor docs to LLM-friendly Markdown (cheap, parallelisable).
  3. Opus agent reads the docs + the live state + the source code, produces hypothesis list.
  4. Agent or human runs each hypothesis as a REPL snippet.
  5. Results flow back as text into the agent’s context for the next iteration.

This pattern only works when the device is interactively probable. Linux SBC + Python is interactively probable but kernel-mediated. Zephyr + RTT/SWO is probable but each probe is a code change. Bare-metal C + JTAG/semihosting is probable but at a feedback latency that breaks LLM context windows.

MicroPython is interactively probable at a granularity and speed that matches how LLMs actually think.

When MicroPython isn’t the answer

Being honest, there’s a narrow set of cases where MicroPython genuinely isn’t the right fit:

  • Million-plus unit cost-down where every cent of BOM matters and the VM’s RAM and Flash overhead can’t justify itself against a bare-metal C build. MicroPython runs first-class on the $1 RP2040 though, so this case is narrower than most teams assume.
  • Frequent wake-sleep duty cycles where the energy spent in cold-boot dominates the power budget.
  • Truly memory-constrained targets at under 32 KB RAM.

A few things often listed as MicroPython limitations don’t actually hold up.

“Can’t do hard real-time” is wrong. Hard interrupts via machine.Pin.irq(hard=True) run in true interrupt context with microsecond-level response. @micropython.viper compiles a Python function to typed integer code with no VM dispatch in the hot path, often much faster than regular MicroPython bytecode. @micropython.asm_thumb lets you write inline ARM Thumb-2 assembly directly. Combine those with pre-allocated buffers, gc.disable() around critical sections, and machine.disable_irq() for the truly time-sensitive code. Timing becomes fully deterministic. MicroPython asyncio is cooperative so there are no background RTOS threads waiting to preempt you either. Hard real-time is tricky here but it’s equally tricky to get perfect on any RTOS platform.

“Can’t do heavy DSP or AI” doesn’t hold either. OpenMV is the existence proof: vision processing, ML inference, complex image pipelines all running under MicroPython, with the inner loops written as C extensions and the application orchestration in Python. Heavy processing belongs in targeted C functions either way. MicroPython gives you the orchestration layer for free.

Even in the cases where MicroPython genuinely isn’t the answer the development board can still run it for bring-up and characterisation, then swap stacks for production. The agentic diagnostic loop pays for itself even if the shipped product is C/Zephyr.

How to set up the same workflow

Three pieces are needed:

  1. A MicroPython port on your target. Mainline MicroPython supports STM32, NXP i.MX RT, ESP32, RP2040, Nordic nRF, and others. If your target is in that list, you’re 80% there.

  2. A device that boots straight into a Python REPL. Usually over USB serial. Sometimes UART or BLE. Frozen modules in firmware for application code; the REPL stays alive for agent interaction.

  3. An agent that can run mpremote. The mpremote skill on the Claude marketplace gives Claude Code the device-interaction patterns described above: resume exec, file transfer, live-session monitoring. With it installed, Claude can drive any MicroPython device by name.

The setup is hours, not weeks. From a cold dev environment to “agent poking my hardware at REPL” is realistically a day.

What this is really about

Embedded development has been the slowest-moving software discipline for forty years. Build-flash-debug-reset has been the loop since the 80s. AI agents could accelerate it dramatically, but only on stacks that match how LLMs actually work: text-in, text-out, fast feedback, no compile barriers.

Most embedded stacks fail that test. MicroPython passes it. The result is what we just demonstrated on the AR0234: a two-week debugging campaign whose iteration loop ran at the speed of typing, not the speed of make.

That gap will only widen as AI agents get faster, cheaper, and more capable. Pick your stack with that future in mind.