Blolabel
← Notes
foundational 6 min read

Screenshotting every screen of an iPhone needs no model, it needs 80 labels it will not tap

The expensive part of an agent step is the decision, not the read: 1 to 3 seconds for the model against 159 ms for the tree. Delete the decision and a screen collection becomes a budget of time.

Collecting a screenshot of every screen in an iPhone app is a graph traversal, and a language model is the worst available tool for it: it forgets which of eleven rows it already opened and pays a second of latency and a fraction of a cent to rediscover that each time. Our crawler does the same walk in code at about 1.2 seconds a step. The part that makes it safe to point at someone's personal phone is not the traversal. It is an 80-label refusal list and five type rules that decide what may be tapped at all.

Abstract technical illustration of a branching node graph where most branches are drawn in bright lines and a minority terminate at small blocked markers

The refusal list is the product

A crawler tapping its way through Settings, Mail and a banking app will eventually reach a control that spends money, deletes data, places a call or grants a permission, and it cannot undo any of them. So the tap decision is a series of refusals before it is a traversal. Counted from phoneshell/crawl.py on 20 September 2026:

setsizeexamplesrule
NEVER_TAP80 labelsdelete, erase, buy, place order, call, allow, turn off, factory reset, dictaterefused whatever the settings say
CAREFUL_TAP15 labelsadd, new, compose, schedule, sign in, pairrefused by default, allowed with Plan.cautious = False
BACK_CONTROLS13 labelsback, cancel, close, done, and the chevron and cross glyphsremoved from the forward set: they are how the crawl gets out
MUTATING_TYPES7 element typesSwitch, Slider, Stepper, PickerWheel, Picker, DatePicker, CheckBoxtapping one changes the phone rather than navigating it
INPUT_TYPES5 element typesTextField, SecureTextField, SearchField, TextView, Keytyping is out of scope, so a keyboard is only something to escape
DENY_BUNDLES6 appsPasswords, Keychain, Wallet, Phonenever entered, even when asked for "everything"

Two details in that list took a real run to learn. Matching is by whole label as a word, not as a substring, so "Delete" is refused and "Deleted Items" stays a screen worth having. And "dictate" is on the never-tap list because the Settings search bar's dictation button sent a crawl straight to the home screen on its very first tap: a microphone control starts capturing and can background the app.

What happens to one control refuse first 13 back labels: exit, not a place 7 mutating types: changes state 5 input types: typing is out 80 never-tap labels 15 careful labels, if cautious then resolve, then tap coordinates read before the last excursion point at nothing, so the control is found again by identity immediately before the tap screenshot taken before and after four outcomes crossing: left the app, shot + edge, return by replaying the path no-op: same signature, moved < 0.01 same screen, live content new screen: record, maybe descend 14 dead taps in a row, not 8 A screen of informational rows answers no tap at all and is perfectly healthy: Settings > About is twelve of them. So the threshold sits above the longest honest dead stretch. At 14 the crawler runs a swipe liveness test, and a failed swipe ends the run with a reason, because a wedged input layer reports success while nothing moves. Read from phoneshell/crawl.py (1,876 lines) in Blomega/phoneshell, 20 September 2026
Refusals, then a fresh resolve, then one tap. Everything after that is classification.

Two keys, because a list of 50 contacts is one screen

The question that decides whether a crawl finishes is "have I seen this screen before", and it has two different answers. memory.fingerprint hashes the layout and ignores the contents. tree.screen_signature hashes the contents: every element as type:text[:30]:round(y/20), sorted, SHA-1, first 16 hex characters.

A new signature is worth a screenshot, because collecting is cheap. Only the first three screens per layout are worth descending into, because exploring is not. That single rule, variant_cap = 3, is the difference between a crawl that finishes and a crawl that walks into 50 contact detail screens and reads out someone's address book.

Fingerprints have to be defended. iOS bakes instance data into accessibility identifiers, including UUIDs, MAC addresses and raw pointers, which survive one launch and change on the next. Unstripped, every revisit looks like a screen the crawler has never seen, and the crawl never terminates. The volatile-text rule does the same job for prices, timestamps and distances.

Where a step goes, and which part the crawler deletes model deciding 1,000 to 3,000 ms fixed settle sleep 600 to 800 ms stability gate 504 ms tree read 159 ms screenshot 72 ms 0 1,000 ms 2,000 ms 3,000 Deterministic traversal removes the top bar completely. The crawler's own figure for a step, with the model gone, is about 1.2 seconds, which is why it can run for hours on a budget of time rather than a budget of tokens. Per-step costs measured on a real iPhone, recorded in phoneshell/memory.py; step figure from phoneshell/crawl.py
The expensive part of a step is the decision, not the read. A traversal does not need a decision.

Breadth, budgets, and the sign-in sheet

The crawl is breadth-first, and the reason is what the collection is for. A depth-first crawl of Settings measured here spent its entire budget inside the Apple Account sign-in sheet: ten screens, nine of them the same modal, and it never reached General, Display or Privacy. Someone asking for "every screen in this app" wants the app's shape first and the third level of one branch last, which is the order a queue gives you and a stack does not. The price is navigation, because a queue sometimes has to replay a path from the root.

budgetdefaultwhat it stops
max_screens150a run that collects forever
max_depth4a branch that descends past anything a person would call a screen
max_taps / max_minutes900 / 30wall clock and wear on the device
variant_cap3a 200-row list becoming 200 subtrees
scroll_cap4endless viewport passes on an infinite feed
per_app_screens / per_app_minutes14 / 4one deep app eating a whole-phone scan

Live content gets its own branch, and it was expensive to learn. A screen whose contents tick on their own, a progress ring counting up every second, produces a new content signature on every read. Recording that as a new screen and then relaunching the app to get "back" to the screen the crawl never left cost 12 relaunches in two minutes of one scan. The fix is to compare against the screen's own keys before concluding anything moved.

Check it yourself

git clone https://github.com/Blomega/phoneshell && cd phoneshell

# the refusal sets, counted rather than described
python3 - <<'PY'
import re
src = open('phoneshell/crawl.py').read()
for name in ("NEVER_TAP","CAREFUL_TAP","BACK_CONTROLS","MUTATING_TYPES",
             "INPUT_TYPES","DENY_BUNDLES","APPLE_USER_APPS"):
    body = re.search(name + r"\s*=\s*[\(\{]((?:.|\n)*?)[\)\}]\n", src).group(1)
    print(name, len(re.findall(r'"[^"]*"', body)))
PY

# the two identity keys
grep -n "def screen_signature" -A 4 phoneshell/perception/tree.py
grep -n "_ID_NOISE\|_VOLATILE" -A 6 phoneshell/memory.py

# the liveness threshold and why it is 14
grep -n "_dead_taps >= 14" -B 4 phoneshell/crawl.py

Asking a real iPhone what is installed returns 428 bundles, and roughly 300 of them are invisible UI services such as AXUIViewService and BusinessActionSheet that exist to draw one sheet inside another app. They cannot be launched to a screen. The crawler carries 44 Apple bundle ids it treats as real apps and 13 name shapes it treats as never-an-app, which is a maintenance cost we accept rather than a rule we derived.

What would prove this wrong

The claim with the most to lose is that a label list is a sufficient safety rail. It is falsifiable by a single counterexample: one control on a first-party iOS app whose label is outside all 80 entries and whose tap is irreversible. We expect to find one, which is why every skipped control is logged with its reason. If a full-phone crawl of the 44 recognised Apple apps completes by 31 December 2026 with no irreversible action and no manual repair, the rail held for first-party apps. Third-party apps are a separate claim and this note does not make it.

Sources

  1. Blomega/phoneshell, phoneshell/crawl.py (1,876 lines): the refusal sets, the breadth-first traversal, the 14 dead-tap liveness test and the crawl budgets. Counted 20 September 2026.
  2. Blomega/phoneshell, phoneshell/memory.py: per-step costs measured on a real iPhone, and the identifier-noise rules that keep a layout fingerprint stable across launches.
  3. Blomega/phoneshell, phoneshell/perception/tree.py: screen_signature, a SHA-1 over element type, first 30 characters of text and the row band.
  4. Blomega/phoneshell, phoneshell/safety.py: the confirmation rails for anything irreversible, and the audit log every action is written to.
  5. Blolabel, iOS bakes instance data into accessibility identifiers, 17 September 2026.