{"slug": "i-gave-local-ai-eyes", "title": "I gave local AI eyes", "summary": "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.", "body_md": "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.\n\nNo app, no App Store, no cloud storage, no third-party relay by default.\nOne Python file, zero pip dependencies. The phone side is a single\nself-contained web page — open it in Safari, tap **SNAP** or toggle\n**Stream** (~2 fps, adjustable).\n\n```\neyes serve                  # LAN mode — nothing leaves your network\neyes latest                 # print the path of the newest frame\neyes wait                   # block until a NEW frame arrives, print its path\neyes gc                     # prune history, printing every file removed\n```\n\nFrames land in `~/.local/state/eyes/`\n\n:\n\n`latest.jpg`\n\n— always the newest frame, replaced**atomically**(`os.replace`\n\n), so a reader can never see a half-written file. Point your agent's image-reading tool here.`history/frame-<timestamp>.jpg`\n\n— rolling history, capped (500 frames / 200 MB by default) so it cannot fill the disk.`latest.jpg`\n\nis a hardlink into the history, so each frame is stored once.\n\nThe agent loop is simply: *\"take a photo of the TV\"* → `eyes wait`\n\n→\nread `~/.local/state/eyes/latest.jpg`\n\n.\n\nPython 3.8+, the `openssl`\n\nbinary, and system libcrypto (present on\nessentially every Linux). Remote mode additionally uses\n[ cloudflared](https://github.com/cloudflare/cloudflared) (see below).\nIf\n\n`qrencode`\n\nhappens to be installed, `eyes serve`\n\nprints the URL as a\nterminal QR code; otherwise you type the URL once and add it to the phone's\nHome Screen — it stays stable across restarts.`getUserMedia`\n\n(camera access) only works in a *secure context*. On iOS\nSafari that is stricter than elsewhere: **a self-signed certificate is not\nenough even after tapping through the warning** — Safari simply does not\nexpose `navigator.mediaDevices`\n\non a page whose certificate it does not\nfully trust. (Chrome lets you bypass this; iOS Safari does not.)\n\nSo `eyes`\n\ngenerates its own tiny certificate authority, once, in\n`~/.local/state/eyes/`\n\n, and issues the HTTPS server certificate from it.\nYou trust that CA on the phone one time:\n\n- Run\n`eyes serve`\n\n, open the printed URL in Safari. You'll get*\"This Connection Is Not Private\"*— tap**Show 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\ninstall flow. Tap\n**Download certificate**, then** Allow**. - iOS Settings →\n**Profile Downloaded**→** Install**. - Settings →\n**General → About → Certificate Trust Settings**→ turn ON** eyes local CA**. - Back to Safari, reload. Green context, camera works. Add to Home Screen.\n\nWhen your machine's LAN IP changes, `eyes`\n\nre-issues the server certificate\nunder the same CA automatically — no re-trusting on the phone.\n\nThe CA lives only on this machine, its key only ever signs certificates for\nthis tool, and nothing is ever installed system-wide here (no sudo,\nnothing under `/etc`\n\n).\n\nLAN mode is the default and remote access never turns on by accident. When you do want eyes away from home:\n\n```\ncloudflared tunnel login            # one browser click, once\neyes tunnel setup eyes.example.dev  # creates tunnel + DNS + user systemd unit\neyes tunnel up                      # start (systemctl --user)\neyes serve --remote\neyes tunnel down                    # kill remote access instantly\n```\n\nThis uses a Cloudflare Tunnel: an *outbound-only* connection from this\nmachine, so no port forwarding, no firewall holes, no exposed home IP —\nand the phone gets a real, publicly trusted certificate, so none of the\niOS certificate dance is needed remotely.\n\n**The catch, handled:** Cloudflare terminates TLS at its edge, so a naive\nsetup would let Cloudflare see your camera frames. `eyes`\n\ntherefore\nencrypts every frame **in the browser** (WebCrypto AES-256-GCM, fresh\nrandom 96-bit IV per frame) before it is sent. The key travels in the URL\n**fragment** (`…/#k=<key>`\n\n) — browsers never send the fragment over the\nnetwork, so the key never reaches Cloudflare or any other intermediary.\nThe server decrypts locally and writes plain JPEGs to disk. In\n`--remote`\n\nmode the server **refuses plaintext frames outright**, drops\n(and loudly logs) anything that fails authentication/decryption, and\ndisables the `latest.jpg`\n\nviewing endpoint so plaintext never transits the\nedge in either direction.\n\n**Put Cloudflare Access in front (strongly recommended).** Free tier, and\nit means a stranger who finds the hostname hits a login wall before a\nsingle byte reaches your machine: Cloudflare dashboard → **Zero Trust →\nAccess → Applications → Add an application → Self-hosted**, set the domain\nto your `eyes`\n\nhostname, add an *Allow* policy with *Include → Emails →\nyour email*, and enable the **One-time PIN** login method. On the phone\nyou'll enter a PIN emailed to you once per session duration; everything\nelse is unchanged. (`eyes tunnel setup`\n\nprints this reminder — it needs a\ndashboard visit, there is no good non-interactive path on the free tier.)\n\nDownload the official static binary release and **verify the checksum**\nagainst the one published in the release notes before installing:\n\n```\nV=$(curl -sI https://github.com/cloudflare/cloudflared/releases/latest | grep -i location | grep -o '[^/]*$' | tr -d '\\r')\ncurl -sLO https://github.com/cloudflare/cloudflared/releases/download/$V/cloudflared-linux-amd64\ncurl -sL https://api.github.com/repos/cloudflare/cloudflared/releases/latest | grep -A40 'SHA256' | grep linux-amd64\nsha256sum cloudflared-linux-amd64      # must match the line above\ninstall -m 755 cloudflared-linux-amd64 ~/.local/bin/cloudflared\n```\n\nThe tunnel runs as a **user** systemd unit (`systemctl --user`\n\n), created by\n`eyes tunnel setup`\n\n, not enabled at boot: it runs only between\n`eyes tunnel up`\n\nand `eyes tunnel down`\n\n.\n\nLayers, outermost first:\n\n**Network position.** LAN mode binds the LAN interface only (never`0.0.0.0`\n\n, 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`\n\n), 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.\n\nWhat each party can see / do:\n\n**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 with`eyes serve --fresh-token`\n\n.**Cloudflare**(remote mode): that your machine keeps a tunnel open, the hostname, request timing/sizes, and the URL*path*(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=`\n\nleaks`~/.local/state/eyes/e2e.key`\n\nand run`eyes serve --remote`\n\nagain (new key), and`eyes serve --fresh-token`\n\n. Nuclear option:`eyes tunnel down`\n\n(instant), or delete the tunnel with`cloudflared tunnel delete eyes`\n\n.**This machine** holds everything: keys, CA, frames. That's the point — sovereign to you. Protect it like you already do.\n\nKnown caveats, stated plainly:\n\n- The LAN hop from\n`cloudflared`\n\nto the local server uses the local CA cert with verification off (`noTLSVerify`\n\n) — both processes are on the same machine, so this hop never crosses a wire. - iOS Safari must keep the page open while streaming;\n`eyes`\n\ntakes a screen wake lock, but iOS will still pause 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.\n\n``` php\neyes latest        # -> /home/you/.local/state/eyes/latest.jpg (exit 1 if none)\neyes wait          # blocks until the human takes the next photo, prints path\neyes wait --timeout 120\neyes gc --keep 50  # prints every removed file before removing it\n```\n\n`python3 test_eyes.py`\n\n— 35 headless checks against a throwaway state dir:\nthe AES-256-GCM binding against a published test vector, WebCrypto interop\n(Node encrypts exactly as the phone page does, Python decrypts), token /\nsize / rate-limit / plaintext-refusal rejections, atomicity of `latest.jpg`\n\nunder concurrent reads, and the CLI. What it *cannot* cover: a physical\niPhone and a live tunnel.\n\nMIT.", "url": "https://wpnews.pro/news/i-gave-local-ai-eyes", "canonical_source": "https://github.com/zyads/eyes", "published_at": "2026-08-02 20:16:14+00:00", "updated_at": "2026-08-02 20:52:56.216225+00:00", "lang": "en", "topics": ["ai-tools", "ai-agents", "developer-tools", "computer-vision"], "entities": ["Simon Willison", "eyes", "Cloudflare Tunnel", "cloudflared", "iOS Safari", "WebCrypto", "AES-256-GCM"], "alternates": {"html": "https://wpnews.pro/news/i-gave-local-ai-eyes", "markdown": "https://wpnews.pro/news/i-gave-local-ai-eyes.md", "text": "https://wpnews.pro/news/i-gave-local-ai-eyes.txt", "jsonld": "https://wpnews.pro/news/i-gave-local-ai-eyes.jsonld"}}