The trade
Frontier reasoning models have no video input. Images they do accept, but an image costs tokens on the turn you send it and on every turn after it, because it stays in the conversation history. A long session with three screenshots in it pays for those screenshots dozens of times.
So split the job. A cheap multimodal model looks and hands back text. The expensive model reasons over that text. The description is usually smaller than the media it replaces, it is not re-encoded on later turns, and Claude never receives the video at all.
local file, or a direct media URL
→ look.py
→ Gemini 3.5 Flash # looks, never decides
→ text description
→ Claude Code # decides, never looks
→ the component, and the fix
We run this daily. A cofounder records himself hitting a UI (User Interface) problem with Cap, pastes the link in chat, and Claude reads the description, finds the component, and fixes it.
| What you send | Input tokens |
|---|---|
| One screenshot, described once by the eyes | 1,080 |
| The same screenshot living in Claude's context | 1,080 per turn |
| One second of silent screen recording | 60 |
| One second of the voice track over it | 25 |
| A 3-minute narrated recording, timestamped | ~15,300 |
Measured July 2026 against gemini-3.5-flash, from the usageMetadata a real call returns — one 118-second Cap recording, sent twice: 7,080 input tokens with the audio track stripped, 9,991 with it. The 3-minute figure is that rate multiplied out. Do not use countTokens for this; it estimates from container metadata and reported identical audio tokens for the file whose audio I had removed. Gemini samples video at one frame per second, so cost tracks duration, not resolution — and a click shorter than a frame interval can be missed. Output tokens are extra and unbounded: the script sets no maxOutputTokens. At Flash list prices a 3-minute recording lands in the low single-digit cents, under a cent on the flash-lite tier. Confirm against current rates before you quote a number.
The witness does not need to be smart. It describes. Claude decides.
01The script
No third-party Python packages. It takes a local file, a direct media URL or a previously uploaded file id, plus a question. It sends that to the Gemini API (Application Programming Interface) and prints the model's text answer. Files under 19MB go inline as base64. Larger files go through the Files API with upload-and-poll, so a long recording does not have to fit in one request.
Five decisions carry most of the value:
- A direct media URL never touches your disk.
file_data.file_uritakes a public URL and Google fetches it server-side. It has to be the media itself: a share page is HTML and fails, so resolve it first (§02). A local path always wins over URL interpretation. If Google cannot fetch the URL the API returns a 400 and the script prints thecurlfallback. - Thinking is off where the model allows it.
thinkingConfig.thinkingBudget: 0removes the model's internal reasoning tokens. On describe-the-screen prompts those were 93% of the output bill and changed no answers. Flash-lite rejects the zero budget, so the script retries once with the model's default. SetLOOK_THINK=<n>to ask for thinking back. - Uploads are reusable. A file over the inline limit is uploaded on every run, but the script prints
uploaded files/<id>on stderr. Pass that id back for follow-up questions instead of re-sending 90MB. Uploads expire after about 48 hours. - The voice track comes too. A narrated recording is transcribed alongside the screens, in the same call, over a remote URL as well as inline. No second request, no
ffmpegextraction, no separate transcription service. The default question asks for it, so the description comes back with the speech attributed per timestamp. Seeing the screen makes it a better transcriber than a dedicated one — see below. - Errors, not tracebacks. The failures you actually hit are one line: the HTTP status and the API's own message, a missing file, an unsupported MIME (Multipurpose Internet Mail Extensions — the
video/mp4-style content type), or a directory where a video was expected.
#!/usr/bin/env python3
"""look.py <file|url|files/id> [question...] — cheap eyes: describe a video/image via the Gemini API."""
import base64, functools, json, mimetypes, os, socket, subprocess, sys, time
import urllib.error, urllib.parse, urllib.request
BASE = "https://generativelanguage.googleapis.com"
MODEL = os.environ.get("LOOK_MODEL", "gemini-3.5-flash")
try:
THINK = int(os.environ.get("LOOK_THINK", "0")) # 0 = no thinking tokens billed
except ValueError:
THINK = 0
DEFAULT_Q = ("Describe this recording shot by shot: timestamps, every screen shown, "
"all on-screen text, every click/scroll/interaction, everything said "
"aloud, and any moments of hesitation, confusion, or UI glitches.")
INLINE_LIMIT = 19_000_000 # measured: 18.9MB raw (~25MB base64) returns 200 — do not lower this
MIMES = { # what the Gemini API accepts, plus the aliases Python's mimetypes emits
"video/mp4", "video/mpeg", "video/mov", "video/quicktime", "video/avi", "video/x-msvideo",
"video/x-flv", "video/mpg", "video/webm", "video/wmv", "video/x-ms-wmv", "video/3gpp",
"image/png", "image/jpeg", "image/webp", "image/heic", "image/heif",
"audio/wav", "audio/x-wav", "audio/mp3", "audio/mpeg", "audio/aac", "audio/x-aac",
"audio/ogg", "audio/flac", "audio/x-flac", "audio/aiff", "audio/x-aiff", "audio/mp4",
}
HINT = "" # extra stderr advice printed alongside a 400
def die(msg):
sys.exit("look.py: " + msg)
@functools.cache
def key():
k = os.environ.get("GEMINI_API_KEY") or ""
if not k:
try:
k = open(os.path.expanduser("~/.config/gemini/key")).read().strip()
except OSError:
die("no GEMINI_API_KEY set and no readable ~/.config/gemini/key")
return k
def api(path, data, headers, timeout=600, soft400=False):
req = urllib.request.Request(BASE + path, data=data,
headers={"x-goog-api-key": key(), **headers})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "replace").strip().replace("\n", " ")[:2000]
if soft400 and e.code == 400 and "Request contains an invalid argument" in body:
return None # flash-lite rejecting thinkingBudget=0 — caller retries without it
print(f"ERROR {e.code}: {body or '(empty body)'}", file=sys.stderr)
if e.code == 400 and HINT:
print(HINT, file=sys.stderr)
if e.code == 404 and "/" in MODEL:
print(f"LOOK_MODEL={MODEL} has a provider prefix; use a bare id "
f"like gemini-3.5-flash", file=sys.stderr)
sys.exit(1)
except (socket.timeout, TimeoutError):
die(f"timed out after {timeout}s on {path}")
except urllib.error.URLError as e:
die(f"network error on {path}: {e.reason}")
def mime_of(path):
m = mimetypes.guess_type(path)[0] or subprocess.run(
["file", "-b", "--mime-type", path], capture_output=True, text=True).stdout.strip()
if m not in MIMES:
die(f"unsupported media type {m or '(unknown)'} for {path}")
return m
def upload(path, mime):
try:
blob = open(path, "rb").read()
except OSError as e:
die(f"cannot read {path}: {e.strerror}")
if len(blob) < INLINE_LIMIT:
return {"inline_data": {"mime_type": mime, "data": base64.b64encode(blob).decode()}}
up = api("/upload/v1beta/files", blob,
{"X-Goog-Upload-Protocol": "raw", "Content-Type": mime})
name, uri = up["file"]["name"], up["file"]["uri"]
for _ in range(90): # videos need server-side processing before they can be used
state = api(f"/v1beta/{name}", None, {}, timeout=30)["state"]
if state == "ACTIVE":
print(f"uploaded {name} (expires ~48h) — pass this id instead of the file "
f"for follow-up questions", file=sys.stderr)
return {"file_data": {"file_uri": uri, "mime_type": mime}}
if state == "FAILED":
die(f"Gemini failed to process {name}")
time.sleep(2)
die(f"still processing after 3min — it may finish anyway; re-run: look.py {name} <question>")
def media_part(inp):
global HINT
if os.path.exists(inp): # a local file always wins over URL/id interpretation
if os.path.isdir(inp):
die(f"{inp} is a directory (a .cap project is a directory, not a video) — run: "
f"cap export {inp} -o /tmp/out.mp4 --json, then pass /tmp/out.mp4")
return upload(inp, mime_of(inp))
if urllib.parse.urlparse(inp).scheme in ("http", "https"):
HINT = (f"if Google cannot fetch that host, download it first: "
f"curl -L -o /tmp/video.mp4 '{inp}' — then pass /tmp/video.mp4")
return {"file_data": {"file_uri": inp}}
if inp.startswith("files/"): # reuse of an earlier upload
return {"file_data": {"file_uri": f"{BASE}/v1beta/{inp}"}}
die(f"no such file: {inp}")
def main():
if len(sys.argv) < 2:
sys.exit(__doc__)
inp, q = sys.argv[1], " ".join(sys.argv[2:]) or DEFAULT_Q
path, hdrs = f"/v1beta/models/{MODEL}:generateContent", {"Content-Type": "application/json"}
body = {"contents": [{"parts": [{"text": q}, media_part(inp)]}],
"generationConfig": {"thinkingConfig": {"thinkingBudget": THINK}}}
resp = api(path, json.dumps(body).encode(), hdrs, soft400=(THINK == 0))
if resp is None: # gemini-3.5-flash-lite rejects thinkingBudget=0; retry with its default
del body["generationConfig"]
resp = api(path, json.dumps(body).encode(), hdrs)
cand = (resp.get("candidates") or [{}])[0]
if cand.get("finishReason", "STOP") != "STOP":
print(f"WARNING: finishReason={cand['finishReason']}", file=sys.stderr)
parts = cand.get("content", {}).get("parts") or []
text = "".join(p["text"] for p in parts if "text" in p) # 3.x replies are multi-part
if not text.strip():
die("no text in response: " + json.dumps(resp)[:2000])
print(text)
if __name__ == "__main__":
main()
Setup
- Get a key: https://aistudio.google.com/apikey
- Store it, or export
GEMINI_API_KEYinstead. - Save the script and make it executable.
- Test it on any image you have.
mkdir -p ~/.config/gemini ~/.claude/scripts
printf '%s' 'YOUR_KEY' > ~/.config/gemini/key && chmod 600 ~/.config/gemini/key
# save the script above as ~/.claude/scripts/look.py, then:
chmod +x ~/.claude/scripts/look.py
~/.claude/scripts/look.py photo.png "what color is this?"
Swapping models is one variable: LOOK_MODEL=gemini-3.5-flash-lite for bulk work. Use bare model ids — a google/ prefix is OpenRouter syntax and returns 404 here. The question matters more than the model. Ask for timestamps, on-screen text, clicks and hesitation; do not ask it to "describe this video".
How good is the transcription?
Good enough that a dedicated speech model was the one making mistakes. I sent the same 118-second narrated recording three ways and compared word by word.
| Transcriber | Agreement with Gemini |
|---|---|
| Gemini 3.5 Flash, second run | 94.1% |
| Whisper large-v3-turbo (base.en), local | 94.0% |
Grok Speech to Text, api.x.ai/v1/stt | 91.1% |
Gemini disagrees with a purpose-built speech model about as much as it disagrees with itself, so most of the gap is filler words — gonna against going to, alright against all right. The disagreements that carry meaning went Gemini's way both times. Both audio-only models heard "connect your cloud"; the word is "Claude", and Gemini gets it because the screen behind the voice says Sign in with your Claude account. Grok also turned "yeah, it didn't work" into "yeah, it's gonna work" — in a bug report, the one sentence you cannot afford to have inverted.
That is the argument for a model that watches and listens at once. An audio-only transcriber has no way to know which homophone your product is called, and no way to check a claim against the screen that provoked it. Grok Speech to Text is worth the call when you need word-level timestamps or speaker separation, which Gemini does not return — at $0.10 an hour it is not a budget decision. It is not worth it for accuracy.
02Recording
Cap is an open-source screen recorder with a CLI (Command-Line Interface) built for agents to drive: every command takes --json.
curl -fsSL https://cap.so/install-cli.sh | sh
cap auth status --json # uploads need a Cap login or CAP_API_KEY
cap record # start a recording (or use the desktop app)
cap recordings list --json # find recordings in the desktop library
cap export <project.cap> -o out.mp4 # a .cap project is a directory, not a video
cap upload out.mp4 # returns a shareable cap.so link
Your cofounder records the bug, uploads, and pastes the link. One catch on the receiving end: cap.so/s/<id> is an HTML page, not a video. The page's og:video meta tag carries the videoId, and this URL is the video itself:
https://cap.so/api/playlist?videoId=<id>&videoType=mp4
Hand that to look.py. Gemini fetches it server-side, so the recording never lands on your disk. Drop &videoType=mp4 and the endpoint returns a 547-byte error blob instead of a video. If the API answers 400 Cannot fetch content from the provided URL, Google could not read it: run curl -fL -o video.mp4 "<url>" and pass the file. Keep the -f — without it curl writes an HTML error page to video.mp4, and the script trusts the extension.
A small wrapper, caplook, does the link resolution, the .cap export and the "latest recording in the library" lookup, then calls look.py. Everything below works without it.
Cap transcribes uploads itself — a share page carries transcriptionStatus: COMPLETE plus a generated title and summary, readable in the Transcript tab. There is no CLI command and no public endpoint for it, so it is not something a script can collect today. videoType=audio on the playlist endpoint does serve the audio track on its own, if you want the voice without the pixels.
03The skill
A skill is a markdown file that tells Claude Code when to reach for a tool and how to use it well. Drop this at ~/.claude/skills/cap-review/SKILL.md and any session can watch a recording on request.
---
name: cap-review
description: Watch a cofounder's cap.so screen recording (or any video/screenshot) with cheap Gemini "eyes" and turn it into concrete UI / onboarding fixes. Use when the user says "cap review", "watch this video", "my cofounder sent a recording", pastes a cap.so link, or points at an .mp4/.mov/.webm showing a UI problem. Pass the whole video to look.py in one call; never scrub frames with ffmpeg unless one specific frame decides something. Claude never ingests the video itself, the script returns a text description to reason over.
---
# cap-review
`~/.claude/scripts/look.py <file|url|files/id> [question]` sends media to the Gemini API at
generativelanguage.googleapis.com and prints text. Key: `GEMINI_API_KEY`, else `~/.config/gemini/key`.
Model `gemini-3.5-flash`; `LOOK_MODEL=gemini-3.5-flash-lite` for bulk runs. Use bare model ids. A
`google/` prefix is OpenRouter syntax and 404s. `gemini-3.6-flash` and `gemini-flash-latest` exist
if you ever upgrade.
`~/.local/bin/caplook [file|url|cap-link|latest] [question]` resolves cap links, `.cap` projects and
the latest library recording (steps 1, 2 and 4 below), then calls look.py.
## Input resolution
0. Screenshot pasted into the conversation: look at it. No look.py round trip.
1. Local file: pass the path.
2. cap.so or cap.link share page: fetch the HTML, take the videoId from the `og:video` meta, pass
`https://cap.so/api/playlist?videoId=<id>&videoType=mp4` straight to look.py. Gemini fetches that
URL itself, so nothing downloads. Without `&videoType=mp4` the endpoint returns a 547-byte error blob.
3. `ERROR 400: Cannot fetch content from the provided URL` means the host refused Google's fetcher.
Run `curl -L -o /tmp/video.mp4 '<url>'` and pass the file.
4. `.cap` project (a directory, not a video): `cap export <project.cap> -o /tmp/out.mp4 --json` first.
5. Follow-up question about a big video: look.py prints `uploaded files/<id>` on stderr. Pass that id
back instead of uploading again. It expires after about 48 hours.
## Workflow
1. Ask the eyes to describe, never to judge. They have no product context, so "is anything broken or
stale or wrong here" gets a confident no. They are the witness; you are the judge.
2. One call, whole video. The built-in default question already asks for timestamps, on-screen text,
clicks, hesitation and everything said aloud, so a narrated recording comes back with the speech
attributed per timestamp. Write your own only when the review has a specific focus, e.g. "give me
only the transcript, verbatim" when the words matter more than the screens.
3. Map the description onto the codebase (fff / codegraph) to find the screens and components.
4. Report: timestamp, what the video shows, the code location, the proposed fix. Then fix what the
user asked for.
5. The one exception to the no-frames rule: when a single frame decides something subtle (pixel
alignment, exact copy), run `ffmpeg -ss <t> -i video.mp4 -frames:v 1 /tmp/frame.png` and read that
frame directly.
## Mechanics
Files under 19MB go inline; larger ones upload and wait up to 3 minutes for server-side processing
("still processing" means re-run, it may have finished by then). Thinking is off by default, set
`LOOK_THINK=<n>` to turn it back on.
Audio rides along. A Cap recording's voice track is ingested and transcribed with the video, over a
remote URL as well as inline — no separate audio call, no ffmpeg extraction. Measured on a 118s Cap
recording (`gemini-3.5-flash`, `usageMetadata`): 7,080 input tokens with the audio stripped, 9,991
with it, so ~60 tokens/sec of video plus ~25 tokens/sec of speech, all reported under the VIDEO
modality. A 3-minute narrated recording is ~15k input tokens, a single frame ~1,080. Do not trust
`countTokens` for this — it estimates from container metadata and reports identical AUDIO tokens for
a file whose audio track has been removed.
## Sending a recording out
Preflight `cap auth status --json`. If it reports unauthenticated, tell Matthias to sign in to Cap
Desktop or set `CAP_API_KEY` rather than continuing silently. Then `cap record`,
`cap export`, `cap upload`, and hand back the link. Before recording: deploy the fix, reset the app
or auth state, verify it live, then hit record.
04The habit
A skill still has to be reached for. These lines in the global ~/.claude/CLAUDE.md make the loop run without prompting, in both directions.
## Cap video workflow (core)
Screen recordings are a core feedback loop with my cofounder, both directions:
- Incoming — a recording (cap.so link or file) showing a UI/onboarding
problem: use the `cap-review` skill. Cheap eyes via look.py, map findings
to code, fix.
- Outgoing — whenever I demo something and want my cofounder to see it:
use the `cap` CLI to record → export → upload and give me the shareable
link to send. Preflight `cap auth status --json`; if it reports
unauthenticated, tell me to sign into Cap Desktop or set `CAP_API_KEY`
instead of continuing.
Zero ceremony: a pasted cap link or video file IS the request. Watch it.
Don't ask permission, don't make me name the skill.
Never hand-transcribe a video or paste frames to the main model — the
look.py eyes exist for that. One exception: a single decisive frame pulled
with `ffmpeg -ss <t> -i video.mp4 -frames:v 1`.
05In practice
My cofounder hit a broken OAuth (Open Authorization) flow in our product's embedded terminal. He recorded four minutes of it with Cap and sent the link.
Claude handed the share URL to the eyes, got the recording back shot by shot, and reported: the paste handler in the embedded terminal double-fires, the code lands mangled, the login fails at 2:41 with "Invalid code", and here is the file to fix. Watching cost about four cents. Nobody transcribed anything.
06What it does not do
- It observes, it does not diagnose. The eyes have no product context. Ask them "is anything wrong here" and you get a confident no. Ask for timestamps, text and clicks, and judge the result yourself.
- One frame per second — for the picture only. A flash of a toast, a cursor flick or a sub-second error can fall between frames. The audio is not sampled that way, so narration survives even where the video misses the moment it describes.
- Media type comes from the filename. An HTML error page saved as
video.mp4is sent as video and fails at the API, not at your shell. - No retries. A 429 or a 5xx exits. There is no backoff and no
Retry-Afterhandling; re-run it. - Recordings leave your machine. The file, or the URL, goes to Google. Read their retention terms before you point this at anything covered by a client agreement.
- On-screen text is untrusted input. A recording can contain wording aimed at the agent reading it. Treat the description as evidence, review the diff, run the tests.
Measured July 2026 on macOS 26.5, Python 3.14, Cap CLI 0.1.0, against gemini-3.5-flash and gemini-3.5-flash-lite — both live on v1beta at the time of writing. The look.py and SKILL.md blocks above are generated from the files on disk, not retyped.