Blolabel
← Notes
foundational 5 min read

iOS gives an agent one interactive surface, so the only honest background mode is yielding

There is no second screen to put an agent on. What you build instead is a watchdog: a foreground check and a 64 by 64 greyscale diff, running faster than a person can pick up their phone.

An iPhone has one interactive surface and both the owner and the agent want it. Apple's external display scene role is literally named UIWindowSceneSessionRoleExternalDisplayNonInteractive, and since iOS 27 shipped on 14 September 2026 an app built against the new SDK is not handed even that scene from its Info.plist manifest. So the honest version of "run the agent in the background" on iOS is not a second screen. It is a watchdog that gives the phone back, and ours does it within one 0.8 second poll of the owner touching it.

Abstract technical illustration of a single bright rectangular plane with two sets of control lines converging on it from opposite sides, one set dimmed while the other is lit

What changed, and when

External displays on iOS have never been a second place to put an interactive app. The scene role carries "NonInteractive" in its name, and the developer guidance is blunt about the consequence: "this view will not be interactive" (João Gabriel, Cindori, 1 December 2024). Mirroring is a picture of the phone, not a second phone.

iOS 27 tightened it further. osy, the maintainer of UTM, filed pull request 7873 on 16 September 2026 with a one-line description of the breakage: "Apps built with the iOS 27 SDK are no longer offered UIWindowSceneSessionRoleExternalDisplayNonInteractive scenes from the Info.plist scene manifest, so on iOS 27 the external display stayed black while the VM ran on the device." The fix registers the external content as a SwiftUI scene accessory on iOS 27 while iOS 26 and earlier keep the old manifest path. A non-interactive escape hatch got narrower two days after the OS shipped.

datewhateffect on an agent that wants its own surfacesource
1 Dec 2024Current guidance on external display scenes: the role is ...ExternalDisplayNonInteractivethe second display can show pixels, it cannot receive a tapcindori.com, João Gabriel
14 Sep 2026iOS 27 releasedships on iPhone 18 Pro and as an update to iPhone 17 hardwareWikipedia, iOS 27
16 Sep 2026UTM PR 7873: iOS 27 SDK stops delivering the non-interactive external display scene from the Info.plist manifestthe one remaining second-surface path needs new code per OS versiongithub.com/utmapp/UTM/pull/7873
this repophoneshell/coexist.py: one interactive display, one digitizer, so the agent yieldsthe design consequence we actually shippedBlomega/phoneshell

One caveat on our own note. The coexist.py header records that the iOS 26.4 SDK headers declare only the non-interactive role and that the interactive one was deprecated in iOS 16. The machine this note was written on has no iOS SDK installed, so we could not re-read the header today. The externally checkable half, the constant's name and the iOS 27 manifest change, is cited above.

The watchdog, with its real constants

If the agent cannot have its own surface, the rule has to be possession in short bursts. The agent brackets its own work with claim(); anything the watchdog sees outside a claim is somebody else, which in practice means the person holding the phone.

Detecting the owner in 0.8 seconds, without reading the tree every 0.8 s, sample /wda/activeAppInfo ~100 ms /screenshot 90 to 130 ms /source?format=json ~2.2 s never polled: slower than the loop is this us? claim() suppresses 2.5 s release() suppresses 1.2 s more inside a claim the baseline is refreshed, never judged somebody else foreground bundle changed OR diff > 0.03 of a 64x64 grid = 123 of 4,096 cells moving more than 12 grey levels the agent stands down human_active stays true for a 6 s grace period after the last change wait_for_turn() blocks, re-checking every 0.4 s, up to 120 s then it reports what it was waiting for, rather than failing the task silently Not a policy choice: one display, one digitizer. Constants read from phoneshell/coexist.py and phoneshell/perception/screen.py, 20 September 2026
The whole detector is a foreground check and a 64 by 64 greyscale diff, because the only read that would be authoritative takes about 2.2 seconds and the loop runs in 0.8.

The threshold is worth stating exactly, because "3% of the screen" sounds vague and is not. visual_difference converts both screenshots to greyscale, resizes each to 64 by 64, and counts cells that differ by more than 12 of 255. Above 0.03, that is 123 of 4,096 cells, the watchdog calls it a change. A blinking cursor does not reach it. Opening the notification shade does.

One minute of a shared phone agent the owner has the phone grace 6 s agent resumes first touch detected on the next sample, at most 0.8 s later every further change restarts the 6 s grace timer 0 s 18 s 38 s 60 s Presence counts samples and yields, so a run can report how often it stood down rather than how long it took. Shape and constants from phoneshell/coexist.py; the timeline is illustrative, not a recorded trace.
Bursts, not background. The agent never holds the phone for longer than it can give it back.

What this means if you are building one

Three consequences follow, and none of them are about model quality.

Any unattended iPhone task is interruptible by definition, so it needs a resume point, not a retry. A task that assumes uninterrupted possession of someone's personal phone will fail on the first incoming call, and it will fail in a way that looks like an agent error in your logs.

The agent cannot be invisible even for one call. Creating a WebDriverAgent session switches the foreground app, which we measured and wrote up separately, so the first act of any automation run is visible to whoever is holding the phone. Politeness has to be designed in, because stealth is not available.

Screen-change detection has to be cheap enough to run faster than a human reaction. That rules out the accessibility tree, which is the authoritative read and takes about 2.2 seconds on this rig. The cheap pair, a bundle id and a thumbnail diff, is what fits inside the loop. It is less accurate, and that is the correct trade for a watchdog whose only job is to notice that the phone is no longer yours.

Check it yourself

# what the two cheap reads actually cost on your rig
for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w "activeAppInfo %{time_total}s\n" http://127.0.0.1:8100/wda/activeAppInfo
  curl -s -o /dev/null -w "screenshot    %{time_total}s\n" http://127.0.0.1:8100/screenshot
done
curl -s -o /dev/null -w "source        %{time_total}s\n" "http://127.0.0.1:8100/source?format=json"

# the constants quoted above
grep -n "poll\|grace\|settle_after_action\|0.03" phoneshell/coexist.py
grep -n "def visual_difference" -A 8 phoneshell/perception/screen.py

For the iOS 27 half, the primary document is the pull request: github.com/utmapp/UTM/pull/7873. Build any app that declares an external display scene in its Info.plist against the iOS 27 SDK, connect a display, and watch it stay black.

What would prove this wrong

The claim is that iOS offers no second interactive surface an agent could occupy while its owner uses the phone. One counterexample retires it: a shipping iOS 27 or iOS 28 API that delivers touch events to a scene the built-in display is not showing, or a WebDriverAgent tap that lands while a finger is on the screen without either input being dropped. If Apple ships an interactive secondary scene role in the iOS 28 betas by June 2027, this note is obsolete and the yielding watchdog becomes a fallback rather than the design.

Sources

  1. osy, UTM pull request 7873, 16 September 2026: apps built with the iOS 27 SDK are no longer offered UIWindowSceneSessionRoleExternalDisplayNonInteractive scenes from the Info.plist scene manifest.
  2. Joao Gabriel, Displaying SwiftUI content on an external screen in iOS, Cindori, 1 December 2024: the external display scene role is non-interactive, "this view will not be interactive".
  3. iOS 27, released 14 September 2026.
  4. Apple, UIWindowScene, UIKit documentation.
  5. Blomega/phoneshell, phoneshell/coexist.py: the 0.8 s poll, the 6 s grace period, the 2.5 s claim and the measured cost of each read.
  6. Blomega/phoneshell, phoneshell/perception/screen.py: visual_difference, greyscale 64 by 64, cells differing by more than 12 of 255.