My AI could write perfect firmware. It had just never touched a wire.
For the last 6 months, my agents have lived in the same place: a black terminal window.
They were brilliant in there. They could refactor a codebase at 2am, spin up 5 sub-agents to audit my repo, write tests I was too lazy to write. But they were also completely, physically useless.
I would ask: "Is it cold in the office?"
And it would curl wttr.in.
It had no body. No hands. No way to affect the real world. So I built it one.
The Cage
If you're using OpenClaw / Claude Code properly, you know the feeling. The agent is no longer a chatbot. It's persistent. It has memory, it has cron jobs, it has tools.
The problem is we keep treating it like a software engineer when it could be a physical operator.
I wanted an agent that doesn't just tell me the plant is dry. I wanted one that waters it. One that doesn't just log that my workshop door opened, but turns on the light, buzzes me, and locks it after.
That meant breaking it out of the terminal.
The Idea: Give It GPIO
The architecture is stupid simple when you draw it out:
OpenClaw Agent -> Custom WebSocket Daemon (on my laptop / Pi) -> WiFi -> ESP32 -> Real World
The agent never talks directly to the ESP32. It talks to a daemon I wrote that exposes physical tools as functions. To the agent, turn_on_relay looks just like read_file. It has no idea it's doing hardware.
This is the core of what I'm calling physical agents. I ended up documenting the entire bridge architecture, protocol, and handshake logic in detail in my guide PHYSICAL AGENTS: Bridging OpenClaw with ESP32 Microcontrollers via Custom WebSocket Daemons — numbpilled.gumroad.com/l/physical-openclaw-agents — because the devil is in the reconnection and state sync.
The Wire: The Daemon
OpenClaw is powerful because you can give it custom tools. So I wrote a tiny Python daemon that runs 24/7 and registers tools like esp32.gpio_write, esp32.read_sensor, esp32.pulse.
# daemon.py - the bridge
from openclaw_daemon import tool
import asyncio
import websockets
ESP32_CLIENTS = set()
@tool("esp32.gpio_write")
async def gpio_write(pin: int, state: int, device_id: str = "workbench_01"):
"""Write to a GPIO pin on the ESP32. The agent uses this like any other tool."""
payload = {"action": "gpio_write", "pin": pin, "state": state}
for ws in ESP32_CLIENTS:
await ws.send_json(payload)
return {"status": "sent", "pin": pin, "state": state}
async def handler(websocket):
ESP32_CLIENTS.add(websocket)
try:
async for msg in websocket:
# sensor data coming back from ESP32 -> feed to agent memory
await memory.ingest(msg)
finally:
ESP32_CLIENTS.remove(websocket)
The agent now thinks GPIO is just another API. It doesn't care if it's an ESP32, a Raspberry Pi, or a toaster.
For the persistence layer — keeping the daemon and the agent alive across reboots, crashes, laptop sleeps — I didn't reinvent it. I used the exact pattern from the Paperclip Method: Replace Your Dev Team With Persistent Claude Agents — numbpilled.gumroad.com/l/paperclip-claude-method. The heartbeat + memory compaction loop is what makes the physical part reliable. Without it, your plant dies when your Mac sleeps.
The Body: The ESP32
The ESP32 side is 80 lines of C++. It just connects to WiFi, connects to the daemon, and listens.
#include <WiFi.h>
#include <WebSocketsClient.h>
WebSocketsClient webSocket;
void webSocketEvent(WStype_t type, uint8_t * payload, size_t length) {
if(type == WStype_TEXT){
// {"action":"gpio_write","pin":2,"state":1}
DynamicJsonDocument doc(256);
deserializeJson(doc, payload);
if(doc["action"] == "gpio_write"){
digitalWrite(doc["pin"], doc["state"]);
}
}
}
void setup(){
pinMode(2, OUTPUT);
WiFi.begin(SSID, PASS);
webSocket.begin("192.168.1.10", 8080, "/");
webSocket.onEvent(webSocketEvent);
}
That's it. Now when I say in OpenClaw: "Turn on the bench light and tell me the temperature in 10 minutes," it does:
> openclaw tools call esp32.gpio_write pin=2 state=1
> openclaw tools call esp32.read_sensor type="dht22" pin=4
If you're new to the CLI, you'll be living in it. I had The Complete OpenClaw CLI Reference (June 2026) – 120+ Commands — numbpilled.gumroad.com/l/openclaw-cli-reference-guide — open on my second monitor the entire first weekend. You need openclaw daemon register and openclaw memory more than you think.
The First Blink
The first time it worked, it was anticlimactic and profound.
I typed: the soil sensor says 22% - handle it
My agent checked the log, saw the threshold was 30%, called esp32.gpio_write pin=5 state=1, waited 4 seconds, called pin=5 state=0.
In my office, 10 feet away, a little 5V pump whirred and watered my monstera. The LED on the ESP32 blinked.
The agent left the terminal. For the first time, it did something that wasn't text.
What This Unlocks
Once you have this, you stop thinking about agents as coders.
My agent now:
- Monitors my 3D printer with a vibration sensor and pauses OctoPrint if it detects a failure pattern
- Turns on a red light outside my office when I'm in deep work mode (it reads my calendar via OpenClaw)
- Waters plants, opens my garage, and resets my router when ping fails
It's not home automation. Home automation is if-this-then-that. This is an agent that reasons about physical state. "It's 11pm, the workshop is 58F, and there's motion. That's probably the cat. Don't turn on the alarm, just log it."
We spent 2 years making agents that can use a computer. The next 2 years will be about agents that can touch the world.
If you want to build one, start with a $7 ESP32 DevKit, one LED, and the daemon above. Don't try to build the whole smart home. Give it one hand first.
The terminal was just the crib. It's time to let it walk.
Build notes, full firmware, and the reconnection protocol with queueing are all in the guides linked above. Code for the simple blink example is free on my GitHub.
Top comments (0)