Build a Robot That Feels a Lock

Build a force-sensing ESP32 robot with supervised Claude control.

Introduction

30 Second Summary

Your fingers notice resistance before your eyes can explain it. A robot needs to turn changing numbers into a safe next move.

In this project, you will use Claude Code to build an ESP32 force probe plus a Mac app. The app turns load cell readings into Claude decisions while firmware keeps every movement inside local force limits.

What You'll Build

You will watch a blunt probe meet one visible practice-lock pin as your terminal draws the force trace and records each constrained Claude decision.

By the end of this project, you'll have:

  • A calibrated touch rig where the terminal settles near 100 g under the 100 g calibration weight.
  • A live force display that responds to pressure while monitor-only mode keeps the servo parked.
  • A supervised closed loop where Claude chooses PUSH, HOLD, or BACKOFF while local safety logic controls the final movement.
  • Secret Mission: Hide the clear lock from view and identify first contact from the force signature alone.

Are There Any Prerequisites?

You need a Mac running macOS 13 or newer plus every hardware item listed for this project. Your Mac must share a 2.4 GHz Wi-Fi network with the ESP32.

You also need Anthropic billing or credits plus a clear practice lock that you own or have explicit permission to manipulate.

Before We Start

This opening checkpoint sets the legal boundary for every hands-on action that follows. You will commit to a one-pin tactile experiment with a clear practice lock that you own or have explicit permission to manipulate.

Set Up the Mac and Development Tools

A safe touch rig depends on a Mac that can reveal each layer of the toolchain. A missing runtime can make healthy hardware look broken.

This step makes every dependency observable before hardware work begins. The servo power stays off while you prepare the development environment.

In this step, get ready to:
  • Confirm your Mac meets the requirement.
  • Inventory every component before assembly.
  • Prepare the Arduino tools plus the local Python workspace.
Confirm your Mac and hardware

Claude Code requires macOS 13.0 or newer. Checking this first prevents an unsupported operating system from hiding behind later installation errors.

  • Click the Apple menu in the upper-left corner.
  • Select About This Mac.
  • Confirm the displayed macOS version is 13 or newer.

Keep Servo Power Off

The switched battery holder is the physical stop for servo motion. It stays off throughout this setup step.

  • Place the Seeed Studio XIAO ESP32-C3 on your workspace.
  • Place the Adafruit HX711 board beside it.
  • Place the 1 kg four-wire load cell beside the HX711.
  • Place the SG92R servo on your workspace.
  • Locate the switched 3xAA JST battery holder.
  • Move the battery holder switch to its off position.

Why Inventory First?

The next step connects every component into one rig. A complete inventory prevents a missing adapter or data cable from interrupting the wiring process.

  • Set aside the JST-PH adapter for the battery holder.
  • Set aside the clear authorized practice lock from your earlier commitment.
  • Set aside the 100 g calibration weight.
  • Set aside enough jumper leads for the sensor connections.
  • Set aside a data-capable USB-C cable.
Prepare Arduino IDE for the XIAO

Arduino IDE compiles the firmware that runs on the XIAO. Its board package supplies ESP32 support.

The HX711 library gives the firmware a supported interface for reading the load-cell converter. Pinning each version keeps the later sketch aligned with the APIs in this project.

  • Press Cmd+Space to open Spotlight.
  • Type Arduino IDE.
  • Press Enter if Arduino IDE appears.
  • Select the Arduino IDE menu if the app opens.
  • Select About Arduino IDE to read the installed version.

✔️ I see version 2.3.10

Arduino IDE is already on the required version. Keep the app open for the board setup below.

ⓧ I see an older version

The installed release does not match the project pin. Install 2.3.10 before adding board support.

  • Visit the official Arduino software page.
  • Download Arduino IDE 2.3.10 for macOS.
  • Quit the currently open Arduino IDE.
  • Run the downloaded installer.
  • Press Cmd+Space after installation finishes.
  • Type Arduino IDE.
  • Press Enter to launch the new version.

ⓧ Arduino IDE is not installed

Install the pinned release from Arduino's official macOS download.

  • Visit the official Arduino software page.
  • Download Arduino IDE 2.3.10 for macOS.
  • Run the downloaded installer.
  • Press Cmd+Space after installation finishes.
  • Type Arduino IDE.
  • Press Enter to launch Arduino IDE.

The board package tells Arduino IDE how to compile for the XIAO. The package index URL makes the Espressif platform available through Boards Manager.

  • Click the Arduino IDE menu.
  • Select Settings.
  • Find Additional Boards Manager URLs near the bottom.
  • Open the URL list using the button beside the field.
  • Paste https://espressif.github.io/arduino-esp32/package_esp32_index.json on its own line.
  • Click OK to close the URL list.
  • Click OK to save the settings.

What Did That URL Add?

Arduino IDE uses the package index to discover Espressif board definitions. The pinned package supplies the compiler support needed by XIAO_ESP32C3.

  • Open Boards Manager from the left sidebar.
  • Search for esp32 by Espressif Systems.
  • Select version 3.3.12.
  • Click Install.
  • Wait for the package installation to finish.
  • Use the board selector to choose XIAO_ESP32C3.

Why Pin the Board Package?

The firmware uses Arduino-ESP32 APIs from version 3.3.12. Selecting the same release keeps compilation behavior predictable.

  • Open Library Manager from the left sidebar.
  • Search for Adafruit HX711.
  • Select version 1.0.2.
  • Click Install.
  • Wait for the library installation to finish.

Package or Library Missing?

If the ESP32 package is missing, reopen Additional Boards Manager URLs. Check that the Espressif URL has no spaces.

If the HX711 library is missing, clear the Library Manager search. Search for Adafruit HX711 again.

Help me diagnose my Arduino IDE package installation.

Build the local Python workspace

Python runs the Mac-side controller in later steps. A virtual environment keeps the pinned Anthropic package inside this project.

Claude Code helps create the project files from the supplied code. The Anthropic API key remains outside those files.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal.
  • Press Enter to open Terminal.
  • Check the active Python version by running this command:
python3 --version

What Does This Check?

The command prints the Python interpreter selected by your shell. This project uses version 3.15.2.

✔️ I see version 3.15.2

Your shell already points to the required Python release. Continue to the workspace commands below.

ⓧ I see an older version

macOS can retain an older system-managed Python. The official installer adds Python 3.15.2 alongside it.

  • Visit the official Python macOS downloads page.
  • Download the macOS installer for Python 3.15.2.
  • Run the downloaded installer package.
  • Keep the default installer components selected.
  • Run Install Certificates.command from the installed Python 3.15 folder in Applications.
  • Close the temporary Terminal window after the certificate installation completes.
  • Open a fresh Terminal window through Spotlight.

ⓧ Command not found

Python is not available through your shell yet. Install the official macOS release before creating the project environment.

  • Visit the official Python macOS downloads page.
  • Download the macOS installer for Python 3.15.2.
  • Run the downloaded installer package.
  • Keep the default installer components selected.
  • Run Install Certificates.command from the installed Python 3.15 folder in Applications.
  • Close the temporary Terminal window after the certificate installation completes.
  • Open a fresh Terminal window through Spotlight.
  • Confirm the installed Python version by running the check again:
python3 --version

What Should You See?

Terminal should print Python 3.15.2. This confirms the shell uses the interpreter required by the project.

  • Create the project directories plus the isolated environment by running these commands:
mkdir -p ~/touch-robot/firmware/TouchRobot
cd ~/touch-robot
python3 -m venv .venv
source .venv/bin/activate
python -m pip install anthropic==2.4.0

What Do These Commands Create?

  • The mkdir command creates ~/touch-robot/firmware/TouchRobot for the firmware sketch.
  • The .venv directory isolates project packages from the rest of your Mac.
  • The activation command makes this environment active in the current Terminal session.
  • The final command installs anthropic 2.4.0 into that environment.
  • Confirm the Terminal prompt begins with (.venv).
  • Check that the package installation reports success for anthropic 2.4.0.

Environment Creation Failed?

If environment creation fails, confirm the preceding version check printed Python 3.15.2. Run the workspace commands again from a fresh Terminal window.

If the package installation fails, confirm the Mac has internet access. Keep (.venv) visible before retrying.

Help me fix my touch-robot virtual environment.

  • Check whether Claude Code is already available by running:
claude --version

What Does This Check?

The command asks the native Claude Code installation for its version. Any installed version number confirms that the command is available to your shell.

✔️ I see a version number

Claude Code is installed. Continue to authentication below.

ⓧ Command not found

Install the native Claude Code command using Anthropic's official installer.

  • Install Claude Code by running this command:
curl -fsSL https://claude.ai/install.sh | bash

What Does This Command Do?

The command downloads the official native installer over HTTPS. The installer adds Claude Code to your Mac.

  • Open a fresh Terminal window after the installer finishes.
  • Confirm Claude Code is available by running:
claude --version

What Should You See?

Terminal should print the installed Claude Code version. This confirms your shell can find the command.

Still Seeing Command Not Found?

Close every Terminal window after the installer finishes. Open a new Terminal window so the shell can load the updated command path.

Help me find Claude Code after installation.

The first Claude Code launch starts authentication with an eligible Anthropic account. Your browser may open so you can approve the login.

  • Start Claude Code authentication by running:
claude

What Happens During Login?

Claude Code starts its sign-in flow. Complete the browser approval if one opens.

  • Complete the Anthropic account login in your browser.
  • Return to Terminal after authentication succeeds.
  • Exit the Claude Code session after the authenticated prompt loads.

Authentication Did Not Finish?

Confirm the Mac has internet access. Restart the claude command if the browser approval expired.

Help me complete Claude Code authentication.

The next setup touches credentials plus metered billing. Your personal API key stays outside the project files.

Protect Your API Key

Claude API usage is metered by tokens. Billing or credits must be enabled before the supervised runtime test.

Store the key in a password manager after creation. Keep it out of screenshots plus source files.

  • Visit the official Anthropic API key guide.
  • Sign in to the Claude Console.
  • Create a personal API key for the later runtime test.
  • Store the personal API key in a password manager.
  • Confirm billing or credits are enabled for the account.
  • Keep ANTHROPIC_API_KEY unset during this step.

Use this comparison to confirm the local artifacts match the required setup.

✔️ Awesome, I've got everything!

Your project directories plus the activated environment are ready. Continue to the final version checks.

ⓧ I'd like to double check the full code

mkdir -p ~/touch-robot/firmware/TouchRobot
cd ~/touch-robot
python3 -m venv .venv
source .venv/bin/activate
python -m pip install anthropic==2.4.0
curl -fsSL https://claude.ai/install.sh | bash
claude --version

What Should Match?

This is the complete terminal setup sequence from this step. Compare each line with the commands you ran.

  • Python virtual environment created with anthropic==2.4.0 installed at ~/touch-robot/.venv.
  • Project firmware directory created at ~/touch-robot/firmware/TouchRobot.

Before the final check, what version do you expect Python to report?

  • Verify Python by running:
python3 --version

Python Check

You should see Python 3.15.2. The required interpreter is now active on your Mac.

Before the next command, what do you expect Claude Code to print?

  • Verify Claude Code by running:
claude --version

Claude Code Check

You should see the installed Claude Code version. This confirms the command is available after authentication.

  • Switch back to Arduino IDE from earlier.
  • Select the Arduino IDE menu.
  • Select About Arduino IDE.
  • Confirm the displayed version is 2.3.10.
  • Close the version window.
  • Confirm the board selector shows XIAO_ESP32C3.

That is the setup layer proven: your hardware is staged while every required development tool is ready. Next, you will assemble the one-axis rig plus calibrate its force reading.

Build and Calibrate the Touch Rig

Your Mac toolchain is ready. The next goal is to give the robot a physical sense of touch through a mounted force sensor.

Raw HX711 counts have no physical meaning until the load cell is mounted in its working direction. Calibration turns those counts into force values that you can read in grams.

In this step, get ready to:
  • Mount the load cell with the servo and blunt probe on its free end.
  • Wire the force sensor plus a separately powered servo to the XIAO ESP32-C3.
  • Upload the safety firmware and calibrate the sensor with a 100 g weight.
Mount the One-Axis Rig

The load cell measures force through a small bend in its metal body. Fixing one end gives the free end room to respond when the probe meets resistance.

Keep Servo Power Off

Mounting puts your hands close to the servo horn. Keep the switched battery holder off so the probe cannot move while you position the hardware.

The battery switch is your physical stop throughout this project. Switch it off before moving the probe or lock.

  • Keep the switched 3xAA battery holder turned off.
  • Fix one end of the 1 kg load cell to a rigid base with clamps or zip ties.
  • Secure the SG92R servo to the free end of the load cell.
  • Attach the blunt probe to the servo horn.
  • Position the clear authorized practice lock near the probe.
  • Align the probe with one visible pin without inserting the probe into the lock.
  • Press the free end gently in the same direction that the visible pin will push back.

Good progress. The free end now bends in the same direction that you will use for calibration and contact sensing.

Wire the Sensor and Servo Safely

The HX711 converts the load cell's small electrical change into a digital reading. A common ground gives the separately powered servo and XIAO ESP32-C3 the same voltage reference.

Separate the Servo Supply

The servo receives positive power only from the switched 3xAA holder through the JST-PH adapter. Never connect its positive supply lead to a GPIO pin.

Keep the battery holder switched off during wiring. Leave it off during firmware upload and calibration too.

  • Connect the load cell's red wire to E+ on the HX711.
  • Connect the load cell's black wire to E- on the HX711.
  • Connect the load cell's green wire to A+ on the HX711.
  • Connect the load cell's white wire to A- on the HX711.
  • Inspect the four HX711 load-cell terminals against the color labels before continuing.

You should now see one load-cell wire secured in each matching terminal. No bare strands should touch a neighboring terminal.

Why Does Wire Order Matter?

The red plus black wires excite the load cell bridge. The green plus white wires carry its measurement signal to the HX711.

A swapped or loose connection can produce unstable counts. It can also prevent calibration from detecting the 100 g weight.

  • Connect HX711 VIN to XIAO 3V3.
  • Connect HX711 GND to XIAO GND.
  • Connect HX711 DATA to XIAO D1.
  • Connect HX711 SCK to XIAO D2.
  • Inspect the XIAO pin labels to confirm that each jumper reaches the intended pin.

The HX711 now has regulated power plus two data connections to the XIAO. The battery holder should still be switched off.

  • Connect the servo signal wire to XIAO D3.
  • Connect the servo positive wire only to the switched 3xAA holder through the JST-PH adapter.
  • Connect the servo ground wire to battery negative.
  • Connect battery negative to XIAO GND.
  • Inspect the servo wiring to confirm that its positive wire has no connection to a XIAO GPIO pin.

Your sensor plus servo now share a signal reference through ground. The servo still receives no power because the battery switch remains off.

Upload the Firmware and Calibrate the Sensor

The firmware converts the HX711 readings into grams. It also keeps actuator travel plus force handling inside a deterministic safety envelope.

  • Switch back to the authenticated Claude Code session in ~/touch-robot.
  • Ask Claude Code to create firmware/TouchRobot/TouchRobot.ino from the project's Complete Code.
  • Confirm that TouchRobot.ino now exists inside ~/touch-robot/firmware/TouchRobot.

Use the tabs below to compare the generated sketch with the source of truth before adding your network values.

✔️ Awesome, I've Got Everything

Your firmware file is ready. Continue with the Wi-Fi credential replacements below.

ⓧ I'd Like to Double Check the Full Code

#include <Arduino.h>
#include <WiFi.h>
#include <Network.h>
#include <Adafruit_HX711.h>
#include <math.h>

const char *WIFI_SSID = "your-wifi-name";
const char *WIFI_PASSWORD = "your-wifi-password";

constexpr uint8_t HX_DATA_PIN = D1;
constexpr uint8_t HX_CLOCK_PIN = D2;
constexpr uint8_t SERVO_PIN = D3;
constexpr uint16_t SERVER_PORT = 8765;

constexpr float CALIBRATION_MASS_G = 100.0f;
constexpr float FORCE_LIMIT_G = 100.0f;
constexpr uint16_t SERVO_PARK_US = 1400;
constexpr uint16_t SERVO_MAX_US = 1600;
constexpr uint16_t PUSH_STEP_US = 20;
constexpr uint16_t BACKOFF_STEP_US = 40;
constexpr uint32_t COMMAND_TIMEOUT_MS = 3000;
constexpr uint32_t SAMPLE_PERIOD_MS = 20;
constexpr uint8_t PWM_RESOLUTION_BITS = 14;
constexpr uint32_t PWM_FREQUENCY_HZ = 50;

Adafruit_HX711 hx711(HX_DATA_PIN, HX_CLOCK_PIN);
NetworkServer server(SERVER_PORT, 1);
NetworkClient client;

int32_t zeroRaw = 0;
float countsPerGram = 1.0f;
float latestForceG = 0.0f;
uint16_t currentPulseUs = SERVO_PARK_US;
uint32_t lastSampleMs = 0;
uint32_t lastCommandMs = 0;
String incomingCommand;

void waitForEnter(const char *message) {
  while (Serial.available()) {
    Serial.read();
  }
  Serial.println(message);
  while (!Serial.available()) {
    delay(10);
  }
  while (Serial.available()) {
    Serial.read();
  }
}

int32_t averageRaw(uint8_t samples) {
  int64_t total = 0;
  for (uint8_t i = 0; i < samples; i++) {
    total += hx711.readChannelBlocking(CHAN_A_GAIN_128);
  }
  return static_cast<int32_t>(total / samples);
}

uint32_t pulseToDuty(uint16_t pulseUs) {
  constexpr uint32_t maxDuty = (1UL << PWM_RESOLUTION_BITS) - 1;
  return static_cast<uint32_t>((pulseUs / 20000.0f) * maxDuty);
}

void setServoPulse(uint16_t requestedPulseUs) {
  currentPulseUs = constrain(requestedPulseUs, SERVO_PARK_US, SERVO_MAX_US);
  ledcWrite(SERVO_PIN, pulseToDuty(currentPulseUs));
}

void parkServo(const char *reason) {
  setServoPulse(SERVO_PARK_US);
  Serial.print("PARK,");
  Serial.println(reason);
}

void calibrateLoadCell() {
  waitForEnter("Remove all load, then send any character.");
  zeroRaw = averageRaw(20);

  waitForEnter("Place the 100 g calibration weight in the probe direction, then send any character.");
  int32_t loadedRaw = averageRaw(20);
  countsPerGram = fabs(static_cast<float>(loadedRaw - zeroRaw)) / CALIBRATION_MASS_G;

  if (countsPerGram < 1.0f) {
    Serial.println("CAL_ERROR: check wiring, mounting, and load direction.");
    while (true) {
      delay(1000);
    }
  }

  waitForEnter("Remove the calibration weight, then send any character.");
  zeroRaw = averageRaw(20);

  Serial.print("CAL_OK,counts_per_gram=");
  Serial.println(countsPerGram, 3);
}

void connectWiFi() {
  Serial.print("Connecting to Wi-Fi");
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print('.');
  }
  Serial.println();
  Serial.print("ESP32_IP=");
  Serial.println(WiFi.localIP());
}

void handleCommand(const String &command) {
  if (command.length() == 0) {
    return;
  }

  const char action = command.charAt(0);
  lastCommandMs = millis();

  if (action == 'P') {
    if (latestForceG < FORCE_LIMIT_G) {
      setServoPulse(currentPulseUs + PUSH_STEP_US);
    } else {
      parkServo("force_limit");
    }
  } else if (action == 'B') {
    uint16_t target = currentPulseUs > BACKOFF_STEP_US
      ? currentPulseUs - BACKOFF_STEP_US
      : SERVO_PARK_US;
    setServoPulse(target);
  } else if (action == 'H') {
    setServoPulse(currentPulseUs);
  } else if (action == 'N') {
    parkServo("remote_neutral");
  }
}

void readClientCommands() {
  while (client && client.connected() && client.available()) {
    char value = static_cast<char>(client.read());
    if (value == '\n') {
      incomingCommand.trim();
      handleCommand(incomingCommand);
      incomingCommand = "";
    } else if (value != '\r' && incomingCommand.length() < 16) {
      incomingCommand += value;
    }
  }
}

void acceptClient() {
  if (client && client.connected()) {
    return;
  }

  if (client) {
    client.stop();
  }
  parkServo("waiting_for_client");

  NetworkClient candidate = server.accept();
  if (candidate) {
    client = candidate;
    lastCommandMs = millis();
    client.println("READY");
    Serial.println("TCP_CLIENT_CONNECTED");
  }
}

void streamSample() {
  int32_t raw = hx711.readChannelBlocking(CHAN_A_GAIN_128);
  latestForceG = fabs(static_cast<float>(raw - zeroRaw)) / countsPerGram;

  if (latestForceG >= FORCE_LIMIT_G) {
    parkServo("force_limit");
  }

  Serial.print("FORCE_G=");
  Serial.print(latestForceG, 2);
  Serial.print(",PULSE_US=");
  Serial.println(currentPulseUs);

  if (client && client.connected()) {
    client.print("DATA,");
    client.print(millis());
    client.print(',');
    client.print(latestForceG, 2);
    client.print(',');
    client.println(currentPulseUs);
  }
}

void setup() {
  Serial.begin(115200);
  delay(1000);

  hx711.begin();
  calibrateLoadCell();

  if (!ledcAttach(SERVO_PIN, PWM_FREQUENCY_HZ, PWM_RESOLUTION_BITS)) {
    Serial.println("SERVO_PWM_ERROR");
    while (true) {
      delay(1000);
    }
  }
  setServoPulse(SERVO_PARK_US);

  connectWiFi();
  server.begin();
  Serial.print("TCP_PORT=");
  Serial.println(SERVER_PORT);
}

void loop() {
  acceptClient();
  readClientCommands();

  uint32_t now = millis();
  if (now - lastSampleMs >= SAMPLE_PERIOD_MS) {
    lastSampleMs = now;
    streamSample();
  }

  if ((!client || !client.connected()) || (now - lastCommandMs > COMMAND_TIMEOUT_MS)) {
    if (currentPulseUs != SERVO_PARK_US) {
      parkServo("command_timeout_or_disconnect");
    }
  }
}

How Does the Safety Envelope Work?

  • The setServoPulse() function constrains every requested pulse between SERVO_PARK_US at 1400 microseconds and SERVO_MAX_US at 1600 microseconds.
  • The FORCE_LIMIT_G guard parks the servo when measured force reaches 100.0f grams.
  • The connection check parks the servo whenever the Mac client disconnects.
  • The COMMAND_TIMEOUT_MS guard parks the servo after 3000 milliseconds without a command.
  • Replace your-wifi-name inside WIFI_SSID with the name of your shared 2.4 GHz Wi-Fi network.
  • Replace your-wifi-password inside WIFI_PASSWORD with that network's password.
  • Save firmware/TouchRobot/TouchRobot.ino.

Arduino IDE compiles the sketch for the selected XIAO ESP32-C3. The upload transfers that firmware through the data-capable USB-C cable.

  • Return to Arduino IDE from the setup step.
  • Load ~/touch-robot/firmware/TouchRobot/TouchRobot.ino from the macOS file picker.
  • Confirm that the board selector still shows XIAO_ESP32C3.
  • Connect the XIAO ESP32-C3 to the Mac with the data-capable USB-C cable.
  • Keep the servo battery holder switched off.
  • Compile the sketch with the checkmark control in the top toolbar.

You should see Arduino IDE finish compiling the sketch without reporting a code error.

  • Upload the compiled sketch with the right-arrow control in the top toolbar.

The upload should complete successfully. The XIAO restarts into the calibration sequence while the servo battery remains off.

Firmware Did Not Upload?

Confirm that the selected board is XIAO_ESP32C3. Check that the USB-C cable supports data instead of charging only.

If compilation fails near the HX711 code, confirm that Adafruit_HX711 1.0.2 is still installed in Arduino IDE.

Keep the battery holder off while you retry. Help me diagnose why TouchRobot.ino will not compile or upload to my XIAO ESP32-C3.

Calibration captures an unloaded baseline in zeroRaw. It uses the known 100 g mass to calculate countsPerGram.

  • Open Serial Monitor for the connected XIAO ESP32-C3.
  • Set the Serial Monitor speed to 115200 baud.
  • Remove all force from the load cell's free end.

You should see Remove all load, then send any character. in Serial Monitor.

  • Enter x in the Serial Monitor input.
  • Press Enter to record the unloaded baseline.

The next prompt asks for the 100 g calibration weight. This measurement must bend the cell in the same direction as the pin reaction.

  • Place the 100 g calibration weight on the rig in the probe direction.
  • Wait for the mounted load cell to settle.
  • Enter x in the Serial Monitor input.
  • Press Enter to record the loaded measurement.

You should now see Remove the calibration weight, then send any character. in Serial Monitor.

  • Remove the 100 g calibration weight from the rig.
  • Wait for the free end to return to its unloaded position.
  • Enter x in the Serial Monitor input.
  • Press Enter to capture the final unloaded baseline.

That is the sensing milestone complete. Serial Monitor should print a line beginning with CAL_OK,counts_per_gram=.

Calibration Stopped Before CAL_OK?

Inspect the red, black, green, plus white load-cell wires against the HX711 terminal labels. Confirm that the weight bends the cell in the mounted probe direction.

A loose terminal can prevent the loaded reading from separating from the unloaded baseline. Tighten the terminal before uploading again.

Keep servo power off while correcting the sensor. Help me troubleshoot why the HX711 calibration does not reach CAL_OK.

Before you test the live reading, what change do you expect when your hand bends the free end of the load cell?

  • Press the free end gently in the calibrated probe direction.

You should see changing lines that begin with FORCE_G=. Each line should also show PULSE_US=1400 while the servo remains parked.

  • Release the free end of the load cell.
  • Confirm that the force reading settles back toward its unloaded baseline.
  • Confirm that the servo battery holder remains switched off.

Your rig can now measure contact force in grams while its firmware keeps the actuator parked inside local safety limits. Next, you will stream those readings to the Mac and expose what the open control loop is still missing.

Stream Force Data to the Mac

Your calibrated ESP32 rig can measure force in grams while keeping the servo parked. The next goal is to make those readings visible on your Mac.

A live sensor trace can reveal contact as it happens. This step streams the readings to a terminal so you can test whether sensing alone closes the physical control loop.

In this step, get ready to:
  • Switch the HX711 to its faster sample rate.
  • Create the Python monitor files with Claude Code.
  • Stream force in monitor-only mode to expose the open control loop.
Increase the sensor sample rate

The HX711 rate switch controls how often the board makes a force sample available. Setting it to H selects 80 SPS for a smoother live trace.

The RATE switch is tiny, so it can take a moment to spot beside the board labels. Keep the servo battery switched off while you adjust it.

  • Keep the switched 3xAA battery holder turned off.
  • Locate the RATE switch on the HX711 board.
  • Move the RATE switch to H.
  • Disconnect the USB-C cable from the XIAO ESP32-C3.
  • Reconnect the data-capable USB-C cable to reboot the board.
  • Return to the Serial Monitor from the previous step.
  • Follow each calibration prompt using the same 100 g calibration procedure.
  • Record the displayed address as ESP32_IP.

You should see CAL_OK after calibration. Serial Monitor then prints ESP32_IP= with the board address followed by TCP_PORT=8765.

Create the Mac force monitor

The Mac monitor acts as a TCP client. It receives newline-delimited samples from the ESP32 before turning them into a terminal sparkline and timestamped CSV logs.

Claude Code uses an interactive session. It may request permission before writing each file.

  • Return to the Terminal session from earlier.
  • Return to your project directory and enter Claude Code by running:
cd ~/touch-robot
claude

What do these commands do?

  • The first command moves the terminal into ~/touch-robot.
  • The second command enters the authenticated Claude Code session for that directory.

Claude Code did not start?

Confirm that the terminal is still connected to the internet. A newly installed command may also require a fresh Terminal session before your shell can find it.

Help me troubleshoot Claude Code on my Mac.

  • Ask Claude Code to create requirements.txt with this exact content:
anthropic==2.4.0

What does this file do?

The dependency file pins the Anthropic Python SDK to 2.4.0. This keeps the saved project definition aligned with the package already installed in your virtual environment.

  • Approve the requested edit to requirements.txt.
  • Ask Claude Code to read back the saved file.

Does the dependency line differ?

Ask Claude Code to replace the file contents with the exact reference above. The file should contain one dependency line.

Help me correct my requirements file.

  • Ask Claude Code to create robot_touch.py from the project's Complete Code.
  • Approve the requested edit to robot_touch.py.
  • Ask Claude Code to explain how DATA,milliseconds,force_g,pulse_us moves from the socket into the sample buffer.
  • Ask Claude Code to compare both files with the references below and fix any syntax or socket differences.

✔️ Claude Code created both files

Both files now match the project references. The monitor is ready for its first connection to the ESP32.

ⓧ I'd like to double check the full code

import argparse
import csv
import json
import os
import socket
import statistics
import sys
import threading
import time
from collections import deque
from datetime import datetime

from anthropic import Anthropic

SERVER_PORT = 8765
MODEL_ID = "claude-haiku-5-5"
DECISION_INTERVAL_SECONDS = 1.2
LOCAL_BACKOFF_G = 90.0
DISPLAY_LIMIT_G = 120.0
BARS = "▁▂▃▄▅▆▇█"

samples = deque(maxlen=250)
decisions = deque(maxlen=8)
data_lock = threading.Lock()
send_lock = threading.Lock()
running = threading.Event()
running.set()


def parse_args():
    parser = argparse.ArgumentParser(
        description="Live force monitor and supervised Claude controller"
    )
    parser.add_argument("esp32_ip", help="ESP32 IP printed in Serial Monitor")
    parser.add_argument(
        "--monitor-only",
        action="store_true",
        help="Plot and log force without calling Claude or moving the servo",
    )
    return parser.parse_args()


def send_action(sock, action):
    command = {
        "PUSH": "P\n",
        "HOLD": "H\n",
        "BACKOFF": "B\n",
        "PARK": "N\n",
    }[action]
    with send_lock:
        sock.sendall(command.encode("utf-8"))


def receiver(sock, force_writer, force_file):
    buffer = ""
    while running.is_set():
        try:
            chunk = sock.recv(4096)
        except socket.timeout:
            continue
        except OSError:
            break

        if not chunk:
            break

        buffer += chunk.decode("utf-8", errors="replace")
        while "\n" in buffer:
            line, buffer = buffer.split("\n", 1)
            fields = line.strip().split(",")
            if len(fields) != 4 or fields[0] != "DATA":
                continue

            try:
                timestamp_ms = int(fields[1])
                force_g = float(fields[2])
                pulse_us = int(fields[3])
            except ValueError:
                continue

            with data_lock:
                samples.append((timestamp_ms, force_g, pulse_us))
            force_writer.writerow([timestamp_ms, f"{force_g:.2f}", pulse_us])
            force_file.flush()

    running.clear()


def choose_with_claude(client, window):
    forces = [point[1] for point in window]
    latest_force = forces[-1]
    mean_force = statistics.fmean(forces)
    max_force = max(forces)
    slope = forces[-1] - forces[0]
    latest_pulse = window[-1][2]
    recent = [round(value, 1) for value in forces[-20:]]

    prompt = f"""
You supervise a one-axis tactile probe touching one visible pin in a clear
practice lock owned by the user. The ESP32, not you, enforces hard safety.
Choose exactly one next action.

Rules:
- PUSH only when force is low and stable.
- HOLD when contact is uncertain or force is rising gently.
- BACKOFF when force is high, rising sharply, or the trace looks unsafe.
- Give a short observation, not hidden reasoning.

Latest force: {latest_force:.1f} g
Mean force: {mean_force:.1f} g
Maximum force: {max_force:.1f} g
Window change: {slope:.1f} g
Servo pulse: {latest_pulse} us
Recent force readings: {recent}
""".strip()

    response = client.messages.create(
        model=MODEL_ID,
        max_tokens=80,
        messages=[{"role": "user", "content": prompt}],
        output_config={
            "format": {
                "type": "json_schema",
                "schema": {
                    "type": "object",
                    "properties": {
                        "action": {
                            "type": "string",
                            "enum": ["PUSH", "HOLD", "BACKOFF"],
                        },
                        "note": {"type": "string"},
                    },
                    "required": ["action", "note"],
                    "additionalProperties": False,
                },
            }
        },
    )

    text = next(block.text for block in response.content if block.type == "text")
    result = json.loads(text)
    action = str(result["action"]).upper()
    if action not in {"PUSH", "HOLD", "BACKOFF"}:
        action = "HOLD"
    return action, str(result["note"])


def decision_worker(sock, decision_writer, decision_file):
    client = Anthropic()
    last_decision = 0.0

    while running.is_set():
        now = time.monotonic()
        if now - last_decision < DECISION_INTERVAL_SECONDS:
            time.sleep(0.05)
            continue

        with data_lock:
            window = list(samples)[-25:]

        if len(window) < 10:
            time.sleep(0.1)
            continue

        latest_force = window[-1][1]
        if max(point[1] for point in window) >= LOCAL_BACKOFF_G:
            action = "BACKOFF"
            note = "Local 90 g guard overrode the cloud decision."
        else:
            try:
                action, note = choose_with_claude(client, window)
            except Exception as error:
                action = "HOLD"
                note = f"API fallback: {type(error).__name__}"

        try:
            send_action(sock, action)
        except OSError:
            running.clear()
            break

        event_time = datetime.now().isoformat(timespec="seconds")
        with data_lock:
            decisions.append((event_time, action, latest_force, note))
        decision_writer.writerow(
            [event_time, action, f"{latest_force:.2f}", note]
        )
        decision_file.flush()
        last_decision = now


def sparkline(values, width=70):
    if not values:
        return "waiting for force samples"
    recent = values[-width:]
    ceiling = max(DISPLAY_LIMIT_G, max(recent), 1.0)
    indexes = [
        min(len(BARS) - 1, int(max(0.0, value) / ceiling * (len(BARS) - 1)))
        for value in recent
    ]
    return "".join(BARS[index] for index in indexes)


def render(monitor_only):
    with data_lock:
        points = list(samples)
        log = list(decisions)

    forces = [point[1] for point in points]
    latest_force = forces[-1] if forces else 0.0
    latest_pulse = points[-1][2] if points else 1400
    mode = "MONITOR ONLY" if monitor_only else "CLAUDE SUPERVISED"

    lines = [
        f"Touch Robot | {mode}",
        f"Force: {latest_force:7.2f} g | Servo: {latest_pulse} us | Hard stop: 100 g",
        sparkline(forces),
        "",
        "Recent decisions:",
    ]

    if not log:
        lines.append("  No decisions yet.")
    else:
        for event_time, action, force_g, note in log:
            lines.append(
                f"  {event_time[-8:]}  {action:8s}  {force_g:6.1f} g  {note[:64]}"
            )

    lines.extend(["", "Press Ctrl+C to park the servo and stop."])
    sys.stdout.write("\033[2J\033[H" + "\n".join(lines) + "\n")
    sys.stdout.flush()


def main():
    args = parse_args()
    if not args.monitor_only and not os.environ.get("ANTHROPIC_API_KEY"):
        raise SystemExit("Set ANTHROPIC_API_KEY before supervised mode.")

    run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
    force_path = f"force_{run_id}.csv"
    decision_path = f"decisions_{run_id}.csv"

    sock = socket.create_connection((args.esp32_ip, SERVER_PORT), timeout=10)
    sock.settimeout(0.5)

    with open(force_path, "w", newline="", encoding="utf-8") as force_file, open(
        decision_path, "w", newline="", encoding="utf-8"
    ) as decision_file:
        force_writer = csv.writer(force_file)
        force_writer.writerow(["esp32_ms", "force_g", "servo_pulse_us"])
        decision_writer = csv.writer(decision_file)
        decision_writer.writerow(["local_time", "action", "force_g", "note"])

        receive_thread = threading.Thread(
            target=receiver,
            args=(sock, force_writer, force_file),
            daemon=True,
        )
        receive_thread.start()

        if args.monitor_only:
            send_action(sock, "PARK")
        else:
            decision_thread = threading.Thread(
                target=decision_worker,
                args=(sock, decision_writer, decision_file),
                daemon=True,
            )
            decision_thread.start()

        try:
            while running.is_set():
                render(args.monitor_only)
                time.sleep(0.2)
        except KeyboardInterrupt:
            pass
        finally:
            running.clear()
            try:
                send_action(sock, "PARK")
            except OSError:
                pass
            sock.close()

    print(f"Saved {force_path}")
    print(f"Saved {decision_path}")


if __name__ == "__main__":
    main()

How is the monitor organized?

  • The receiver parses each four-field sample before storing it and writing it to the force log.
  • The renderer turns recent force values into the live sparkline shown in the terminal.
  • The main function creates both log files before starting the receiver thread.
  • Monitor-only mode sends a park command instead of starting the decision thread.
anthropic==2.4.0

What should the dependency file contain?

The file contains one pinned dependency. It should match the line shown above exactly.

Run the open-loop monitor

Monitor-only mode creates the shortest test loop between touching the sensor and seeing its response. The probe must remain clear of the practice lock during this check.

  • Return to the Terminal from earlier.
  • Enter the project directory and activate the existing virtual environment by running:
cd ~/touch-robot
source .venv/bin/activate

What do these commands do?

  • The first command returns the shell to the directory containing robot_touch.py.
  • The second command activates the virtual environment that already contains the pinned Python dependency.

Virtual environment did not activate?

Confirm that your terminal is inside ~/touch-robot. The activation file lives inside the existing .venv directory.

Help me activate the project environment.

Before you run the monitor, do you think a changing force trace is enough to make the servo respond?

  • Replace ESP32_IP in the command below with ESP32_IP.
  • Start monitor-only mode by running the edited command:
python robot_touch.py ESP32_IP --monitor-only

What does this command do?

The Python client connects to TCP port 8765 on the ESP32 address you entered. It parses each complete newline-delimited sample before updating the terminal and log files.

  • Keep the blunt probe away from the clear practice lock.
  • Press the free end of the load cell gently by hand.
  • Watch the force number and sparkline respond to your pressure.
  • Confirm that the displayed servo pulse remains at 1400 microseconds.

You should see the force value and sparkline change as approximately 50 samples per second reach the Mac. The decision panel remains empty while the servo stays parked.

You have now proved the intended shortfall: the machine can feel your touch, but the open loop cannot turn that sensation into movement.

Why does the servo stay parked?

The --monitor-only path sends PARK through send_action() when the connection starts. It does not start decision_worker().

The ESP32 keeps streaming samples because sensing remains active. No returning movement decision closes the loop.

No live force samples?

Confirm that the Mac and ESP32 are using the same 2.4 GHz Wi-Fi network. Also compare the address in your command with the latest ESP32_IP= value in Serial Monitor.

If updates are unusually slow, confirm that the HX711 RATE switch is still set to H.

Help me debug the monitor connection.

Your Mac can now see and record the rig's sense of touch. Next, you can add a supervised decision path while the firmware keeps control of the safety limits.

Run Claude’s Supervised Control Loop

Your monitor-only run proved that force samples can travel from the ESP32 to the Mac in real time. The servo stayed parked because no decision returned across the connection.

The Anthropic Claude API now becomes the supervisory layer in your closed-loop control system. Firmware still owns the force stop, travel envelope, disconnect behavior, and command timeout.

In this step, get ready to:
  • Start supervised mode with session-only API access.
  • Inspect each constrained decision sent to the ESP32.
  • Prove the local safety layers with a dry run away from the lock.
Start supervised mode

Supervised mode keeps the fast force stream local. It asks Claude for one constrained action at a slower cadence of about 1.2 seconds.

Protect Your API Key

This step uses a live credential through ANTHROPIC_API_KEY. The export applies only to the active Terminal session.

Claude API requests are metered while supervised mode runs. Stopping the app ends the request loop.

  • Return to the active Terminal session from your monitor-only test.
  • Press Ctrl+C to park the servo and close the monitor-only connection.
  • Switch off the servo battery holder.
  • Position the probe away from the practice lock.
  • Replace your-api-key-here in the first command with your personal API key.
  • Replace ESP32_IP in the second command with the address from Serial Monitor.

Before the run, predict whether the terminal can show constrained decisions while the servo battery remains off.

  • Export the API key and start supervised mode by running these commands:
export ANTHROPIC_API_KEY="your-api-key-here"
python robot_touch.py ESP32_IP

What Do These Commands Do?

  • The first command makes ANTHROPIC_API_KEY available to the Python process in this Terminal session.
  • The second command starts robot_touch.py without --monitor-only.
  • The app connects to port 8765 over TCP.
  • The app sends force summaries to claude-haiku-5-5 approximately every 1.2 seconds.

Good progress. The terminal now shows live force data with recent PUSH, HOLD, or BACKOFF decisions while the unpowered servo remains still.

No Decisions Appearing?

A missing API key or dropped ESP32 connection can prevent new decisions from appearing.

  • Stop the app with Ctrl+C.
  • Confirm Serial Monitor still shows the ESP32 connected to Wi-Fi.
  • Replace ESP32_IP with the current address before starting the app again.
  • Re-enter the export command with your current personal API key.

Ask for help with a supervised mode connection or authentication problem.

Inspect the bounded decision path

A structured output gives the cloud response a fixed shape. The app accepts only an action plus a short note.

Why Use Supervisory Claude?

A local threshold controller provides fast deterministic protection. This project keeps that protection in Python and firmware.

  • The output_config.format setting supplies a JSON schema that permits only PUSH, HOLD, or BACKOFF.
  • The DECISION_INTERVAL_SECONDS value limits cloud decisions to roughly one every 1.2 seconds.
  • The LOCAL_BACKOFF_G guard sends BACKOFF at 90 g without waiting for cloud output.
  • An API failure becomes HOLD so an unavailable model cannot request more movement.
  • The firmware independently constrains every command to the 1400 to 1600 microsecond envelope.
  • Watch the decision panel until it contains at least two entries.
  • Confirm each entry uses PUSH, HOLD, or BACKOFF.
  • Compare the entry timestamps to confirm decisions arrive approximately every 1.2 seconds.
  • Read each short note to identify what Claude observed in the recent force window.

The sensor trace continues updating between decisions. This keeps cloud latency outside the fast sampling loop.

Prove the force limits in a dry run

Keep the Physical Stop Ready

This check powers moving hardware while the probe remains away from the lock. The battery switch gives you an immediate physical stop if the direction or alignment looks wrong.

  • Confirm the practice lock sits outside the probe path.
  • Place one hand beside the battery switch.
  • Switch on the servo battery holder.
  • Watch the displayed servo pulse while Claude makes several decisions.
  • Switch off the battery holder immediately if the motion direction or mechanical alignment looks wrong.

You should see the displayed pulse remain between 1400 and 1600 microseconds. Every cloud action stays inside the firmware travel envelope.

Before loading the cell, predict which safety layer reacts near 90 g and which layer parks the servo at 100 g.

  • Lower the 100 g calibration weight onto the free end in the calibrated probe direction.

As the force reaches 90 g, the next local decision should show BACKOFF with the note Local 90 g guard overrode the cloud decision. At 100 g, the firmware parks the servo at 1400 microseconds.

  • Capture the terminal panel by pressing Cmd+Shift+4 while the hard-stop state is visible.
  • Remove the calibration weight.
  • Switch off the servo battery holder.
  • Press Ctrl+C to park the servo and stop the supervised app.

You should see saved-file messages for force_<timestamp>.csv and decisions_<timestamp>.csv. The decision file contains timestamps, constrained actions, force readings, and notes from this dry run.

Servo Did Not Park at the Force Limit?

Switch off the battery holder before inspecting the rig. A force reading below 100 g means the weight may be loading the cell in a different direction from calibration.

  • Confirm the 100 g weight bends the load cell in the calibrated probe direction.
  • Return to Serial Monitor to confirm the force reading reaches 100 g.
  • Recalibrate the load cell before repeating the dry run if the reading stays below the expected value.

Ask for help with a force-limit or servo-parking problem.

That is the safety case proven: Claude can supervise movement while local code keeps final authority. Next, you can apply the same bounded loop to one visible pin on your authorized practice lock.

Capture a One-Pin Evidence Run

Your dry run proved that the supervised loop responds safely away from the lock. A bench test still leaves the physical event unproven.

This controlled trial connects each Claude decision to movement at one visible pin. The ESP32 remains in charge of the hard safety envelope.

The video captures the visible contact event. The two CSV logs capture the matching force samples and structured decisions.

In this step, get ready to:
  • Prepare an authorized clear practice lock for the one-pin run.
  • Run the supervised loop for approximately 30 seconds.
  • Preserve a synchronized evidence package from the trial.
Prepare the authorized lock

The clear practice lock lets you compare the force response with a visible pin movement. The probe begins from its parked position before any powered motion.

  • Switch off the servo battery holder.
  • Remove any tension tool from the lock.

You should see a motionless probe parked at 1400 microseconds.

Keep the Experiment Authorized

This experiment is limited to a clear practice lock that you own or have explicit permission to manipulate. Local laws define whether lock manipulation is permitted in your area.

  • Confirm that you own the clear practice lock or have explicit permission from its owner.
  • Check that local law allows this activity.

With those checks complete, this run stays inside the project's authorized scope.

  • Secure the rig base with your existing mounting materials.
  • Secure the clear practice lock beside the parked probe.

The base and lock should remain still when you apply gentle pressure nearby.

Alignment is the fiddly part because a small sideways angle can twist the load cell.

  • Position the parked blunt probe just clear of one visible key pin.
  • Inspect the probe alignment from the side.

You should see a small gap before contact. The probe should point in the same direction used during calibration.

Run the supervised trial

The supervised app stores every force sample while the camera records the physical response. Its decision log preserves each action with a timestamp and short note.

  • Frame the visible pin stack and Mac terminal in the same video shot.
  • Start the video recording.

Your camera preview should show the clear pin stack beside the live terminal area.

Before you start the supervised run, do you expect the first visible pin movement to line up with a rising force trace or a new decision?

  • Return to the active Terminal session from the dry run.
  • Start the supervised app with the same command from the previous step.

You should see the live force sparkline updating. New PUSH, HOLD, or BACKOFF decisions should appear approximately every 1.2 seconds.

Keep the Physical Stop Ready

Powering the servo enables physical motion. The battery switch remains your immediate stop.

  • Keep one hand beside the battery switch throughout powered motion.
  • Switch off the battery immediately if the load cell twists.
  • Switch off the battery immediately if the lock shifts.
  • Switch off the battery immediately if the probe misses the visible pin.
  • Press Ctrl+C in Terminal after cutting servo power.
  • Switch on the servo battery holder.

You should see the servo remain between 1400 and 1600 microseconds. The terminal should continue displaying force samples during each movement.

  • Let the supervised loop run for approximately 30 seconds.
  • Press Ctrl+C in Terminal when the trial is complete.

The terminal should print two saved filenames matching force_*.csv and decisions_*.csv.

  • Switch off the servo battery holder.
  • Stop the video recording.
  • Save the recording as one_pin_run_video with the two run logs.

That's the physical run complete. Your probe has now produced force data beside a visible one-pin event.

Compare and preserve the evidence

A useful evidence package lines up the physical event with the terminal trace. The saved logs preserve the sensor values and decisions behind that moment.

Before you compare the files, which signal do you expect to mark contact most clearly: visible pin movement, the force rise, or the displayed action?

  • Open the touch-robot folder inside your home folder in Finder.
  • Identify the newest force_<timestamp>.csv file from the filename printed in Terminal.
  • Identify the newest decisions_<timestamp>.csv file from the filename printed in Terminal.
  • Review one_pin_run_video until the first visible pin movement.
  • Pause the recording at that movement.
  • Record the playback time here: first visible contact timestamp.
  • Record the action displayed in the paused terminal frame here: contact-frame action.

The marker at first visible contact timestamp anchors the physical side of your comparison. The displayed action anchors the decision side.

  • Inspect the sparkline in the paused video frame for a force rise.
  • Open the newest force_<timestamp>.csv file.
  • Confirm that the force_g column contains the rising readings shown in the recording.
  • Open the newest decisions_<timestamp>.csv file.
  • Find the row containing contact-frame action.
  • Compare its note value with the visible pin response.

You should now have a video frame where visible pin movement lines up with a rising sparkline. The decision log should contain a corresponding action with a timestamped note.

You now have a one-pin evidence package that connects visible contact, sampled force, and supervised decisions.

Secret mission

Find the Contact Point Without Looking

Hide the clear lock from your own view while the camera keeps recording it. Predict first contact from the force sparkline plus Claude decision log across three trials. Compare each prediction with the first visible pin movement.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your resources ready, pause the project for later, or remove them entirely. Your stored files and idle hardware do not create ongoing charges.

Cost warning

Metered Claude API requests continue while robot_touch.py runs in supervised mode. Your switched-off rig does not generate API charges.

  • Stop the app after each experiment to prevent further API requests.

Resources you used:

  • The local ~/touch-robot directory. It contains your Arduino sketch, Python app, .venv virtual environment, requirements file, and generated CSV logs.
  • The preserved video from the visible one-pin run.
  • The three preserved hidden-lock trial videos.
  • The hidden-contact prediction record with its comparison summary.
  • The personal API key in the Claude Console.
  • The shell-only ANTHROPIC_API_KEY value in your current Terminal session.
  • The mounted and calibrated ESP32 touch rig with its switched battery holder.

Keep everything running

No deletion is needed. Choose this if you plan to continue experimenting with the authorized clear lock.

  • Leave the ~/touch-robot directory on your Mac.
  • Keep the original one-pin evidence package.
  • Keep the hidden-lock trial evidence package.
  • Press Ctrl+C in Terminal after every supervised run.
  • Switch off the 3xAA battery holder between experiments.
  • Keep your personal API key outside source control.

Pause - I'll come back to this later

Shut down the active process to leave the rig in a safe state. Your code and evidence remain ready for another session.

  • Press Ctrl+C in Terminal if the Python app is still running.
  • Switch off the 3xAA battery holder.
  • Remove the AA batteries from the holder.
  • Unplug the ESP32 from your Mac.
  • Clear the shell-only API key from the current Terminal session by running:
unset ANTHROPIC_API_KEY

What does this command do?

This removes ANTHROPIC_API_KEY from the current shell. Your personal API key remains available in the Claude Console for your next session.

  • Leave the ~/touch-robot directory in place.
  • Keep the videos and hidden-contact prediction summary in their saved locations.

Delete - I don't want to use this again

Remove the project resources if you no longer plan to use this tactile rig. Your reusable development tools can stay installed for future projects.

Deletion is permanent

Deleting ~/touch-robot removes the firmware, app, virtual environment, and CSV logs without moving them to the Trash. Evidence stored outside that directory remains in place until you remove it separately.

Make the rig safe:

  • Press Ctrl+C in Terminal if the Python app is still running.
  • Switch off the 3xAA battery holder.
  • Remove the AA batteries from the holder.
  • Unplug the ESP32 from your Mac.

Remove the active shell key:

  • Clear the shell-only API key from the current Terminal session by running:
unset ANTHROPIC_API_KEY

What does this command do?

This clears ANTHROPIC_API_KEY from the current shell. It does not revoke the personal API key stored in the Claude Console.

Delete the local project:

  • Delete the complete ~/touch-robot directory by running:
rm -rf ~/touch-robot

What does this command do?

This removes the firmware directory, robot_touch.py, requirements.txt, .venv, and any generated CSV logs stored inside ~/touch-robot.

Revoke the personal API key:

  • Return to the Claude Console API key page from earlier to locate the personal key created for this project.
  • Revoke that personal API key.
  • Confirm that the key is no longer active.

Remove evidence saved elsewhere:

  • In Finder, locate the one-pin video saved outside ~/touch-robot.
  • Locate the three hidden-lock trial videos saved outside ~/touch-robot.
  • Locate the hidden-contact prediction summary saved outside ~/touch-robot.
  • Move those evidence files to the Trash.
  • Empty the Trash.

Reset the physical rig:

  • Disassemble the blunt probe from the clear practice lock.
  • Store the electronic components with the battery holder switched off.

Nice Work!

Nice Work!

Outstanding work! Your supervised one-pin tactile robot now turns measured force into controlled movement inside firmware-enforced safety limits.

You've learned how to:

  • Build and calibrate a load-cell touch rig around an ESP32-C3. You can now measure contact force in grams.
  • Stream force samples over local TCP to a live terminal sparkline. Your timestamped CSV logs preserve the matching sensor evidence.
  • Close a supervised control loop with Claude structured outputs. Firmware still enforces travel limits, force limits, disconnect parking, and timeout parking.
  • Complete the Secret Mission by identifying first contact from the force signature without looking at the lock. You compared three predictions with visible pin movement to find the most reliable clue.

Ready to quiz yourself?