From XiaoZhi activation to local Hermes control: StackChan Unit #4

Created Last updated
By James Meadlock and Milo

A month ago I started working on four little StackChan desk robots. Then I wiped the firmware on the spare, forgot most of what I had learned, and came back to a device speaking Chinese and asking to be activated.

It was not bricked. It was waiting for the wrong brain.

Current result: the spare now runs a reproducible local-only firmware build and authenticates to a pinned StackChan gateway under Milo's Hermes profile.

Contents

  1. The starting point
  2. The architecture we wanted
  3. Why we used gates
  4. The actual bug
  5. The local-only repair
  6. What we proved
  7. What remains locked

The starting point

The factory path expected a XiaoZhi-backed cloud identity. I did not want to create another assistant, another profile, or another memory plane. Hermes already has those jobs. StackChan should be a face and a body attached to the same brain, not a separate cloud character sitting beside it.

We chose the fourth unit as the sacrificial spare. Units #1 through #3 stayed sealed on factory firmware. Before writing anything, we identified the USB device, saved an official factory rollback image, pinned a matched gateway/firmware pair, and documented how to recover the board.

StackChan Unit 4 on a workbench showing the Chinese network setup screen and the local captive-portal address.
After the first custom flash, the screen read 配网模式, or “network setup mode,” and exposed the firmware's local captive portal. The supplied factory-activation photo is not published because it contains a pairing code. Photo: James Meadlock.

The architecture we wanted

The path is deliberately small. Hermes talks to one pinned MCP gateway. The gateway authenticates one spare robot over the LAN. Hermes sees only six read-only tools. The cloud activation path is absent.

Diagram showing Hermes Milo on a Mac Studio connecting through stackchan-mcp to StackChan Unit 4 over an authenticated local WebSocket. Cloud identity, physical tools, and production units remain outside the active path.
Current local-only architecture. The editable source is available as SVG.
Gatewaystackchan-mcp 0.17.0, pinned and launched through a per-unit wrapper.
Firmware basisTagged firmware 1.16.0 with one board-scoped local-startup option.
Hermes boundaryRegistered only in Milo's profile. The sibling/default profile has no StackChan MCP entry.
Robot boundaryUnit #4 only. The other three units were not flashed, paired, or tested.

Why we used gates

A robot combines several different risk classes in one cheerful package: firmware writes, microphones, cameras, servos, LEDs, persistent credentials, and a network service. A generic “go” is too broad. We split the work into checkpoints and required a fresh approval whenever the next checkpoint changed the risk.

Qualification timeline showing R0 through R4 complete, R5 physical tests locked, and later voice, soak, and proposal work not started.
R0 through R4 were complete at this snapshot. R5a has since passed; R5b–R5e remain locked. Editable SVG.
GateResult
R0Researched hardware, recovery paths, firmware, and MCP options.
R1Prepared pinned software and collision-free local ports without connecting a robot.
R2Identified Unit #4 over USB and proved a factory rollback artifact existed before any write.
R3Flashed the tagged firmware, provisioned the LAN, and stored the local gateway configuration.
R3bRepaired a startup-order problem with a narrow local-only build and an app-only flash.
R4Proved authenticated local WebSocket access and six read-only Hermes MCP calls.
R5aLater qualified the custom avatar, brightness, and one static base-LED value, then restored idle state.

The actual bug

The first R4 test looked promising. Hermes could start the MCP server, and the gateway could listen for the robot. But the robot never established its WebSocket connection.

The easy story was “Wi-Fi is broken.” It was wrong. Serial logs showed the spare joining the LAN correctly. Source inspection showed what happened next: tagged firmware 1.16.0 called CheckNewVersion() before InitializeProtocol(). The device entered a Tenclass activation loop and never reached the code that starts its local protocol. The saved gateway URL and token were fine. Firmware control flow made them irrelevant.

Before-and-after diagram showing tagged firmware entering a cloud activation loop before protocol initialization, and the repaired local-only build bypassing that gate and establishing an authenticated WebSocket.
The decisive change was boot order. We did not replace the networking stack. Editable SVG.
A useful failure: our first verification client returned exit code 0 even when device calls failed. That proved only that the MCP process started. We replaced it with a fail-closed verifier that rejects tool errors and empty payloads.

The local-only repair

R3b added an explicit STACKCHAN_LOCAL_ONLY build option for the StackChan board. In that mode, the firmware applies its local assets, skips the cloud OTA/activation gate, then starts the local protocol. Other boards keep the tagged behavior unless they opt in.

We built the image with the upstream board-aware release script in ESP-IDF 5.5.2. The repair was flashed only to the application partition at 0x20000. That preserved NVS, so the spare kept its Wi-Fi credentials, local gateway URL, and device token.

What the flash did not touch: the bootloader, partition table, OTA data, asset partition, and NVS. It also did not authorize any physical capability test.

What we proved

After reboot, the serial log showed the local-only marker, the saved local gateway, a successful authenticated WebSocket handshake, and no Tenclass request. The gateway announced 40 firmware capabilities, but Hermes was allowed to see only the six reads we had approved.

Allowlisted toolWhat it proves
get_statusThe device session is alive and returns structured status.
get_device_infoThe gateway is talking to the intended hardware and firmware.
get_head_anglesCurrent pose can be read without commanding movement.
get_touch_stateTouch state can be observed without changing configuration.
get_touch_sensor_enabledThe persistent touch-enable state can be inspected.
gateway_config_getThe local gateway configuration can be checked without returning the token value.
R4 passed: all six calls returned valid payloads under the strict verifier. Parallel calls remain disabled, and the 49-tool bulk-enable prompt was rejected.

One caveat surfaced during the R4 proof. Gateway 0.17.0 automatically rendered its built-in idle avatar after device discovery. We did not call a display tool, but the screen changed because that behavior is hard-coded in the gateway. Removing it would require a separate gateway patch and review.

What remains locked

R5 is not one switch. R5a display and base LEDs have now passed; touch configuration, controlled head movement, one intentional device-camera frame, and audio remain separate approvals with their own evidence, cleanup, and rollback state.

Only after those tests would we consider voice integration, a seven-day soak on the spare, and a proposal for Unit #1. A successful spare test will never become a fleet cutover by accident.