Screenshots capture
Status summary: prototype implemented, live e2e green against local dev
(vision description + tags + idempotent event, end to end). Mobile side is
JS-only (ships OTA); the PR also carries a small os fix it exposed —
ai.toMarkdown now accepts bytes/base64 for blob, because a sandbox-made
Blob can't cross the RPC boundary (the documented agent recipe was broken).
Main missing pieces: real-phone dogfood pass, and the follow-ups listed at
the bottom (auto-sync, share sheet, embedding search).
What it is#
The mobile app gets a Screenshots screen (drawer item). You pick screenshots from the photo library (PHPicker — no new permission, no native module). For each:
- bytes →
itx.filesat/screenshots/inbound/<sha256>-<filename> - one
capabilityHost.runScriptcall runs server-side:files.bytes → ai.toMarkdown(Cloudflare's vision model writes a natural-language description) →ai.runcheap text model multi-tags the description → appendsevents.iterate.com/screenshots/capturedto the/screenshotsstream, idempotency-keyed by content hash - the screen lists captures newest-first from
streams.get('/screenshots').getEvents(...)with client-side search over description + tags
"Semantic search" for the prototype = keyword search over the vision model's descriptions ("train ticket" matches because the description says train ticket). True embedding search is a follow-up — the repo has no Vectorize binding today.
Where metadata lives (the persistence question)#
Stream + files, mirroring email ingress:
- bytes:
itx.files(R2), content-hash keyed — retries overwrite, never dup - metadata + markdown + tags: event payloads on the
/screenshotsstream — the platform's canonical home for facts-with-history - NOT a repo/workspace (screenshots aren't review/diff material, volume too high), NOT kv (policy knobs only, 64KiB caps)
- follow-up home for real querying: a screenshots stream processor with reduced state (the email pattern); the event vocabulary is designed so that processor can be added later without re-ingesting
Tags#
Multi-tag, overlap allowed (deliberately not first-match-wins). Starter taxonomy, LLM may also add up to 2 novel kebab-case tags:
transient (OTP codes, one-off confirmations) · media (posts/articles/
memes worth keeping) · logistics (tickets, bookings, travel) · receipt ·
bug-report (software misbehaving) · iterate (about iterate itself) ·
code (code/terminal/dev tools) · conversation (chat/email screenshots) ·
reference (info to keep long-term)
Taxonomy is data in apps/mobile/src/lib/screenshots.ts — expect churn.
Checklist#
-
src/lib/screenshots.ts: event type + payload, tag taxonomy, file path/idempotency-key derivation, pipeline script builder (JSON-embedded input, injection-safe), client-side search filter apps/mobile/src/lib/screenshots.ts; script is evaluated (not string-asserted) in its unit test - unit tests for script builder + search filter (root CI runs these) 7 tests incl. hostile-filename injection and dedup short-circuit
-
pickImagesgains a requiredselectionLimitparam (chat passes 6, screenshots 20) attachments.ts also now carries width/height - Screenshots screen: capture → per-item progress → list with signed-url thumbnails, tag chips, search box; drawer item + route union app/project/[projectId]/screenshots.tsx
- dedup: skip upload when
getEvent({ idempotencyKey })already exists client-side pre-check + server-side check inside the script + append idempotency key - live e2e (
apps/mobile/e2e/screenshots.e2e.test.ts): tiny PNG fixture through the whole pipeline — also the repo's first proof that image→toMarkdown works (cf-ai-to-markdown example ise2eProven: false) passes against local dev; red-square fixture gets a real vision description
Explicitly cut from #2405 (still good ideas, later)#
- media-library auto-sync of the Screenshots album (
expo-media-library= native module = fingerprint bump = new EAS build; the picker ships today) - rules engine, OCR layer, obligation-pattern LLM step
- server-side processor +
alreadySyncedreconciliation - backfill job,
BGAppRefreshTask
Guesses and assumptions#
- [guess] "capture" = pick-from-library, since that ships OTA today; the share-sheet extension and auto-sync are the obvious next asks
- [guess] tag taxonomy above — derived from Misha's examples; the LLM's novel-tag allowance is the pressure valve
- [guess] tagging model
@cf/meta/llama-3.2-3b-instructover the description text (not the pixels) — cheap, good enough for a prototype - [guess] search stays client-side over one
getEventspage until volume hurts, then the processor + reduced state follow-up
Implementation log#
-
(starting) design derived from two exploration passes; all seams verified against main @ 207823a45:
runScriptarbitrary code,appendidempotency replay,getEvent({idempotencyKey}),files.putbase64FileData, toMarkdown image support via Cloudflare converter. -
(found)
ai.toMarkdown({ name, blob: new Blob(...) })— the documented recipe — fails from script sandboxes: capnweb can't serialize Blob ("Could not serialize object of type Blob"). Fixed at the product surface:CfMarkdownDocument.blobnow takes the wholeFileDataunion (bytes/base64/Blob), coerced server-side. Agent prompts,__describe, and the cf-ai-to-markdown example updated to the bytes form. -
(found) the coerced Blob needs a non-empty
type— Workers AI's binding rejectstype: ""with a bare zod "Too small: expected string to have >=1 characters".application/octet-streamworks; the converter picks the format from thenameextension (result carries the real mimeType). -
(found)
@cf/meta/llama-3.2-3b-instructanswers OpenAI-style (choices[0].message.content), not.response— tag parser handles both. -
default agent prompt was ~70 chars under its 17000-char ceiling; the recipe edit had to stay terse (agent-prompt-budgets.test.ts enforces it).
Feedback round 1 (dogfood, 2026-08-10)#
Misha's feedback after capturing 20 real screenshots, all addressed:
- optimistic UI: picked items show immediately as pending cards with per-item status; pipeline runs 3-wide (was strictly sequential — a 20-item batch crawled) mapWithConcurrency in lib/media.ts
- tags were wild guesses (everything
bug-report): droppedbug-report/iteratefrom the taxonomy, tags now come from a vision model looking at the PIXELS (not the description), prompt is explicitly conservative, empty tag list is a valid answer - re-tag from fresh: "Re-analyze" on an expanded card reruns the whole
pipeline and appends
media/processed; deriveMediaList overlays the latest result per item — prompt/model improvements apply retroactively - tap thumbnail → full-screen viewer
- full text search: one llama-4-scout vision call per item returns {transcript, tags} as JSON; transcript is verbatim OCR, searched alongside the description (e2e asserts the fixture's rendered text comes back verbatim)
- renamed screenshots → media (
/mediastream,events.iterate.com/media/*,/media/inbound/*files, Media screen): camera photos are equally valid input;screenshotis now just a tag the vision model applies. NOTE: pre-rename dogfood captures live on/screenshotsand won't show in the Media screen. - identify source app/website per item — deferred (strays into the rules territory of #2405); revisit after dogfooding tags/search
- full-screen viewer chrome — deferred (Misha, round 2): pinch-zoom; tap toggles a chrome overlay showing tags + description (minimised, "See more" expands to a scrollable half-screen); swipe-down dismisses (social-media style, since tap no longer closes)
Feedback round 2 (2026-08-10)#
- tapping a pending (still-analyzing) thumbnail opens the full-screen viewer too uses the local previewUri
- agents can now discover media:
media-searchexample in the itx catalogue (apps/os/src/itx/examples-source.ts) — agents find it via itx.docs.search("media"/"screenshot"), it reads /media events, overlays processed results, filters by keyword, mints signed URLs. The real surface later: MediaProcessor reduced state + itx.media.search/list/get (already in follow-ups).
Additional log#
- toMarkdown transcribes clean text images perfectly but summarizes dense screenshots (its prompt is Cloudflare-fixed) — hence the separate transcript call. Vision model shootout on dev: llama-4-scout and mistral-small-3.1 both transcribe verbatim via OpenAI-style image messages; llava garbles; llama-3.2-11b-vision needs a license handshake; moondream rejects the messages shape. Picked scout.
- e2e fixture is a checked-in PNG (e2e/fixtures/ticket.png, rendered with AppKit) reading "Train to Florence / Seat 21A" — the transcript assertion is a real OCR check.