Token-efficient tooling for driving an iOS Simulator from a Claude Code agent — for iOS developers who want an agent to exercise their SwiftUI app without burning the context window on it.
An agent asked to "check that the sheet animates in and the email field accepts input" pays for that check in observation tokens. Measured across seven existing tools, the same screen costs between 78 and 96,873 tokens to observe — a 1,240x spread — while latency varies only 3x (0.36–1.4 s). Observation tokens, not latency, are the dominant cost.
So MobileController does not reimplement device automation. It adopts
agent-device (callstack, MIT) as the engine,
driven strictly as a CLI from Bash — never as an MCP server — and ships the three things it does
not: a skill encoding an observation ladder, simprobe for pixel truth, a warm daemon for loops.
| A 30-action session | Observation cost | Per action |
|---|---|---|
| Next-best measured tool | ≈ 18,400 tokens | press ≈ 1.0–1.5 s |
| This project's ladder | ≈ 4,200–9,900 tokens | simprobe tap 16 ms through the daemon |
flowchart LR
A["Claude Code agent"] --> B["skill/ — ladder, action rule, pitfalls"]
B --> C["agent-device CLI — XCUITest runner<br/>snapshot / press / fill"]
B --> D["simprobe — simctl pixel truth<br/>wait-stable / motion / shot / diff<br/>frames via idb"]
B --> E["simprobe-daemon — warm idb gRPC<br/>tap / tree, ~16 ms"]
C --> F["iOS Simulator"]
D --> F
E --> F
- Observe cheaply. Start at the smallest rung that answers the question; escalate only when it cannot. A screenshot is the last rung, not the first.
- Act fast.
agent-devicefor sessions, refs and text entry; the warm daemon when one screen is acted on dozens of times in a row. - Verify with pixels.
simprobeanswers "did it move, and has it stopped?" with a number and zero images returned.
Prerequisites: macOS 14+, Xcode 26.6, an iOS 26.5 simulator, Node (for agent-device), and
optionally idb for frames and the daemon.
1. Install the automation engine.
npm i -g agent-device@0.21.0 # or run it ad hoc: npx agent-device@0.21.0 <command>2. Install simprobe. All three lines are required: the tap URL because the repository is
not named homebrew-simprobe, and brew trust because Homebrew 6 refuses to load a formula from
a third-party tap until you say so. Read Formula/simprobe.rb first — it
is under fifty lines and builds from the tagged source tarball.
brew tap NicolasBataille/MobileController https://github.com/NicolasBataille/MobileController
brew trust nicolasbataille/mobilecontroller
brew install simprobeFrom source instead — simprobe alone has one dependency and a 58 s clean build (2.7 MB);
adding simprobe-daemon pulls in 22 transitive packages, 347 s for both in release (38.5 MB):
git clone https://github.com/NicolasBataille/MobileController && cd MobileController
swift build -c release --product simprobe # the CLI alone
swift build -c release # adds simprobe-daemon and its gRPC stackKeep the two binaries together: tap and tree look for simprobe-daemon beside the running
simprobe, never on PATH.
3. Install the skill. Copy the whole directory — the reference files are loaded by relative
path, so a copy of SKILL.md alone is broken.
mkdir -p ~/.claude/skills && cp -r skill ~/.claude/skills/mobilecontroller4. A first session.
UDID=$(simprobe devices --booted --platform ios --json | python3 -c 'import json,sys;print(json.load(sys.stdin)[0]["udid"])')
agent-device --platform ios --udid "$UDID" --session mc open com.apple.Preferences
agent-device --platform ios --udid "$UDID" --session mc snapshot -i
agent-device --platform ios --udid "$UDID" --session mc press text=General # localised row
simprobe wait-stable --udid "$UDID" --timeout 6s
simprobe shot --udid "$UDID" --out /tmp/settings.jpg--session on every call is not optional: without it the session name is a hash of the current
directory, so two shells silently drive two sessions on one device.
SKILL.md is always resident; three reference files load on demand
(agent-device cheatsheet,
pitfalls, simprobe). Its core is
the escalation ladder:
agent-device --level digest --json snapshot -i— refs and labels of interactive elements (--levelis a global flag; on 0.21.0 this rung is not the cheap one, and can come back with no refs at all — see Measured).agent-device snapshot -i— when the digest has no ref matching the target.agent-device snapshot— structure questions: nesting, containers, non-interactive text.simprobe frames --interactive— where something is; no snapshot rung carries coordinates.simprobe shot— rendering questions no tree can answer, at ~470–510 vision tokens.
Every verb has a compact human-readable default, a --json form, and an exit code a shell can
branch on. Six need nothing but xcrun simctl; frames needs idb, and tap/tree need a
warm daemon. Full reference: skill/references/simprobe.md.
| Verb | What | Cost |
|---|---|---|
wait-stable |
Poll until three consecutive frames agree within --tol |
4 captures, 1.5–4 s |
motion <ms> |
A diff timeline over a window, zero image bytes on stdout | one line |
shot |
One JPEG at 1x logical points, so coordinates map 1:1 onto AX frames | ~470–510 vision tokens |
frames |
On-screen elements with their 1x coordinates (needs idb) |
~1.3 KB, 0.6–1.5 s |
devices |
Simulators, booted first, with the UDID agent-device --udid wants |
one line each |
diff <a> <b> |
Two image files on the same scale the live verbs use | exit 4 when different |
daemon start|stop|status |
A warm idb gRPC connection, one process per UDID | 1.56 s cold start |
tap <#id | @index | x,y> |
Tap through the daemon, --wait-stable to verify it landed |
16 ms |
tree |
frames' output from the warm path |
82 ms, 1.9 KB |
simprobe-daemon is a second product on purpose: gRPC brings 22 transitive packages, and
swift build --product simprobe never compiles any of them. Start it when one screen is observed
and acted on many times in a row — a mixed loop measured 2.7x faster end to end than pure
agent-device, with an XCUITest session live on the same simulator. It is not a replacement: no
session, no fill, a leaner tree, a hard dependency on idb_companion.
Design.
Never compare node counts across engines. idb's tree is leaner by design than XCUITest's. Diffing the two counts, or using either to prove an element is absent, produces confident nonsense.
bench/ runs a fixed observe→act→verify flow and records wall time, stdout
bytes, estimated tokens, host loadavg and an independent simctl control probe per step, so a
number survives being read on another machine. DemoApp/ is its public
SwiftUI target: tabs, a 40-row list, a fill/read-back/clear form, and exactly one 300 ms
easeInOut transition to measure against.
One representative repeat from the Settings and daemon runs, on an 8-core Apple Silicon Mac,
Xcode 26.6, iPhone 17 Pro / iOS 26.5, agent-device 0.21.0 (the simprobe tap/tree/loop
daemon rows were measured on the 0.20.11 base; the daemon path does not go through agent-device).
| Step | stdout | wall |
|---|---|---|
agent-device --level digest --json snapshot -i |
482 B — but no refs, see below | 194 ms |
| ↳ the same call on the XCUITest acquisition path | 1646 B | 276 ms |
agent-device snapshot -i |
1116 B | 180 ms |
agent-device snapshot (full tree) |
2315 B | 186 ms |
agent-device press text=… |
33 B | 801 ms |
agent-device press text=… --settle |
1808 B | 1839 ms |
simprobe shot |
164 B + a 47 KB file ≈ 468 vision tokens | 340 ms |
simprobe tap (daemon) |
— | 16 ms |
simprobe tree (daemon) |
1.9 KB | 82 ms |
20x (tap + tree), daemon vs press + snapshot -i |
— | 2.05 s vs ~26–40 s |
Read ratios, not absolutes: wall time inflates 2–10x when host load rises above roughly twice
the core count, which is why every bench row carries loadavg_1m and a control probe.
Two things about rung 1 on 0.21.0. It is still not the cheap rung — 1646 B against 1116 B
for snapshot -i on the same screen, where 0.20.10 read 447 B; the envelope regression seen on
the 0.20.11-dev base shipped in the release. And it can be empty: 0.21.0 added a second
snapshot acquisition path that carries no hittability evidence, which -i filters on, so on
Apple Settings' UIKit collection rows the digest returns "refs": [] — the 482 B above. The
same path is fine on the DemoApp's SwiftUI tree. Every snapshot names its path on a banner
line; read it, and drop to the full snapshot when -i looks implausibly empty.
Observation is otherwise ~30 % faster than on 0.20.11-mc.1.
Full tables, load conditions and per-row analysis: docs/benchmarks.md.
Tested only on this triple. When it drifts, re-run bench/run.sh before trusting any number here.
| Component | Version |
|---|---|
| agent-device | 0.21.0 |
| Xcode | 26.6 |
| iOS Simulator runtime | 26.5 |
simprobe needs macOS 14+, a Swift 6.0+ toolchain and nothing but xcrun simctl — except
frames, tap and tree: brew install facebook/fb/idb-companion && pip3 install fb-idb.
agent-device version policy. 0.21.0 is pinned: the skill, the cheatsheet and the pitfalls
are written against it. It ships the fixes for all five known limitations (PRs #2065–#2068,
merged 2026-08-27), which retires the interim 0.20.11-mc.1 patched build — do not build it.
0.21.0 also carries breaking changes and two findings that move numbers on this page: the
digest rung is still not the cheap rung, and a new snapshot acquisition path can return no
interactive refs on UIKit collection screens. All of it is in
docs/upstream.md.
- No private APIs. No
dlopen, noAXPTranslator, noSimulatorKit, no Python, no codesigning story. CI greps for them and fails the build. - CLI, never MCP. An MCP server costs 3k–66k tokens of tool schemas on every sampling; a CLI invoked from Bash costs zero standing tokens.
- Token budget first. Output is measured in hundreds of bytes, and every observation form is benchmarked against the alternatives rather than asserted to be cheap.
- Pixel truth, not AX quiescence. A tree goes quiet while implicit SwiftUI animations, crossfades and Canvas views are still moving. Rendering questions get a rendering answer.
- Public-repo hygiene as a gate. No absolute home paths, no simulator UDIDs, no private bundle
identifiers, no committed screenshots;
scripts/hygiene-check.shenforces it in CI.
tree --point x,yand a mixed-engine row (simprobe tap+agent-device snapshot -i) in the bench.- Trim
skill/SKILL.mdbelow 130 lines without losing the ladder or the pitfalls pointers. - A real-app case study with token counts before/after, beyond Settings and the DemoApp.
See CONTRIBUTING.md. Every change ships with a test that failed before it; the
gates are swift test --enable-code-coverage (80 % floor on Sources/), swift format lint --strict --recursive Sources Tests, and ./scripts/hygiene-check.sh.
| PRD | Problem, goals, measured baseline, acceptance targets |
| Architecture | Repo layout, module split, CLI surface, exit codes |
| Task list | Phased tasks with their TDD red steps |
| Warm daemon spike | Feasibility, latency, the coexistence test |
| Warm daemon design | Why a second product, the wire protocol, the build budget |
| Benchmarks | The full measured tables and how to regenerate them |
| Upstream notes | Known agent-device limitations and the patched build |
| The skill · bench/ · DemoApp/ | What the agent reads, the harness, its target |
| CHANGELOG.md | Release history |
- agent-device by callstack (MIT) — the automation engine. MobileController is a complement to it, not a fork of it.
- idb by Meta — the accessibility and HID path behind
frames,tapandtree; its protos are MIT and the stubs are generated from the installed client's own descriptor. - grpc-swift — the daemon's transport.
MIT — see LICENSE.