claude-code-eyes — let Claude look at the real world
Some outputs live where no test, log, or API can reach them: a display panel, an
LED, a breadboard, a rack of wires. This skill grabs the current camera frame and
lets you Read it, so you answer from what you actually see — not from what the
code should produce.
How
- Run:
bash <path-to-skill>/snap.sh— it prints the path(s) of the captured JPEG/PNG. (It works from any directory.) Readeach printed path — the Read tool renders images visually.- Answer from the frame. Reference positions concretely: "the red wire on the left rail, third hole down", not "a wire near the top".
If a capture fails, read the error before retrying — it names the actual cause
and the fix. One case is worth knowing: Claude Code runs Bash in a sandbox that
blocks LAN addresses, so a camera your own terminal reaches fine can be
unreachable here. snap.sh detects that and prints the fix already filled in —
either approve Claude's offer to rerun the command outside the sandbox, or add the
printed sandbox.excludedCommands entry to ~/.claude/settings.json (the network
allowlist does not cover private addresses). Frames are written under ./.claude-code-eyes/ in the working
directory, not $TMPDIR, because sandboxed commands and the Read tool resolve
$TMPDIR differently. See the README's Troubleshooting section.
When you cannot read something, zoom instead of guessing. If a label, marking, or band is too small to resolve, do not report a guess and do not say the image is too blurry — take another look:
bash <path-to-skill>/snap.sh --zoom 5 # then Read the new frame
bash <path-to-skill>/snap.sh --focus # if it is soft rather than small
--zoom is a magnification from 1 to 10 and the previous zoom is restored
afterwards, so it is safe to reach for. Requires CCE_CAM_TYPE=ipwebcam; other
backends say so and still capture. Escalate deliberately: 1x to frame the scene,
then zoom to read the detail you actually need.
For "watch this" / an action in progress: bash <path-to-skill>/snap.sh 3 2
(3 frames, 2 s apart), then compare the frames and narrate what changed. Longer
processes: raise the count/interval.
Visual-verify: catch what green tests cannot
A rendered screen is an output no unit test can see. This is the use case that makes this more than a webcam grab.
Workflow for any display/render change:
- Before/after — snap once before the change, once after, and diff the two frames.
- Against the source of truth — take what the spec / API / expected string says the screen should show, and check the frame against it character by character, element by element.
- Report mismatches — say exactly what differs, where.
A small case library (real bugs that every automated layer passed):
- Font coverage / silently-dropped characters. A 48 px clock font contained
only
0-9 : . - a p m; handed~1h23minit rendered1 24m—~ h i nwere dropped silently. The bytes were valid ASCII and the test asserting ASCII passed; the narrow thing was the font. Only the camera caught it. - Text ↔ graphic collision. With the text finally rendering in full, the tail of a number was drawn underneath a status icon sharing that row. It had already been misread as a pass once — look at the whole frame, not just the part you expected to change.
- Clipping at a panel edge. Text that runs past the usable width is truncated by the panel, not by your string — you only see it on the glass.
- Stale vs live. A frozen render can look identical to a live one. Confirm the screen reflects now (a value you just changed, a timestamp, a known-stale state that should read "offline"/grey rather than a frozen number).
Rule of thumb: any change to a render path or a font is not "done" until a frame confirms it.
Wiring-mentor: before you approve a power-on
When the question is "should I connect that?" / "is this right?", the frame is checked against a plan, not eyeballed:
- Check the frame against the build's wiring table (the from-pin → to-pin contract). Call out every mismatch.
- Wire-color discipline helps a photo be checked against a plan (e.g. red = 3V3/5V, black = GND, other colors per signal).
- Verify polarity and voltage rails before saying "power it on": no 5 V on a 3.3 V-only pin (many MCU GPIOs, e.g. ESP32, are NOT 5 V-tolerant); check battery / JST polarity (reversed connectors are a classic release-the-smoke gotcha).
- Safety rails: a relay/module switching mains → stop and involve a qualified human. Don't hot-plug camera ribbons or delicate connectors.
- Too far or too blurry to read pin labels? SAY SO and ask for a closer aim. Never guess a pin you cannot read.
Know what your instrument can and cannot see
The camera is an instrument, and so is every other tool you trust. A blank result is not a negative result until the instrument itself is proven working. Three ways the same lesson bit in one session:
- A tool that was never installed.
tcpdumpreturned empty because it wasn't on the box, and2>/dev/nullturned "command not found" into a convincing clean capture — which became a confident, wrong root cause that had to be retracted. - A log for a code path that logged nothing. Silence from a serial log read as "it didn't happen" when the path simply emitted no line.
- A correct behavior invisible in the data. A correct de-duplication leaves the database byte-identical to "nothing happened" — the only evidence was on the device and in the response, not in the store.
So: when a frame looks blank, empty, or unchanged, first ask whether the camera is actually pointed, focused, and capturing — prove the instrument before you trust its silence.
Config
Set a camera endpoint (highest precedence first): exported env vars, then a
.cce.env in the current directory, then
${XDG_CONFIG_HOME:-$HOME/.config}/claude-code-eyes/config. Config files only
fill values you haven't already exported; they are read, never executed.
| Key | Meaning |
|---|---|
CCE_CAM_URL |
Camera URL (interpreted per CCE_CAM_TYPE) |
CCE_CAM_AUTH |
Optional HTTP basic auth user:pass |
CCE_CAM_TYPE |
ipwebcam | camera-streamer | url (default url) |
Backends:
ipwebcam— Android IP Webcam app:GET $CCE_CAM_URL/shot.jpg.camera-streamer— Raspberry Pi camera-streamer:GET $CCE_CAM_URL/snapshot.url— generic:GET $CCE_CAM_URLexactly as given (any snapshot URL).
A local USB-webcam grab via ffmpeg is possible but experimental / untested
(it needs camera permission granted to your terminal); see the README. The
supported, portable path is a snapshot URL via one of the backends above.
Troubleshooting
If nothing is reachable, snap.sh prints exactly which type + URL it tried. Common
causes: the phone app or Pi server isn't running, the IP or port changed, HTTP
basic auth is required (set CCE_CAM_AUTH), or you're not on the same network.