Ramiz RajaBook a call
← Writing
Working With AI Agents

Android and iOS, Two Device Layers: agent-device Against My Own Stack

The same kind of task on an Android emulator and an iOS Simulator, run through two device layers. One handed the agent screens of a few hundred bytes; the other handed it tens of kilobytes. Both finished, and both platforms had a moment that would have fooled an agent.

Ramiz RajaNovember 3, 20269 min read
Android and iOS, Two Device Layers: agent-device Against My Own Stack

Your coding agent can already build the app. Next you want it to open the app on an Android emulator or an iOS Simulator, walk a flow, and tell you honestly whether it worked. A mobile MCP server or CLI is the layer in the middle: it turns "tap Done" into something the device understands, and turns the screen back into something the model can read.

This is for anyone wiring that layer into Claude Code, Cursor or their own agents.

The reading half is where I've been paying. On my stack, one Android dialog was 19,477 bytes and one iOS form was 131,590, mostly keyboard. Callstack's agent-device showed them in 271 and 1,695 bytes.

So I ran both layers through the same tasks on both platforms and logged every call. This isn't sponsored. Nobody at Callstack saw it before it went up.

The tasks, and the two layers

On Android (Android 17 emulator, Pixel 9a profile): open Settings, scroll to About emulated device, tap Device name, replace the text, press OK, accept the confirmation, check the new name, screenshot.

On iOS (iPhone 18 Pro Simulator, iOS 27.2) I planned the same rename, but the Simulator's Name row is read-only. The closest task with a text field and a save: Contacts, Add, type a first name, Done, check the saved card, screenshot.

agent-device 0.21.36 is an MIT-licensed CLI, MCP server and Node API, an "open source device layer for AI coding agents" in its own words. The loop is open, snapshot -i, then press or fill on a ref like @e5, with --settle so each action waits for the UI to go quiet and prints what changed.

My stack is plain tools. On Android: adb for taps and text, and android layout from Google's android CLI 1.1.16717390 (install script at dl.google.com/android/cli) for the screen as JSON. Every Android call went through my private farm wrapper, which takes a device lease first so two agents can't drive one device. That makes my Android timings my setup, not something you can reproduce from this post. On iOS: AXe 1.8.0 (brew install cameroncooke/axe/axe), the tool from the real-iPhone article, with axe describe-ui, tap -x -y, type and screenshot, called directly with no lease. Both baseline versions were read from my machine on 2026-11-04.

Android: agent-deviceAndroid: my stackiOS: agent-deviceiOS: my stack
Resultdonedonedonedone
Tool calls (failed)33 (5)18 (2)12 (1)7 (0)
Summed time27.2 s, 10.2 s of it one timed-out wait41.7 s21.4 s7.3 s
Typical read0.12–0.6 s2–5 s0.2–0.5 s0.7–2.0 s
Typical action0.7–2.4 s, returns a diff1.1–1.5 s, returns nothing1.1–4.9 s, returns a diff0.2–1.5 s, returns nothing

Counts include my own mistakes. Android counts run from opening Settings to the last screenshot on both sides. iOS counts run from opening Contacts to the check on the saved card on both sides, so they leave out each side's screenshot and one untimed agent-device raw snapshot. agent-device's first iOS open printed "Building Apple runner..." and its first snapshot took 35.7 s, on an earlier Settings screen.

These are summed times for the sessions I recorded, not a controlled race. On Android agent-device came out faster; my wrapper's lease and instrumentation round trip on every call may contribute, but I didn't measure them separately. On iOS AXe came out faster; agent-device's --settle waits may contribute.

What a mobile MCP server hands the agent

The tree is what the agent pays for, in tokens and attention, every time it looks. Here is the Android device name dialog as snapshot -i showed it:

Page: com.android.settings
App: com.android.settings
Snapshot: 5 nodes
@e1 [scroll-area] [scrollable]
@e2 [text-field] "sdk_gphone16k_arm64" [focused] [editable]
@e3 [scroll-area] "com.android.settings:id/buttonPanel" [scrollable]
@e4 [button] "Cancel"
@e5 [button] "OK"
Screenagent-device snapshot -iMy stack
Android: Settings home801 B (~200 tokens)5,825 B android layout
Android: About, after the change866 B5,712 B
Android: name dialog, keyboard up271 B (~70 tokens)19,477 B (~4,900 tokens)
iOS: Contacts list1,605 B55,213 B axe describe-ui
iOS: New Contact, keyboard up1,695 B131,590 B (~33,000 tokens)
iOS: saved contact card969 B45,225 B

About 7 to 72 times smaller on Android, 34 to 78 times on iOS. AXe sizes are compact JSON; as emitted, pretty-printed, they're about six times larger. Tokens are bytes ÷ 4, a rough guide.

Not everything is filtered. On iOS, -i still listed every keyboard key (67 nodes on the form), which Android's -i didn't.

The second gain is feedback. After every tap my agent re-reads the whole tree to learn what happened. agent-device's --settle answers in the same call. This is the iOS fill:

Filled 7 chars
settled after 929ms: +4 -3 (~26 unchanged)
- @e55 [button] "Done" [disabled] [covered]
+ @e55~s477531 [button] "Done" [covered]
+ @e62~s477531 [text-field] "benchad"

One call typed the name and showed Done becoming enabled. Lines trimmed.

The ~s477531 suffixes pin a ref to that output. On Android, pressing a bare @e5 after a diff was refused with the hint "Retry with the exact emitted ref @e5~s683610". A stale coordinate on my stack just taps whatever is there now.

iOS Simulator MCP: when the refs said no

Look at that diff again: Done is marked [covered]. Pressing it failed:

Error (COMMAND_FAILED): Ref @e55 is covered by another visible element and cannot be tapped safely
Hint: Covered by the toolbar "Toolbar" at (0, 83 402x48).

The raw XCTest tree for the same button said "hittable": true. On Settings > General it was worse: a toolbar spanning the whole screen (402×874) marked every row [covered], and both press @e8 and find text About were refused. A coordinate press at the same spot worked both times.

The safety check is a good idea, but on iOS 27 it misread the toolbars, and my agent was back to pixel coordinates, which refs are meant to replace.

Two smaller iOS differences. agent-device's fill set "benchad" exactly. axe type sends real keystrokes, so iOS capitalised mine to "Benchours", and my lowercase assertion failed until I made it case-insensitive. And every Contacts snapshot printed "Simulator AX snapshot unavailable (circuit-disabled); used XCTest".

Where wait text passed too early, on both platforms

After fill, I wanted to check the name had taken. agent-device wait text bench-ad on Android, wait text benchad on iOS: both returned at once, exit code 0, no output. Neither had been saved. The only match was the text field I'd just typed into. An agent would read that as done.

On Android, pressing OK opened a second dialog warning that the name is visible to apps and nearby devices. Its -i snapshot was three nodes: a button panel, Cancel, OK. Nothing says what OK agrees to. The full snapshot showed the title and a truncated warning; my layout JSON had both in full.

And I made the same mistake on both platforms. After a full snapshot I pressed a ref number from an earlier -i output.

The two views number the screen differently: on Android @e3 was the status-bar clock, on iOS @e7 opened the Apple Account sign-in sheet. agent-device followed its rule (refs come from the latest output); I didn't. An agent switching views can do the same.

What I changed: assert where the value can only exist after the save. The final checks are the Android About screen and the iOS contact card, not the field. Also, the help lists find among ways to verify, but help find says "the action defaults to click". My find text bench-ad tapped the field.

My stack had its moment too: android layout failed twice on About with Could not obtain idle state until I added --no-idle.

Two agents, one device

On both platforms a second session on the same device got DEVICE_IN_USE with a hint to wait or close. On Android, --wait 20000 polled for about 4 seconds while I closed the first session, then failed for a different reason: the app I'd asked for isn't on that image.

Those claims only cover agent-device. While it held the Simulator, axe describe-ui ran fine; my iOS stack has no lease at all. While my farm held the Android emulator for another agent, agent-device snapshot ran too, and only my wrapped tools were refused. Each lock stops only its own tools. If your agents share devices, as in my multi-agent setup, agent-device needs to sit under your lock.

And agent-device devices listed my personal iPhone next to the emulator; I didn't test opening it.

Try it

These are my recorded runs minus the mistakes. The refs are from my screens; on yours, take them from the latest output.

npm i -g [email protected]

# Android: rename the device
agent-device open com.android.settings --serial emulator-5556 --session bench --platform android --foreground
agent-device snapshot -i --session bench
agent-device scroll bottom --settle --session bench
agent-device press @e7~s683597 --settle --session bench   # About emulated device
agent-device snapshot -i --session bench
agent-device press @e5 --settle --session bench           # Device name
agent-device snapshot -i --session bench
agent-device fill @e2 bench-ad --settle --session bench
agent-device snapshot -i --session bench
agent-device press @e5 --settle --session bench           # OK
agent-device snapshot -i --session bench
agent-device press @e3 --settle --session bench           # OK on the warning
agent-device wait text bench-ad --session bench           # on About, after the save
agent-device close --session bench

# iOS: add a contact (my session was already open with --platform ios --udid <SIM_UDID>)
agent-device open com.apple.MobileAddressBook --session ios
agent-device snapshot -i --session ios
agent-device press @e49 --session ios --settle            # Add
agent-device snapshot -i --session ios
agent-device press @e5 --session ios --settle             # Continue (typing tip)
agent-device snapshot -i --session ios
agent-device fill @e62 benchad --session ios --settle     # First name
agent-device press 364 100 --session ios --settle         # Done by coordinates; @e55 was refused as covered
agent-device snapshot -i --session ios
agent-device wait text benchad --session ios              # on the saved card
agent-device close --session ios

claude mcp add agent-device -- agent-device mcp   # same commands as an MCP server

What I didn't test

Cloud and proxy providers (accounts needed), my real phones (another machine), XcodeBuildMCP's snapshot_ui, and Mobile Next's mobile-mcp, the top result for "mobile mcp". One emulator, one Simulator, one run each.

Which one wins what

Better hereWhy
What the agent readsagent-device271 B vs 19,477 B (Android), 1,695 B vs 131,590 B (iOS), plus a diff per settled action
Speedsplitagent-device on Android, AXe on iOS
Completing the blocked iOS tapsneitherboth finished them by coordinates; agent-device first refused taps that worked as "covered"
Typing exact textagent-devicefill sets the value; axe type got autocapitalised
A modal's text in the compact viewmy stackAndroid -i dropped the dialog title and warning
Not passing earlyneitherwait text matched the field on both; my stack has no wait
Several agents sharing an Android devicemy stackmy farm lease blocks my wrapped tools; agent-device locks only its own sessions. On iOS my stack had no lease

If you're starting from nothing, agent-device is a better first layer than raw layout dumps on either platform: smaller screens, refs, diffs. On iOS, expect to fall back to coordinates until the covered check settles down. If your agents already share devices, keep your lock as the gate and run agent-device inside it. That's what I'm testing next.