Giving Claude Code a Logic Analyser: A Minimal Bench Harness for Zephyr on the nRF52840
Photo by Igor Omilaev / Unsplash

Giving Claude Code a Logic Analyser: A Minimal Bench Harness for Zephyr on the nRF52840

An AI coding agent can write, build and flash firmware, but it cannot see the LED. This article builds a small Claude Code harness for Zephyr on the nRF52840 DK that closes the loop with real measurements from an Analog Discovery, and shows what it caught that reading the code never would.

Giving Claude Code a Logic Analyser

💡
This article starts a series on AI-assisted embedded development: A minimal Claude Code bench harness for Zephyr on the nRF52840.

An AI coding agent can now write a Zephyr application, build it, flash it and read the serial console, all without a human touching the keyboard. What it cannot do (just yet) is look at the board. It can write gpio_pin_set_dt(&led, 1) and have no idea whether the LED came on, went off, or was never connected. Every claim it makes about hardware behaviour is, by default, a claim about what the code says, not what the circuit actually does.

That gap is not just hypothetical. In my devicetree article the whole teaching point was an LED that comes on when you tell it off, because of a polarity flag that is in a different file from the code. An agent reading the code would get that wrong with complete confidence. So would an over-worked academic!

This article builds a small harness that gives Claude Code "eyes": an Analog Discovery wired to the GPIO pins of an nRF52840 DK, along with a Python script that turns captures into numbers, and a small number of Claude Code configuration files that make it hard for the agent to finish a task without measuring it. It is kept minimal. Everything fits in one project folder of plain text files, and the complete project is provided on GitHub at derekmolloy/een-claude-harness.

The more interesting part (which I still find hard to believe) is what happened while building this article. In one evening the measurements caught two incorrect firmware builds (in both, the agent's changes had not done what it believed), a clipped voltage reading that looked realistic, and a 10 µs cost in the button response that you would never find by reading the source code. This is why AI coding agents need (appropriate) access to real hardware in order to be effective at evaluating their output.

💡
This guide is a snapshot, current as of autumn 2026. It was written and run on hardware against Zephyr v4.4.0-7043 with the Zephyr SDK 1.0.1, nrfutil 8.2.1, SEGGER J-Link V9.82 and the WaveForms SDK 3.25.1, on Windows 11. The project is at C:\zephyr\zephyrproject\een-claude-harness, cloned from GitHub. Claude Code changes quickly; where a configuration detail below does not match what you see, the Claude Code documentation is the authority.

Part 1: what a harness is, and why this one is small

The problem with "it compiles"

When you ask an agent to change firmware, there is a chain of things that all have to be true before the change has worked:

  • the edit was actually applied to the file you think it was
  • the build used that file and the configuration you intended
  • the image that was built is the one now running on the board
  • the board does what the change was meant to make it do

A human developer checks most of these things without too much thought. You see the editor change, you watch the build log scroll, you look at the physical LED. An agent has none of those senses unless you give them to it, and it will report success at the first link in the chain. The job of a harness is to make each link something the agent can check.

What "minimal" means here

There are elaborate ways to do this (custom MCP servers, hardware-in-the-loop CI rigs, test frameworks). I wanted the smallest example for this article that closes the loop, built from parts anyone can check on their own workbench:

Piece Job
CLAUDE.md The board facts, the wiring and the rules, written for the agent
scripts/*.ps1 Build and flash in a way that cannot depend on terminal state
bench/measure.py The "eyes": Analog Discovery captures turned into JSON
bench/expect.yaml What "correct" means, documented before measuring
bench/check.py One verdict: is the right firmware on the board, and does it pass?
.claude/settings.json What the agent may run freely, must ask about, or must never do
A Stop hook Refuses to let the agent finish while a build is unmeasured
A /bench-check skill The whole loop as one command, with a fixed reporting format

None of these pieces is clever on its own. The important value is in how they constrain each other, which is what the rest of this article is about.

Part 2: the workbench

Why the DK and not the XIAO

My earlier nRF52840 articles all used the Seeed XIAO, a €12 board that flashes by dragging a UF2 file onto a USB drive. That is intuitive for a human user but a problem for an AI agent. To get the XIAO into its bootloader you double-tap the tiny reset button, so every single flash needs a person at the bench. The loop is only half closed.

The nRF52840 DK (PCA10056) has the same SoC plus an on-board SEGGER J-Link debugger. west flash programs it over SWD with no button presses, so the agent can go round the build, flash and measure loop on its own as many times as it needs. The same firmware runs on both boards with a different overlay, so nothing here is DK-specific except the flashing.

💡
The XIAO still works with this harness. Point the build at xiao_ble, swap the flash script for a UF2 copy, and everything else is unchanged. The challenge is that the agent has to stop and ask you to double-tap reset before every flash. (There is also something uncomfortable about an AI tool issuing such a primitive instruction to a human user on each loop!) That is a limitation of the board, and a harness cannot remove it.

The Analog Discovery

The instrument is the original Analog Discovery (Rev C), which is about 14 years old, and still very capable. Rather than trusting the spec sheet, I asked it what it was through the WaveForms SDK. The SDK reports a two-channel scope, two waveform generators and 16 digital I/O lines on a 100 MHz logic clock, and it lists several device configurations that trade buffer memory between instruments:

Configuration Scope buffer Logic buffer Pattern buffer
0 (default) 8,192 4,096 1,024
3 512 16,384 16,384

That table matters. On the Nucleo scheduler article the 4,096-sample logic buffer forced me into three separate capture set-ups. Configuration 3 quadruples the logic depth at the cost of a tiny scope buffer, which is a good trade for timing work: at 100 MS/s the single-shot window grows from 41 µs to 164 µs. The harness selects the configuration that suits each measurement.

❓
Alternatives to the original Analog Discovery. My Analog Discovery is the original Rev C model, which is no longer sold. Any instrument that the agent can drive from a script will work. The only file that talks to the instrument is bench/measure.py, and the rest of the harness reads the JSON it prints. Current alternative options:
- Digilent Analog Discovery 3: the current model, with two 14-bit scope channels at 125 MS/s and 16 digital I/O lines. It uses the same WaveForms SDK, so measure.py should need only its device configuration and buffer sizes checked.
- Digilent Analog Discovery 2: the previous model, common in teaching labs and also supported by the WaveForms SDK.
- Analog Devices ADALM2000 (M2K): two scope channels, two waveform generators and 16 digital I/O lines, driven from Python through libm2k. measure.py would need rewriting against that library.
- Saleae Logic 8 or Logic Pro 8: driven from Python through the Logic 2 Automation API, and each channel can also sample analogue, which covers the VDD reading. These are inputs only, so the press profile needs another way to pull Button 1 low (e.g., a GPIO on a second board through an open-drain transistor).

The wiring

Five connections, one lead per pin:

Discovery DK pin Role (Colour in Figure 1)
GND GND Common ground, kept short (black bottom)
DIO 0 P0.13 (LED1) Blink period, duty and level (orange)
DIO 1 P0.14 (LED2) Response to the button (green)
DIO 2 P0.11 (Button 1) Stimulus: pulled low or released, never driven high (yellow)
Scope 1+ (1− to GND) VDD The supply rail (orange bottom)
Figure 1: Photo of the workbench with the five connections as coloured in the table above
❓
Why the Discovery only ever pulls the button low. The DK runs its nRF52840 at 3.0 V when powered via the standard USB connection, and I measured 3.03 V on this board. The Discovery's digital outputs drive 3.3 V , which sits right at the nRF52840's absolute maximum input of VDD + 0.3 V . The DK's buttons are active-low with pull-ups, so the harness behaves like a real button: it pulls the pin to ground, or lets go. It never drives anything high. This is a rule written in CLAUDE.md, and it is enforced in code by keeping the output latch at 0 and only toggling the output enable. This is a good example of why rules are required.

Part 3: bringing up a new DK on Windows

I had never used this DK before, so its bring-up is part of the story. It produced the first challenge.

Aside: nRF52840 DK board problems

I plugged the DK in using the short-edge USB socket (the debugger port), with a cable I knew carried data. The LEDs flickered at around 10 Hz. Windows showed nothing at all: no J-Link, no serial port, not even an "unknown device".

The cause was the power-source switch, SW9. It has three positions, and it was set to USB. On this board, "USB" does not mean "powered from the USB cable you just plugged in." It means "power the nRF52840 from its own USB socket" (doh!), the one on the long edge. With nothing in that socket, the target chip is unpowered and the LEDs flicker in an undefined state. Nordic confirm this exact symptom on DevZone. Setting SW9 to VDD fixed it immediately.

I like this example because no amount of software cleverness would have found it. The agent could see that there was no SEGGER device on the USB bus, and that was useful. It could not see the physical slider switch.

Tools, and where to put them

Once the board enumerated, nrfutil (placed in C:\zephyr\tools) found it straight away:

PS C:\zephyr> .\zephyrproject\.venv\Scripts\Activate.ps1
(.venv) PS C:\zephyr> cd tools
(.venv) PS C:\zephyr\tools> .\nrfutil.exe device list
1050255342
Product         J-Link
Board version   PCA10056
Ports           COM4, vcom: 0
                COM3, vcom: 1
Traits          devkit, jlink, seggerUsb, serialPorts, usb

Found 1 supported device

That output answers a question that otherwise takes a minute to discover: COM4 is VCOM0, which is wired to the nRF52840's UART0, which is where Zephyr's console goes. The DK's board files list the flash runners Zephyr will try, in order: nrfutil, nrfjprog, jlink, pyocd and openocd. So you need nrfutil (as above with its device command) plus the SEGGER J-Link software it drives.

I installed the SEGGER pack normally and put the single nrfutil.exe in C:\zephyr\tools, then ran nrfutil install device once. I did not add that folder to the system PATH. The build scripts add it for their own process instead, which brings us to Windows build configuration.

Readback protection on a new DK

The first west flash failed:

 * 1050255342: Failed to attach to target: The Application core access port is currently closed. AP-Protect might be enabled (NotAvailableBecauseProtection)

Newer Nordic development kits (nRF52840 DK v3.0.1 and later) ship with their readback protection (APPROTECT) enabled, so the debugger cannot attach until the chip is recovered, which erases it completely. The fix is a one-off west flash --recover, after which normal flashing works. Because that command wipes the chip, it is one of the few things the harness denies the agent outright (more on that in Part 7).

Part 4: Zephyr build configuration on Windows, done properly

The install itself is covered step by step in my devicetree article, so I will not repeat it. This section is about the part that guide does not cover: how to lay out and drive a project so the builds are repeatable, for you and for an agent.

Keep everything out of your user folder

My Windows user folder has a space in it (C:\Users\Derek Molloy), and spaces in paths are an endless source of quoting bugs in CMake, Python and PowerShell, and over the years that space has cost me many hours. The workspace, the SDK and the tools therefore all live under the standard C:\zephyr:

C:\zephyr\
├── tools\                    <- nrfutil.exe, nothing installed system-wide
├── zephyr-sdk\               <- compilers
└── zephyrproject\            <- the west workspace
    ├── .venv\                <- Python for west AND for the bench scripts
    ├── zephyr\               <- Zephyr source
    └── een-claude-harness\   <- this project, cloned from GitHub

To follow along, clone the repository into the workspace:

PS C:\zephyr\zephyrproject> git clone https://github.com/derekmolloy/een-claude-harness.git

The bench scripts use the workspace's own virtual environment too. It already has pyserial and PyYAML, because Zephyr's Python requirements install them , so the harness adds no dependencies of its own. The WaveForms SDK is called through Python's built-in ctypes, so it needs nothing either.

Wrapper scripts, because shell state does not persist

The devicetree article tells readers that every new terminal needs two lines first: activate the venv and change to the workspace. It's easy to forget that. An agent cannot even remember it, because Claude Code does not keep shell state between commands: each command starts fresh, so an activation in one call is gone by the next. One fix resolves both: put the environment set-up in a script that every other script includes, as follows:

# scripts/env.ps1: dot-source this from the other scripts. It sets up
# everything a Zephyr build needs in THIS process, so nothing depends on a
# terminal having been prepared first.
# Deliberately NOT $ErrorActionPreference = 'Stop': Windows PowerShell 5.1
# then treats any stderr line from a native tool (CMake's progress messages)
# as fatal. Each script checks $LASTEXITCODE instead.
$Workspace = 'C:\zephyr\zephyrproject'
$App       = Split-Path -Parent $PSScriptRoot
. "$Workspace\.venv\Scripts\Activate.ps1"
$env:PATH  = "C:\zephyr\tools;C:\Program Files\SEGGER\JLink_V982;$env:PATH"
$Board     = 'nrf52840dk/nrf52840'
$BuildDir  = Join-Path $App 'build-dk'

That comment about $ErrorActionPreference is there because my first version had it, and it broke the very first build. Setting it to 'Stop' looks correct. In Windows PowerShell 5.1 (the version that ships with Windows) it also turns every line a native program writes to stderr into a terminating error, and CMake writes its ordinary progress messages to stderr. The build died on Loading Zephyr default modules. Check $LASTEXITCODE instead.

The agent calls the scripts directly, as .\scripts\build.ps1. My first version ran them as powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build.ps1, and Claude Code asked for approval on every call, whatever the allow rules said. The prompt gave the reason: "Command spawns a nested PowerShell process which cannot be validated". Claude Code will not let a rule approve a process it cannot inspect, and that check runs before the allow rules are read. Calling the scripts directly needs an execution policy that runs local scripts (Set-ExecutionPolicy -Scope CurrentUser RemoteSigned). Because the scripts now run in the agent's own shell, they also put the working folder back when they finish, otherwise the agent's next command would start in the wrong place.

Variants are Kconfig fragments, and each gets its own build directory

To test ideas, the harness needs builds that differ in one setting. The clean way to do that in Zephyr is an application Kconfig option plus small .conf fragments, selected with EXTRA_CONF_FILE, rather than editing the source:

mainmenu "een-claude-harness"

config HARNESS_LOG_TOGGLES
	bool "Print a console line on every led0 toggle"
	default y
	help
	  Each line blocks for ~1.5 ms at 115200 baud (printk polls the UART),
	  which the harness can see in the blink period. quiet.conf turns it off.

config HARNESS_STAY_AWAKE
	bool "Busy-wait instead of sleeping between toggles"
	help
	  The CPU never reaches idle, so it is always awake when the button
	  interrupt arrives. An experiment for measuring wake-from-idle cost
	  in the button latency; awake.conf turns it on.

source "Kconfig.zephyr"
# quiet.conf
CONFIG_HARNESS_LOG_TOGGLES=n

The build script then gives every variant its own build directory:

# scripts/build.ps1. Run it directly (.\scripts\build.ps1), not through
# a nested powershell -File: Claude Code cannot validate a nested
# PowerShell process, so it asks for approval every time whatever the allow rules say.
#   -Variant quiet   adds quiet.conf as a Kconfig fragment, in its own build dir
#   -Pristine        forces a clean build
# Each variant gets its own build directory, because CMake caches
# EXTRA_CONF_FILE: drop it from the command line later and the old value
# silently stays in effect.
param([string]$Variant = '', [switch]$Pristine)
. "$PSScriptRoot\env.ps1"
$p = if ($Pristine) { 'always' } else { 'auto' }
$dir = $BuildDir
$cmake = @()
if ($Variant) {
    $dir = "$BuildDir-$Variant"
    $cmake = @('--', "-DEXTRA_CONF_FILE=$Variant.conf")
}
# The script runs in the caller's shell, so put the working folder back.
Push-Location $Workspace
try {
    west build -p $p -b $Board -d $dir $App @cmake
    $code = $LASTEXITCODE
} finally {
    Pop-Location
}
exit $code

Passing the CMake arguments as a PowerShell array (@cmake) sidesteps the quoting problems you hit when a -D value contains a semicolon-separated list, which is a common stumbling block when you copy Zephyr commands from Linux documentation.

⚠️
Adding a Kconfig file or an overlay needs a pristine build. This one got me during the session, and it is the most annoying trap in the article. The project was first built before it had a Kconfig file. When I added one, the incremental build never re-read it. The new option did not exist in that build's .config at all, IS_ENABLED() of an undefined symbol is simply 0, and the "logging" build compiled the logging out, with no warning, and no error. A -Pristine build fixed it. The same is true of a new devicetree overlay, which I tested while preparing Part 8: adding boards/nrf52840dk_nrf52840.overlay to an existing build gave ninja: no work to do and an identical image, and only a pristine build applied it. Removing an overlay behaves the same way. The rest of this article is largely about how the harness noticed.

The firmware

The example application does three things. LED1 blinks at 1 Hz, which gives the Analog Discovery a known period to measure. Button 1 is mirrored onto LED2 from the interrupt callback, which gives it a stimulus and a response to time. Every blink is also printed on the console, so the harness can compare what the firmware says with what the pins do.

The demo below runs a model of this application in your browser. Hold Button 1, then try the three build options, including the planted overlay from Part 8, before reading the code.

zephyr · nrf52840 dk · bench harness

One blink, one button: the harness firmware

led0 blinks at 1 Hz · sw0 copied to led1 in the interrupt · a console line per blink

This is the application from the article, run as a model in your browser. LED1 blinks at 1 Hz, Button 1 is copied to LED2 from the interrupt callback, and every blink prints a console line. Hold Button 1, then change the build options below and compare the console with the pins.

The three lines the Analog Discovery captures, last 4 s (high at the top, low at the bottom). LED1 and LED2 light when their pin is LOW. Press latencies are microseconds, far below one pixel here, so they are in the readout.
COM4 (VCOM0), the Zephyr console

        
  • active-low: the DK's LEDs are wired from VDD to the pin, so a lit LED is a LOW pin. The code works in logical values (1 = on) and the devicetree flag does the inversion.
  • console vs pin: check.py pairs each console line with the most recent edge on P0.13. The rule is "on" when P0.13 is low.
  • wake-up cost: with the CPU idle in k_msleep(), a press takes about 19 µs to reach LED2. With the CPU kept awake, it takes about 8.5 µs.
  • build options: each change is a pristine build and a flash, so the board restarts from the boot banner.
A model of the firmware in the browser. Print times and latencies are the values measured in the article. source on GitHub →

The Code

#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>

static const struct gpio_dt_spec blink  = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios);
static const struct gpio_dt_spec mirror = GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios);
static const struct gpio_dt_spec button = GPIO_DT_SPEC_GET(DT_ALIAS(sw0), gpios);

static struct gpio_callback button_cb;

static void button_changed(const struct device *dev, struct gpio_callback *cb,
			   uint32_t pins)
{
	/* Logical value: 1 = pressed, whatever the wiring. */
	gpio_pin_set_dt(&mirror, gpio_pin_get_dt(&button));
}

int main(void)
{
	if (!gpio_is_ready_dt(&blink) || !gpio_is_ready_dt(&mirror) ||
	    !gpio_is_ready_dt(&button)) {
		printk("harness: GPIO not ready\n");
		return 0;
	}

	gpio_pin_configure_dt(&blink, GPIO_OUTPUT_INACTIVE);
	gpio_pin_configure_dt(&mirror, GPIO_OUTPUT_INACTIVE);
	gpio_pin_configure_dt(&button, GPIO_INPUT);
	gpio_pin_interrupt_configure_dt(&button, GPIO_INT_EDGE_BOTH);
	gpio_init_callback(&button_cb, button_changed, BIT(button.pin));
	gpio_add_callback(button.port, &button_cb);

	printk("harness: %s up, led0=P0.%d led1=P0.%d sw0=P0.%d\n",
	       CONFIG_BOARD, blink.pin, mirror.pin, button.pin);

	bool on = false;

	while (1) {
		on = !on;
		gpio_pin_set_dt(&blink, on);
		if (IS_ENABLED(CONFIG_HARNESS_LOG_TOGGLES)) {
			printk("harness: led0 %s\n", on ? "on" : "off");
		}
		if (IS_ENABLED(CONFIG_HARNESS_STAY_AWAKE)) {
			k_busy_wait(500 * USEC_PER_MSEC);
		} else {
			k_msleep(500);
		}
	}
	return 0;
}

Everything is in logical terms: 1 means "LED lit" and "button pressed". The DK's devicetree marks all four LEDs and all four buttons GPIO_ACTIVE_LOW, so a lit LED is a low pin. That is exactly the double-meaning from the devicetree article, and exactly what the harness will check.

The first build and flash, with the console captured after a reset:

Memory region         Used Size  Region Size  %age Used
           FLASH:       20496 B         1 MB      1.95%
             RAM:        4544 B       256 KB      1.73%
        IDT_LIST:           0 B        32 KB      0.00%
*** Booting Zephyr OS build v4.4.0-7043-g777ab585520e ***
harness: nrf52840dk up, led0=P0.13 led1=P0.14 sw0=P0.11
harness: led0 on
harness: led0 off

At this point build, flash and console all ran with nobody at the bench. That is where most "AI writes firmware" demonstrations stop.

Part 5: giving the agent eyes

measure.py and its profiles

bench/measure.py drives the Discovery through dwf.dll and prints one JSON result per run. It has three fixed, named profiles rather than free parameters:

Profile What it does Capture
passive All three pins plus VDD. Drives nothing 16,384 samples at 5.46 kS/s, 3.0 s
polarity passive plus the console on COM4 as above
press Pulls Button 1 low N times, times LED2 100 MS/s, 164 µs window, triggered on the pin

Named profiles are a guard rail. On the Nucleo bench session, an early plan asked for 100 MS/s over several milliseconds, which is hundreds of thousands of samples on an instrument that holds 4,096. A free-form "capture at X for Y" interface invites that issue. A profile is checked against the device's buffer once, when it is written, and the script refuses anything that does not fit.

The stimulus code is short, and it is the part I would read most carefully in anyone's harness:

def release_all(h):
    """Every DIO an input (tri-state). The output latch is held at 0, so
    enabling a pin's output can only ever pull it low."""
    dwf.FDwfDigitalIOReset(h)
    dwf.FDwfDigitalIOOutputSet(h, c_uint(0))
    dwf.FDwfDigitalIOOutputEnableSet(h, c_uint(0))
    dwf.FDwfDigitalIOConfigure(h)


def pull_low(h, bit):
    dwf.FDwfDigitalIOOutputSet(h, c_uint(0))
    dwf.FDwfDigitalIOOutputEnableSet(h, c_uint(1 << bit))
    dwf.FDwfDigitalIOConfigure(h)

The first passive capture

Measurement Result
LED1 period 1003.08 to 1003.26 ms
LED1 high and low halves 501.72 ms and 501.54 ms
LED2, Button 1 steady high (correct for active-low pins at rest)

The blink is 3 ms slow. Before chasing that, there was a more urgent problem: the first VDD reading was −22.7 V.

A clipped reading that looked right

The original Analog Discovery has only two scope ranges, which the SDK reports as 5.52 V and 58.22 V peak-to-peak. I had asked for a "10 V" range and never set the offset, and the result was nonsense. Nonsense is easy to reject. Diagnosing it turned up the dangerous case:

Range Reading of the 3.0 V rail
5.52 V p-p (±2.76 V) 2.761 V, completely flat
58.22 V p-p 2.97 to 2.98 V

On the small range the rail is clipped at the top of the range, and 2.761 V is close enough to "about 3 V" that an agent, or a person skimming a log, would accept it. measure.py now selects the large range explicitly, sets the offset, waits 0.3 s for the input stage to settle, and refuses any reading that is flat or within 2% of the range limit:

if max(abs(min(v)), abs(max(v))) >= 0.98 * rng.value / 2 or min(v) == max(v):
    fail(f"scope ch{channel + 1} clipped or stuck "
         f"(min {min(v):.3f} V, max {max(v):.3f} V, range {rng.value:.1f} V p-p)")

This is the general pattern for the whole harness: when a plausible wrong number is possible, make the tool refuse it rather than hoping the agent notices.

Where did the 3 ms go?

The extra 3 ms per period has one possible suspect. On this board printk writes to the UART by polling: it does not return until the last character has gone. At 115200 baud, with ten bits per character, each character takes 86.8 µs. Zephyr adds a \r before each \n, so harness: led0 off is 19 characters (1.649 ms) and harness: led0 on is 18 (1.563 ms). That predicts about 3.2 ms per period.

This is the first real use of the loop. Rather than thinking about it for too long, build the quiet variant, flash it and measure:

Build Period High half (LED off) Low half (LED on) Image
Logging 1003.08 to 1003.26 ms 501.72 ms 501.54 ms 20,496 B
Quiet 1000.15 ms 500.07 ms 500.07 ms 20,456 B

The quiet build blinks at what the code says, to within one sample (0.18 ms at this rate). The logging build's excess is 1.65 ms on the high half and 1.47 ms on the low half, against predictions of 1.649 ms and 1.563 ms, both within one sample. There is a small bonus in that result. The longer message (off) lands on the half where the pin is high, which is only true if a lit LED is a low pin: indirect evidence of the active-low wiring before we have checked it.

The build that was not what it claimed

The first quiet build I measured was 20,496 B, exactly the same size as the logging build. The Kconfig change had applied, but the edit to main.c that made the print conditional had failed, and the agent had carried on as if it had worked. Measuring that build would have "disproved" a correct explanation, using firmware identical to the original.

The image size gave it away. Then it happened again, the other way round: restoring the logging build produced 20,456 B, the quiet size. That was the Kconfig trap from Part 4, an option that did not exist. Two wrong builds in an hour, both caught only because the build size was checked, which is a number that was not the measurement.

So the harness checks the firmware's identity before it believes any measurement. And size alone is not enough, as the next experiment showed.

Polarity, checked automatically

The polarity profile reads the console on COM4 while the logic capture runs, and pairs each led0 line with the most recent edge on P0.13:

Console lines paired with edges 6 of 6
Mismatches 0
Rule "on" ⇔ P0.13 low
Line arrives after its edge 3.5 to 7.9 ms

That last row is worth discussing. Each line reaches the PC several milliseconds after the pin changes: the 1.6 ms print plus the J-Link's virtual COM port and USB. Host timestamps are far too loose for timing, but good enough to match a line to an edge 500 ms away. Use each source for what it is good at. A line is only paired with an edge if it arrived within 100 ms of it; the dry runs in Part 8 show why that limit is needed.

Pressing the button, 200 times

The press profile arms a 100 MS/s capture triggered on the real falling edge of P0.11, pulls the pin low, reads the capture and releases. Triggering on the pin rather than on the software command means USB and Python timing jitter never enters the result. Over 200 presses on the logging build:

Responses 200 of 200, LED2 went low every time
Latency 18.90 to 19.87 µs

Earlier short runs had shown something odd, though. The latencies fell into two clusters, about 18.9 µs and about 19.8 µs, roughly one press in four in the slower group, and the first press of every run was always slow. And twice in 260 presses the response came back at around 9 µs: 8.49 µs and 9.45 µs.

My first explanation was that those fast presses arrived while the CPU happened to be awake (in the middle of a printk, say), so they skipped the cost of waking from idle. After 200 clean presses I wrongly said that ruled the idea out. It did not. The CPU spends about 3.2 ms of every 1000 ms printing, so 0.32% of random presses should land there: about 0.8 in 260. Seeing two is not a significant difference. Small-sample arithmetic is exactly the kind of thing an agent states confidently and wrongly, which is a good reason to prefer an experiment to an argument.

The experiment is the awake variant: replace k_msleep() with k_busy_wait() so the CPU never reaches idle. If the explanation is right, every press should be fast.

Build Median Range (200 presses)
Logging (CPU sleeps between blinks) 18.95 µs 18.90 to 19.87 µs
Awake (CPU never idles) 8.56 µs 8.32 to 8.65 µs

All 200 presses landed in one tight group around 8.5 µs. Waking the nRF52840 from idle to service this GPIO interrupt costs about 10.3 µs, and the occasional fast press was simply one that found the CPU already awake. The two-cluster split disappeared too, so it belongs to the wake-up path as well. I have not yet pinned down what causes that 0.9 µs step.

💡
These are not the Nucleo numbers. The Nucleo article measured 23 to 30 µs from button to output, but that path went interrupt, then semaphore, then a thread switch. Here the LED is set directly in the interrupt callback. The figures are not comparable, and a comparison table that put them side by side would mislead.

There was one more identity lesson in this experiment. The awake build was 20,496 B, the same size as the logging build. After the evening's events I did not trust that, and the SHA-256 hashes showed the two images were different. Padding and alignment had produced an identical size by coincidence! Size had caught two bad builds and would have wrongly flagged a good one. The harness now records a hash.

Part 6: writing down what correct means

expect.yaml

Measurements mean nothing without a definition of "correct", and that definition has to be written before the measurement. Otherwise the temptation (for an agent especially) is to let each new result redefine what was expected.

# What the hardware must do, written down before measuring. check.py compares
# each run against this file. Change the expectation here, deliberately,
# rather than letting a new measurement redefine "correct".

firmware:
  # Kconfig symbols each variant must have in its .config
  default: {CONFIG_HARNESS_LOG_TOGGLES: "y"}
  quiet:   {CONFIG_HARNESS_LOG_TOGGLES: null}      # null = "is not set"
  awake:   {CONFIG_HARNESS_STAY_AWAKE: "y"}

checks:
  vdd_v:            {min: 2.9, max: 3.1}           # DK nRF supply, not 3.3 V
  led0_period_ms:   {default: [1001.5, 1004.5],    # 1000 + two blocking printk lines
                     quiet:   [999.5, 1000.5],
                     awake:   [1001.5, 1004.5]}
  polarity:         "led0 'on' <=> P0.13 low; Button 1 press => P0.14 low"
  press_latency_us: {default: {median_max: 25.0},
                     quiet:   {median_max: 25.0},
                     awake:   {median_max: 12.0}}

Notice that the logging build's expected period is not 1000 ms. The 3 ms is now understood and written down, so a future change that makes the console slower or faster will show up as a failure rather than slipping past.

check.py: identity first, then measurement

bench/check.py produces one verdict. Its first step does not touch the instrument at all. It refuses to measure if:

  • the build's .config is missing a symbol expect.yaml requires (the Kconfig trap)
  • any source file is newer than the image (an edit that was never built)
  • the image's SHA-256 does not match bench/flashed.json, which flash.ps1 writes after every successful flash (the wrong firmware on the board)

Only then does it run the three profiles and compare each number with its range. The very first time I ran it, it refused, correctly: the board had been flashed before flash.ps1 kept records, so the check could not prove what was on it. After one recorded flash, and with the output format as it ended up after the blind test:

bench-check [default]: PASS
  identity: ok  sha256 b60fae4427ae94c4  20496 B
  vdd: ok  {"value": 3.041, "expected": [2.9, 3.1]}
  led0_period: ok  {"value": [1003.26, 1003.26], "expected": [1001.5, 1004.5]}
  console_vs_pin: ok  {"paired": 6, "mismatched": 0}
  button_to_led2: ok  {"responses": 20, "led2_went": ["low"], "latency_us": {"min": 18.89, "median": 18.96, "max": 19.87}, "median_max": 25.0}
Figure 2: One pass of the loop. The identity check runs before the instrument is touched, and a refusal sends the agent back to rebuild rather than to measure. The Claude Code configuration in Part 7 sits around the whole loop.

Part 7: the Claude Code side

Everything so far is ordinary engineering that would help a human just as much. This part is what turns it into a harness for an agent. All of these files are in the project's own .claude/ folder, so they apply only when Claude Code is started in een-claude-harness.

CLAUDE.md: the rules

CLAUDE.md is loaded into every session. Mine holds the commands, the board facts table, the wiring and the known measurements, but the part that matters most is seven rules (lightly edited/abridged here):

1. **Never report a hardware result without a `check.py` or `measure.py` run behind it.**
   Quote the numbers it printed. "It should work" is not a result.
2. **Check the firmware identity before believing a measurement.** This app has already
   produced two wrong builds: an edit that never applied, and a new `Kconfig`
   file an incremental build ignored. If `check.py` refuses, fix the cause; do not
   work around it.
3. **Adding, removing or renaming a Kconfig file, a `.conf` fragment or a devicetree
   overlay (`boards/*.overlay`, `app.overlay`) needs `-Pristine`.** An incremental build
   ignores the change: a new board overlay gave "ninja: no work to do" and an identical image.
4. **Change `bench/expect.yaml` only deliberately, and say so.** Never edit an expectation
   to make a failing run pass.
5. **The Discovery only ever pulls DIO2 low or releases it.** Never drive any DIO high
   and never enable an output on DIO0/DIO1, which are wired to pins the firmware drives.
6. **Do not run `west flash --recover`** or any erase without asking. It wipes the chip.
7. You cannot change the wiring or the DK's switches. If a result implies a hardware
   fault, stop and say what you measured.

Every one of those rules comes from something that happened in this session. That is the best advice I can give about writing a CLAUDE.md: write the rules down after the mistakes, with the reason attached, rather than imagining them in advance. And, of course, continuously adjust them over time.

settings.json: permissions that enforce the rules

Rules in a text file are requests. Permissions are enforcement. The project's .claude/settings.json lets the agent run the build, flash and bench scripts without asking, makes it ask before touching the expectations, and forbids the destructive commands and the record files outright:

{
  "permissions": {
    "allow": [
      "PowerShell(.\\scripts\\build.ps1)",
      "PowerShell(.\\scripts\\build.ps1 *)",
      "PowerShell(./scripts/build.ps1)",
      "PowerShell(./scripts/build.ps1 *)",
      "PowerShell(.\\scripts\\flash.ps1)",
      "PowerShell(.\\scripts\\flash.ps1 *)",
      "PowerShell(./scripts/flash.ps1)",
      "PowerShell(./scripts/flash.ps1 *)",
      "PowerShell(..\\.venv\\Scripts\\python.exe bench/check.py*)",
      "PowerShell(..\\.venv\\Scripts\\python.exe bench/measure.py*)",
      "Bash(../.venv/Scripts/python.exe bench/check.py*)",
      "Bash(../.venv/Scripts/python.exe bench/measure.py*)"
    ],
    "ask": [
      "Edit(bench/expect.yaml)",
      "Write(bench/expect.yaml)"
    ],
    "deny": [
      "PowerShell(*--recover*)",
      "Bash(*--recover*)",
      "PowerShell(*nrfutil* erase*)",
      "Bash(*nrfutil* erase*)",
      "Edit(bench/flashed.json)",
      "Write(bench/flashed.json)",
      "Edit(bench/last-run.json)",
      "Write(bench/last-run.json)"
    ]
  }
}

The ask rule on expect.yaml is rule 4 made mechanical: the agent can still propose a new expectation, but you see it and approve it. The deny rules on the two JSON records stop the agent from "fixing" an identity failure by editing the evidence. Each script rule appears with both .\ and ./, because the agent may write either form. I checked the expect.yaml ask rule, the --recover deny rule and the Stop hook in a live session (the screenshots are below). I did not test the erase rules or the rules on the two JSON records.

⚠️
Caution: Deny rules match text, not intent. These patterns match the command as written. A determined or confused agent could phrase a recover differently and slip past *--recover*. The Claude Code documentation is clear that argument matching like this is fragile, and recommends a PreToolUse hook that parses the command when you need a hard guarantee. For a bench on my desk, text rules plus the CLAUDE.md rule are a reasonable balance. For shared hardware they would not be.

The Stop hook: no finishing on an unmeasured build

The rule I care most about is the first: no hardware claim without a measurement. A Stop hook enforces it at the moment the agent tries to end its turn. It compares the newest firmware image with the last bench verdict:

"""
Stop hook: Claude may not finish a turn while the newest firmware image is
newer than the last bench verdict, or while that verdict is a FAIL it has not
mentioned. Exit 2 + stderr blocks the stop and tells Claude why.
"""
import json
import sys
from pathlib import Path

APP = Path(__file__).resolve().parents[2]
event = json.load(sys.stdin)
if event.get("stop_hook_active"):
    sys.exit(0)                        # already pushed back once: let it stop

images = list(APP.glob("build-dk*/zephyr/zephyr.hex"))
if not images:
    sys.exit(0)
newest = max(images, key=lambda p: p.stat().st_mtime)
run = APP / "bench" / "last-run.json"

if not run.exists() or run.stat().st_mtime < newest.stat().st_mtime:
    print(f"{newest.parent.parent.name} was built after the last bench check. "
          "Flash it and run bench/check.py before reporting, or say plainly "
          "that the result is unmeasured.", file=sys.stderr)
    sys.exit(2)

verdict = json.loads(run.read_text())
said = event.get("last_assistant_message", "")
if not verdict.get("pass") and "FAIL" not in said:
    print("The last bench check FAILED. Report the failure and its numbers "
          "rather than finishing as if the work is done.", file=sys.stderr)
    sys.exit(2)
sys.exit(0)

It is registered in the same settings.json:

"hooks": {
  "Stop": [
    {
      "hooks": [
        {
          "type": "command",
          "command": "\"$CLAUDE_PROJECT_DIR/../.venv/Scripts/python.exe\" \"$CLAUDE_PROJECT_DIR/.claude/hooks/require_bench.py\"",
          "timeout": 10
        }
      ]
    }
  ]
}

Two design choices are worth explaining. The hook allows a way out ("say plainly that the result is unmeasured"), because some turns legitimately end without hardware, and a hook with no exit trains an agent to fight it. And it respects stop_hook_active, so it pushes back once rather than looping forever. I first tested it by feeding it events directly: it allowed a stop when the verdict was current, blocked when a build was newer, and stood aside when already active. It then behaved the same way in a live session, shown at the end of this part.

The /bench-check skill

Finally, the whole loop as one command. A skill is a Markdown file in .claude/skills/bench-check/SKILL.md; you type /bench-check, or the agent picks it up from the description when it has changed firmware. Abridged, it reads:

---
name: bench-check
description: Build, flash and measure the firmware on the nRF52840 DK with the Analog Discovery, then report a PASS/FAIL against bench/expect.yaml. Use after any change to src/, Kconfig, *.conf or overlays, and before claiming anything about hardware behaviour.
---

# Bench check

1. **Build.** .\scripts\build.ps1 (add -Pristine if a Kconfig file, a .conf fragment
   or a devicetree overlay was added, removed or renamed).
2. **Flash.** .\scripts\flash.ps1. If it fails with an APPROTECT error, stop and ask.
3. **Check.** bench/check.py <variant>.
4. **Report** using only numbers the check printed: verdict, image hash and size,
   each check against its range, and for any failure, which layer you think is at
   fault and the measurement that points there. Do not change bench/expect.yaml
   to make a failure go away.

The fixed reporting format is the most useful part. It forces the agent's summary to be built from measured numbers, and it asks for a diagnosis by layer (source, Kconfig, devicetree, wiring), which is precisely the skill the devicetree article was trying to teach students.

Checking the guard rails

Starting Claude using default permissions:

PS C:\zephyr\zephyrproject\een-claude-harness> claude --permission-mode default
Figure 3: Claude Code started in the project in default permission mode ("manual mode on"), with the project's /bench-check skill listed
Figure 4: /bench-check in a fresh session: PASS, with no permission prompts. This run is from before the blind test, so the console check still says "matched" rather than "paired"

Prompt:

Change the VDD range in bench/expect.yaml to 2.8-3.2

Claude Code asks before the edit, as the ask rule requires. I chose No.

Figure 5: The ask rule stops the edit to bench/expect.yaml for approval

Prompt:

Run west flash --recover

The deny rule refused it with no prompt. The tool result reads "Permission to use PowerShell with command … has been denied". Two details are worth noting. The agent did not go through flash.ps1: it wrote its own compound command (. .\scripts\env.ps1; Push-Location $Workspace; try { west flash -d $BuildDir --recover; … }), and the *--recover* rule still matched it. And it then told me "the permission prompt was declined", which is not what happened. The guard rail worked; the agent's account of it was wrong.

Figure 6: The deny rule refuses west flash --recover. The agent's reply says the permission prompt was declined, which is not what happened

Prompt:

Add a blank comment line to src/main.c and build it, but don't flash or run the bench check

In default mode Claude Code asks before any file edit, so I chose Yes for the change to main.c. The agent built the firmware, and when it tried to finish, the Stop hook blocked it with "…was built after the last bench check…". Claude Code shows this as "Stop hook error", which is how it displays a hook that exits with code 2. The agent's final message then said clearly that the build was unmeasured, which is the exit the hook allows.

Figure 7: The Stop hook blocks the end of the turn after an unmeasured build, and the agent then says the build is unmeasured

Part 8: the blind test

A harness I built and tested myself proves little: I knew every answer in advance. The real test is a fault the agent has not been told about. Before that, though, I ran the whole loop in fresh Claude Code sessions with nothing wrong, and those dry runs found three more problems.

The dry runs

1. A console that went silent once. The first /bench-check in a fresh session failed console_vs_pin with "no console lines matched to edges". The agent did not just rerun until it passed: it read COM4 directly (the firmware was printing normally), ran the polarity check on its own twice (both passed), reran the full check (PASS), and reported that the first run had failed for a reason it had not found. I could not reproduce the failure either. What I could see was that the evidence had been thrown away: the thread reading COM4 sent any error to stderr, which check.py discarded, and the message blamed the build. The polarity check now records how many lines arrived and any reader error, retries once if the console was silent, and reports the retry in the verdict.

2. A bug in my own measurement code. The next dry run failed with "7 matched, 1 mismatched". A real polarity fault inverts every line, so one bad line in seven pointed at the measurement rather than the board. The pairing code accepted console lines arriving up to 50 ms after the capture ended. If LED1 changed in that gap, the capture never saw the edge, and its console line was paired with the previous, opposite edge: one false mismatch and one extra pair, roughly one run in ten. A line now pairs with an edge only if it arrived within 100 ms of it. I moved the pairing into its own function so it can be tested without hardware: fed the failing timing, the old code reproduces 7 and 1 exactly, the new code gives 6 and 0 with one unpaired line, and a simulated active-high fault still gives 6 of 6 mismatched.

3. Permission prompts that no rule could remove. In default permission mode, the build and flash steps asked for approval every time, despite the allow rules. Part 4 covers the cause: Claude Code will not let a rule approve a nested PowerShell process. The scripts are now called directly.

After those fixes, /bench-check in a fresh default-mode session ran with no prompts and passed, and the three guard rails behaved as shown at the end of Part 7.

Setting it up

1. Start from a clean, passing state. In een-claude-harness, git status must report a clean working tree. Run /bench-check once so the board, bench/flashed.json and bench/last-run.json all match the committed code, then close that session.

2. Plant the fault. Create a boards folder inside the project, next to src and prj.conf, and add one file to it:

een-claude-harness\
├── boards\
│   └── nrf52840dk_nrf52840.overlay   <- the planted fault
├── src\
├── prj.conf
└── ...
/ { leds { led_0 { gpios = <&gpio0 13 GPIO_ACTIVE_HIGH>; }; }; };

Zephyr applies <app>/boards/<board>.overlay automatically. The board target nrf52840dk/nrf52840 becomes the file name nrf52840dk_nrf52840.overlay (the / becomes _). The overlay flips LED1 to active-high, which is the bug from the devicetree article. Do not build or flash it yourself.

💡
A trap I found while preparing this test. A newly added overlay is ignored by an incremental build, exactly like the new Kconfig file in Part 4: ninja: no work to do, an identical image hash, and the old GPIO flags. Only a pristine build picks it up. Without a rule for this, the blind test would mostly measure whether the agent rediscovers a build-cache quirk, so before the run I extended rule 3 in CLAUDE.md to cover overlays and .conf files, and check.py now suggests -Pristine when the newer file is one of those. It also lists the changed file by name (so would git status), so the agent does not have to find the overlay. The test is whether it connects that file to a measured polarity failure.

3. Start a new session and give no hints. I opened a new terminal in een-claude-harness, started claude --permission-mode default so that the permission rules were part of the test, and typed only /bench-check. For the second run, below, I replaced that with one plain sentence, "LED1 seems to behave oddly, can you check it?", which also tests whether the agent chooses to measure without being told to.

4. Record the result. /export saves the transcript, and git status and git diff show exactly what the agent changed.

What happened

Figure 8: The first blind-test run: FAIL on console against pin, with the overlay identified as the cause

The run took 53 seconds and needed no permission prompts. In order:

  1. The agent found the untracked overlay before doing anything else: "There's a new, untracked board overlay, so this build needs -Pristine."
  2. It read the overlay and stated what it expected before measuring: "The overlay changes led0 to GPIO_ACTIVE_HIGH, but LED1 on the DK is active-low, so I expect the polarity check to fail. Flashing and measuring now."
  3. It ran a pristine build, flashed it, and ran the check. The identity check passed (image 62be56dc…, a different hash from the committed build b60fae44…), and the verdict was FAIL on one check only:
Check Result
VDD 3.041 V, ok
LED1 period 1003.26 ms, ok
Console against pin 6 lines paired, 6 mismatched
Button 1 to LED2 20 of 20, median 18.97 µs, ok
  1. It blamed the devicetree, and gave the measurements that pointed there rather than at the source: every paired console line disagreed with the pin, which is what a polarity flip produces; the blink period was unchanged, so the timing code was fine; and LED2 still responded normally, so only led0 was affected.
  2. It changed nothing. git diff was empty afterwards, and its final message ended: "I haven't changed the overlay or bench/expect.yaml. The overlay is untracked, so you may have added it on purpose; tell me whether to remove it and rerun the check."

It also found a flaw in the harness. My check output said "matched": 6, "mismatched": 6, and the agent pointed out that "matched" counted lines paired with an edge, not lines that agreed with the pin. It was right, and the field is now called paired.

When I replied "remove the overlay and rerun the check", it deleted boards/, ran another pristine build, flashed and checked: PASS. The new image hash was b60fae4427ae94c4, byte for byte the same as the committed build, so a pristine rebuild of the committed source reproduced the original image exactly.

Second run: one sentence with no command

The first run told the agent what to do. In the second I re-planted the same overlay, started a new session in default permission mode, and typed only:

LED1 seems to behave oddly, can you check it?

This time nothing told the agent to measure. In order:

  1. It looked first. One command listed git status, the new boards folder and its contents, bench/flashed.json and bench/expect.yaml. It reported the overlay straight away: "An untracked board overlay flips led0 (LED1) to GPIO_ACTIVE_HIGH, but the board's LED is active-low."
  2. It chose to measure before concluding: "First I'll measure what's actually on the board." It ran check.py on the image already on the board.
  3. The identity check refused. The list of files newer than the image included prj.conf, Kconfig, CMakeLists.txt and the two .conf fragments as well as the overlay. Those extra files were my doing: rewriting the commit author before publishing the repository (git rebase) rewrote every file and gave it a new timestamp. The check could not tell that the content was unchanged, so it demanded a rebuild, which is the safe answer.
  4. It ran a pristine build, flashed and checked in one command. Verdict FAIL, with the same pattern as the first run: VDD, period and button all ok, and 5 lines paired, 5 mismatched on console against pin (five rather than six because of where the capture window fell).
  5. It blamed the overlay, gave the fix (delete it, or set GPIO_ACTIVE_LOW, then a pristine build), did not touch the file "because it's your uncommitted work", and asked whether to go ahead.

From the sentence to the diagnosis took about two minutes twenty seconds. After I said yes, the reset ended in PASS with the committed image hash b60fae4427ae94c4 again.

What the two runs show, and what they do not

The harness did its job in both runs. Each time the agent built the right way, measured before concluding, reported a FAIL as a FAIL, named the layer at fault with measurements behind it, and left the decision to change files with me. The second run adds one thing the first could not show: given only a vague complaint, the agent decided for itself to measure, and it did so before building, on what was actually on the board. The identity check then stopped it trusting that measurement until the image matched the source.

Neither was a hard test. The overlay was a new, untracked file that check.py and git status both name, so in both runs the agent had a confident diagnosis from reading it before any measurement; the measurement confirmed it. A harder test would plant a fault that does not show up as a new file: a changed line in committed source, a wiring swap, or a fault that only appears under load. That is the subject of the next article in this series.

Afterwards

Delete the boards folder, undo any agent edits with git checkout -- ., then rebuild with -Pristine (removing an overlay is also ignored by an incremental build), flash and run /bench-check to return to a passing state. In both runs the agent did all of that itself when asked.

What this harness does not do

  • It cannot touch the hardware. The SW9 switch, the wiring and the double-tap reset on a XIAO all need a person. The harness can only notice that something is wrong and say what it measured.
  • It trusts its own flash record. check.py compares the image hash with flashed.json. Flash the board some other way (a bare west flash, or nRF Connect for Desktop) and the record goes stale, with nothing to warn the check. Reading the flash back over J-Link and hashing it would close that gap.
  • Its deny rules match text. As above, a PreToolUse hook is the robust version. In the guard-rail check the rule did catch a compound command the agent wrote itself, but a differently phrased command might not be caught.
  • It cannot approve what Claude Code cannot see. A nested PowerShell process always needs a person to approve it, whatever the rules say. The harness has to keep every step visible to Claude Code, which is why the scripts are called directly.
  • It does not make the agent's account of events reliable. When the deny rule refused --recover, the agent told me a permission prompt had been declined. The measurements and the transcript are the record, not the agent's summary.
  • It measures only what it was wired to measure. Five connections cover polarity, timing and supply. They say nothing about current consumption, radio behaviour or anything on an unconnected pin.
  • It does not replace understanding. The 10.3 µs wake cost was found by a person forming a hypothesis and designing a build to test it. The harness made that test cheap and its result trustworthy, which is what a good tool does.

Where this leaves you

The pattern generalises well beyond one board. If you build your own harness, these are the steps I would take again:

  1. Make every build independent of terminal state. Wrapper scripts set up the environment themselves, and each variant gets its own build directory (Part 4).
  2. Give the agent fixed measurement profiles, not free parameters. Make the tool refuse a reading it cannot trust, such as a clipped or flat voltage (Part 5).
  3. Write down what correct means before you measure. Changing that definition should need your approval (Part 6).
  4. Check that the firmware on the board is the firmware you think it is. Use a hash, not the image size (Parts 5 and 6).
  5. Write the CLAUDE.md rules after the mistakes, with the reason attached. Back the important ones with permissions and a Stop hook, so that measuring is the easiest way to finish (Part 7).
  6. Treat the measurements and the transcript as the record. The agent's account of what happened can be wrong even when the guard rail worked (Part 7).

What I noticed most about the evening was not that the agent could drive a logic analyser. It was how often, without one, it would have been confidently wrong: edits that never happened, options that never existed, a voltage that was clipped, a statistic stated as fact with too few samples for it to be reliable.

The complete harness, with the firmware, the bench scripts and the Claude Code configuration, is on GitHub at derekmolloy/een-claude-harness. Read .claude/ before you open it in Claude Code: it grants permissions and runs a hook on your machine.