LED Control
Every Intern device has an 8-LED WS2812 ring on the front. The default colors track the device lifecycle (cyan when thinking, blue while idle, red on errors, white during boot, …), but you can override them per-state through a small HTTP API exposed by intern-server.
This page covers everything you need to drive the ring from your laptop, phone, or any other device on the same Wi-Fi.
How it works
The ring is controlled exclusively by intern-server, which listens on port 5000 of the device's Wi-Fi IP. You cannot drive the WS2812 chain directly from /sys/class/leds/, pinctrl, gpioset, or any sysfs path — those control the Pi board's ACT/PWR status LEDs, which are physically different chips.
Any color you set is saved to config.json on the device, so it survives reboot and is picked up on every transition into that state — including transitions driven by the OpenClaw lifecycle (Thinking ↔ Working) as you chat with the bot.
Prerequisites
- The Intern is paired and connected to Wi-Fi (see Pairing & Wi-Fi).
- Your laptop / phone is on the same Wi-Fi network as the device. The HTTP API binds to the LAN IP — it is not reachable from outside the local network.
Finding the device IP
Once the device is paired, ask it directly over your chat channel:
You: what's your IP?
Intern: I'm on
192.168.1.42. You can reach my API athttp://192.168.1.42:5000.
Replace 192.168.1.42 with whatever the bot reports. Every example below uses $PI as a placeholder:
PI=192.168.1.42
If the chatbot is unreachable (e.g. you're still setting it up), you can also find the IP from your router's DHCP leases page — look for the hostname that starts with intern- or the MAC in the device's Wi-Fi info screen.
States
The ring has 8 lifecycle states. The HTTP API accepts these state values:
| State | Default visual | When the device enters it |
|---|---|---|
working | Blue breathing (2.5s) | Ready / idle after setup, or after an agent reply finishes |
thinking | Cyan breathing (random 2-7s) | OpenClaw lifecycle agent.start — agent is processing a message |
idle | Dim blue 5% | Entered only via explicit API call (no auto-fade after inactivity) |
connectionmode | White blink 0.5s | Captive portal active — needs configuration |
error | Orange-red solid | OpenClaw lifecycle agent.error (auto-rolls back to Working after 10s) |
booting | White solid | Device startup |
workingnointernet | Amber blink (1s) | Network monitor detected internet loss |
poweroff | Off | Reset button 3s, or explicit API call |
Most people only care about
working— that's the state the ring sits in when nothing else is happening. If you change one color, change that one.
Endpoints
POST http://$PI:5000/api/led
POST http://$PI:5000/api/led/reset-all
GET http://$PI:5000/api/led
Header Content-Type: application/json is required on POSTs.
Quick examples
Just switch state
curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{"state":"working"}'
Set Working to solid red (saved)
curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{
"state":"working",
"color":{"r":255,"g":0,"b":0}
}'
Set Working to red breathing 2s
curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{
"state":"working",
"color":{"r":255,"g":0,"b":0},
"breathing_seconds":2
}'
Dim solid red for night mode
curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{
"state":"working",
"color":{"r":255,"g":0,"b":0},
"brightness":20
}'
Subtle blue breathing (always lit, 50-70%)
curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{
"state":"working",
"color":{"r":0,"g":150,"b":255},
"breathing_seconds":3,
"brightness_min":50,
"brightness_max":70
}'
Turn the ring off (locked)
curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{"state":"poweroff","inhibit":true}'
Release the lock
curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{"state":"working","inhibit":false}'
Reset a saved color for one state
curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{"state":"working","reset":true}'
Wipe every saved color
curl -sS -X POST "http://$PI:5000/api/led/reset-all"
Read the current state
curl -sS "http://$PI:5000/api/led"
# → {"state":"working"}
Request fields
| Field | Type | Required | Notes |
|---|---|---|---|
state | string | yes | One of the states in the table above. |
color | {r, g, b} | no | 0-255 per channel. Persists to config.json. |
breathing_seconds | number | no | > 0 enables sine breathing on that period. Omit or ≤ 0 for solid. |
brightness | number | no | Solid mode only. Range 5-80, default 80. |
brightness_min | number | no | Breathing mode only. Range 0-80, default 20. |
brightness_max | number | no | Breathing mode only. Range 0-80, default 80. |
reset | bool | no | true clears the saved color for state. Mutually exclusive with color (returns HTTP 400). |
inhibit | bool | no | true locks the ring at this state — useful only for poweroff. false releases a prior lock. Omit to leave the lock untouched. |
Combination rules:
breathing_seconds > 0→ engine readsbrightness_min/brightness_max, ignoresbrightness.- No
breathing_seconds(or≤ 0) → engine readsbrightness, ignoresbrightness_min/brightness_max. inhibit:trueis only useful withpoweroff; do not pair it with color changes.
Response shape
Success:
{
"state": "working",
"inhibit": false,
"color": {"r":0,"g":255,"b":0},
"breathing_seconds": 2,
"brightness_min": 20,
"brightness_max": 80
}
The response echoes the effective values after defaults have been applied. So if you POST without brightness_min / brightness_max, the response still shows 20 / 80 so you know what the engine is rendering.
Failure (HTTP 400):
{
"error": "unknown LED state \"banana\"",
"valid": ["booting","idle","connectionmode","thinking","working","workingnointernet","error","poweroff"]
}
Color presets
A small palette for quick experiments:
| Name | r,g,b |
|---|---|
| Red | 255, 0, 0 |
| Orange | 255, 100, 0 |
| Yellow | 255, 200, 0 |
| Green | 0, 255, 0 |
| Cyan | 0, 200, 255 |
| Blue | 0, 100, 255 |
| Purple | 180, 0, 255 |
| Pink | 255, 0, 180 |
| White | 255, 255, 255 |
| Warm white | 255, 200, 150 |
The engine internally caps every channel at 80% to extend LED lifespan, so r:255 is the maximum allowed value for that channel, not a "blinding" brightness.
Breathing presets
Common breathing setups, expressed as ready-to-paste payload fragments:
| Preset | breathing_seconds | brightness_min | brightness_max |
|---|---|---|---|
| Slow / chill | 4 | 20 | 80 |
| Normal | 2 | 20 | 80 |
| Fast | 0.8 | 20 | 80 |
| Subtle (always lit) | 2 | 50 | 70 |
| Soft pulse | 2 | 10 | 40 |
| Deep (off to full) | 2 | 0 | 80 |
Tips
- Override survives reboot. Once you POST with
color, the choice is written to/root/config/config.jsonand reapplied on next boot. To revert, send the same state withreset:true(or hit/api/led/reset-allto wipe everything). - Override replaces the default animation. If you set
thinkingto red solid, the ring no longer renders the rotating cyan spot — only your solid red. Same forconnectionmode(loses the blink) and any other state with a built-in animation. - The chat bot can drive the ring too. The
led_controlskill bundled with the device understands "change the led to red", "make it breathe slow blue", "turn off the light", etc. The skill posts to the same API. - No internet → still works. Because the API is purely local (
5000bound to the Wi-Fi IP), LED control keeps working even when the device is offline to the wider internet.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
curl: (7) Failed to connect ... | Wrong IP, or your laptop is on a different network | Re-ask the bot for IP; verify same Wi-Fi |
HTTP 503 LED not available | LED engine failed to bind SPI (dev machine or no dtparam=spi=on) | Reboot after setup.sh ran the SPI stage |
HTTP 400 reset and color are mutually exclusive | Sent both in the same request | Send reset:true first to wipe, or send color alone |
HTTP 400 unknown LED state | Misspelled state name | Use a value from the valid array in the response |
| Color set OK but ring doesn't change | Engine is inhibited (locked) by a previous request | POST any state with inhibit:false to release |
| Setting persists but lost after reboot | Editing config manually | Always go through the API — manual edits are overwritten on Save() |