cd /news/ai-tools/i-gave-local-ai-eyes Β· home β€Ί topics β€Ί ai-tools β€Ί article
[ARTICLE Β· art-83940] src=github.com β†— pub= topic=ai-tools verified=true sentiment=↑ positive

I gave local AI eyes

Developer Simon Willison released 'eyes', a free, open-source Python tool that gives terminal AI agents live vision by streaming phone camera frames as JPEGs to a local folder, with no cloud storage or third-party relay in LAN mode. The tool, requiring only Python 3.8+ and OpenSSL, uses atomic file replacement and a self-signed CA to work around iOS Safari's secure-context restrictions, and offers optional Cloudflare Tunnel remote access with browser-side AES-256-GCM encryption to prevent Cloudflare from seeing frames.

read7 min views1 publishedAug 2, 2026
I gave local AI eyes
Image: source

Give a terminal AI agent (or anything that can read a file) live eyes: point your phone camera at the world and frames land on this machine as plain JPEGs at a stable path.

No app, no App Store, no cloud storage, no third-party relay by default. One Python file, zero pip dependencies. The phone side is a single self-contained web page β€” open it in Safari, tap SNAP or toggle Stream (~2 fps, adjustable).

eyes serve                  # LAN mode β€” nothing leaves your network
eyes latest                 # print the path of the newest frame
eyes wait                   # block until a NEW frame arrives, print its path
eyes gc                     # prune history, printing every file removed

Frames land in ~/.local/state/eyes/

:

latest.jpg

β€” always the newest frame, replacedatomically(os.replace

), so a reader can never see a half-written file. Point your agent's image-reading tool here.history/frame-<timestamp>.jpg

β€” rolling history, capped (500 frames / 200 MB by default) so it cannot fill the disk.latest.jpg

is a hardlink into the history, so each frame is stored once.

The agent loop is simply: "take a photo of the TV" β†’ eyes wait

β†’ read ~/.local/state/eyes/latest.jpg

.

Python 3.8+, the openssl

binary, and system libcrypto (present on essentially every Linux). Remote mode additionally uses cloudflared (see below). If

qrencode

happens to be installed, eyes serve

prints the URL as a terminal QR code; otherwise you type the URL once and add it to the phone's Home Screen β€” it stays stable across restarts.getUserMedia

(camera access) only works in a secure context. On iOS Safari that is stricter than elsewhere: a self-signed certificate is not enough even after tapping through the warning β€” Safari simply does not expose navigator.mediaDevices

on a page whose certificate it does not fully trust. (Chrome lets you bypass this; iOS Safari does not.)

So eyes

generates its own tiny certificate authority, once, in ~/.local/state/eyes/

, and issues the HTTPS server certificate from it. You trust that CA on the phone one time:

  • Run eyes serve

, open the printed URL in Safari. You'll get*"This Connection Is Not Private"*— tapShow Details → visit this website. (This is expected exactly once, before the CA is trusted.) - The page detects that the camera API is unavailable and shows the install flow. Tap Download certificate, then** Allow**. - iOS Settings → Profile Downloaded→** Install**. - Settings → General → About → Certificate Trust Settings→ turn ON** eyes local CA**. - Back to Safari, reload. Green context, camera works. Add to Home Screen.

When your machine's LAN IP changes, eyes

re-issues the server certificate under the same CA automatically β€” no re-trusting on the phone.

The CA lives only on this machine, its key only ever signs certificates for this tool, and nothing is ever installed system-wide here (no sudo, nothing under /etc

).

LAN mode is the default and remote access never turns on by accident. When you do want eyes away from home:

cloudflared tunnel login            # one browser click, once
eyes tunnel setup eyes.example.dev  # creates tunnel + DNS + user systemd unit
eyes tunnel up                      # start (systemctl --user)
eyes serve --remote
eyes tunnel down                    # kill remote access instantly

This uses a Cloudflare Tunnel: an outbound-only connection from this machine, so no port forwarding, no firewall holes, no exposed home IP β€” and the phone gets a real, publicly trusted certificate, so none of the iOS certificate dance is needed remotely.

The catch, handled: Cloudflare terminates TLS at its edge, so a naive setup would let Cloudflare see your camera frames. eyes

therefore encrypts every frame in the browser (WebCrypto AES-256-GCM, fresh random 96-bit IV per frame) before it is sent. The key travels in the URL fragment (…/#k=<key>

) β€” browsers never send the fragment over the network, so the key never reaches Cloudflare or any other intermediary. The server decrypts locally and writes plain JPEGs to disk. In --remote

mode the server refuses plaintext frames outright, drops (and loudly logs) anything that fails authentication/decryption, and disables the latest.jpg

viewing endpoint so plaintext never transits the edge in either direction.

Put Cloudflare Access in front (strongly recommended). Free tier, and it means a stranger who finds the hostname hits a login wall before a single byte reaches your machine: Cloudflare dashboard β†’ Zero Trust β†’ Access β†’ Applications β†’ Add an application β†’ Self-hosted, set the domain to your eyes

hostname, add an Allow policy with Include β†’ Emails β†’ your email, and enable the One-time PIN login method. On the phone you'll enter a PIN emailed to you once per session duration; everything else is unchanged. (eyes tunnel setup

prints this reminder β€” it needs a dashboard visit, there is no good non-interactive path on the free tier.)

Download the official static binary release and verify the checksum against the one published in the release notes before installing:

V=$(curl -sI https://github.com/cloudflare/cloudflared/releases/latest | grep -i location | grep -o '[^/]*$' | tr -d '\r')
curl -sLO https://github.com/cloudflare/cloudflared/releases/download/$V/cloudflared-linux-amd64
curl -sL https://api.github.com/repos/cloudflare/cloudflared/releases/latest | grep -A40 'SHA256' | grep linux-amd64
sha256sum cloudflared-linux-amd64      # must match the line above
install -m 755 cloudflared-linux-amd64 ~/.local/bin/cloudflared

The tunnel runs as a user systemd unit (systemctl --user

), created by eyes tunnel setup

, not enabled at boot: it runs only between eyes tunnel up

and eyes tunnel down

.

Layers, outermost first:

Network position. LAN mode binds the LAN interface only (never0.0.0.0

, and it refuses to bind non-private addresses). Remote mode is an outbound tunnel; nothing listens on the internet from your machine.Cloudflare Access(remote, if you set it up): auth wall before anything reaches you.** URL token.**Every path requires an unguessable token (secrets.token_urlsafe

), compared in constant time. Without it: uniform 404.E2E key(remote): frames are AES-256-GCM ciphertext to everyone but the phone page and this machine.** Input hygiene:**10 MB upload cap, global 8-frames/sec rate limit, payload must be a well-formed encrypted frame or (LAN only) start like a JPEG, history capped so disk can't fill.

What each party can see / do:

Anyone on your LAN without the token: that a TLS server exists on that port. With the token (i.e. they saw the URL over your shoulder or in your shell history): they can view the latest frame and post frames. Rotate witheyes serve --fresh-token

.Cloudflare(remote mode): that your machine keeps a tunnel open, the hostname, request timing/sizes, and the URLpath(including the token β€” which is fine, the token's job is keeping strangers out and Cloudflare is already in front of it). It relays only AES-GCM ciphertext for frames; it never sees the key (fragment) or a single decrypted pixel. Metadata (when/how often you stream) is visible to it; content is not.If the full URL including(screenshot, shared link): the holder still has to get past Cloudflare Access (if configured), but assume compromise: delete#k=

leaks~/.local/state/eyes/e2e.key

and runeyes serve --remote

again (new key), andeyes serve --fresh-token

. Nuclear option:eyes tunnel down

(instant), or delete the tunnel withcloudflared tunnel delete eyes

.This machine holds everything: keys, CA, frames. That's the point β€” sovereign to you. Protect it like you already do.

Known caveats, stated plainly:

  • The LAN hop from cloudflared

to the local server uses the local CA cert with verification off (noTLSVerify

) β€” both processes are on the same machine, so this hop never crosses a wire. - iOS Safari must keep the page open while streaming; eyes

takes a screen wake lock, but iOS will still captures if you switch apps. That is an iOS platform limit, not a bug here. - Frame timestamps use the server's clock, not the phone's.

eyes latest        # -> /home/you/.local/state/eyes/latest.jpg (exit 1 if none)
eyes wait          # blocks until the human takes the next photo, prints path
eyes wait --timeout 120
eyes gc --keep 50  # prints every removed file before removing it

python3 test_eyes.py

β€” 35 headless checks against a throwaway state dir: the AES-256-GCM binding against a published test vector, WebCrypto interop (Node encrypts exactly as the phone page does, Python decrypts), token / size / rate-limit / plaintext-refusal rejections, atomicity of latest.jpg

under concurrent reads, and the CLI. What it cannot cover: a physical iPhone and a live tunnel.

MIT.

── more in #ai-tools 4 stories Β· sorted by recency
── more on @simon willison 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/i-gave-local-ai-eyes] indexed:0 read:7min 2026-08-02 Β· β€”