Let your AI agent use your iOS simulator.
I'm building Jev Mobile to keep mobile automation simple for AI agents. Give it a goal, let Jev or local Laya pick the next action, and let Maestro do the tapping. Your agent gets a short result when it's done, while the screen data and action loop stay inside the server.
The idea came from Browser Use's jev-ultrafast. I wanted to bring that approach to mobile apps with Maestro, and make it available as a local MCP server.
By Doğukaan Kılıçarslan · MIT
One controlled run on a real app (2026-09-25). The same Claude Haiku agent prompt did two tasks, once with the stock Maestro MCP server and once with Jev Mobile's direct tools:
- T1: run an existing Maestro flow file.
- T2: navigate a record to a detail field and read its value.
| Maestro MCP | Jev Mobile | Difference | |
|---|---|---|---|
| T1 and T2 | both passed | both passed | — |
| Total tokens processed | 591k | 429k | −27% |
| Agent turns | 13 | 10 | −3 |
| Wall time | 3.6 min | 3.2 min | −11% |
| Screen data per read | 3.9k chars | 1.4k chars | −64% |
| Screen reads | 4 | 2 | −2 |
Where the saving comes from: compact screens, and run_flow returning the screen after it runs,
so the agent reads less and makes fewer calls.
How it was run: two fresh iPhone 17 simulators (iOS 26.5) with only the app installed,
Maestro 2.10.0, one runner at a time, with the same guardrails for both. This is one
run per runner on one app. The direction is clear, but the numbers aren't statistically
solid yet, and repeat runs are planned. It measures the direct tools (screen, run_flow),
not the model-driven run_goal loop. Screens have since switched to a more compact
one-line-per-element format, which isn't reflected in these numbers.
The same setup ran 12 existing smoke flows, one run call per flow. Both runners passed
12/12 at the same speed, 42–57 s per flow. jev's first flow took 62 s against Maestro's
105 s, because its driver starts faster. But jev processed 793k tokens against Maestro's
692k (+15%): every passing flow returned the final screen, about 2k characters per
result against Maestro's ~430, and the agent re-read them on every later turn.
Two fixes followed, each measured by rerunning the suite (jev only; Maestro's numbers stand):
- File and directory runs return a one-line result on pass, and the screen only on failure.
- The default wait went from 45 s to 100 s. The cold first flow (61 s) had returned
running, which cost one extra agent turn.
| Maestro | jev at first | after fix 1 | after fix 2 | |
|---|---|---|---|---|
| Result | 12/12 | 12/12 | 12/12 | 12/12 |
| Wall time | 11.2 min | 11.3 min | 11.1 min | 11.3 min |
| Agent turns | 16 | 17 | 17 | 16 |
| Run results in context | ~430 chars/flow | ~2,000 chars/flow | 126 chars/flow | ~85 chars/flow |
| Total tokens | 692k | 793k (+15%) | 731k (+5.6%) | 684k (−1.2%) |
With equal turn counts, jev comes out slightly cheaper, all of it from smaller results. For scripted suites the two runners are effectively level.
Lesson so far: agent turns drive cost. Fix 1 saved 62k tokens by shrinking results;
fix 2 saved 47k by removing one turn. run_flow(dir=..., include_tags=[...]) now runs a
whole suite in one call, which should cut turns for either runner. It hasn't been measured
yet.
Check every finished feature on the simulator without an agent driving it step by step.
The app opens any screen from a debug launch argument, each feature has a flow file, a
small sim-tester agent runs it in one call, and jev-mobile test runs suites from a git
hook or CI with no model at all:
make workflow PROJECT=~/code/your-app APP=com.example.app # agent + pre-push hook
jev-mobile test maestro --include-tags smoke # one line, exit 1 on failureSee the workflow guide, including the debug-route spec for your app.
Your agent sends a task like:
Open Settings, go to General, then About.
Jev Mobile reads the screen, gives the selected model a list of possible actions, runs the selected action through Maestro, and repeats. You can supply final text checks so Maestro verifies the result. The run also leaves behind a trace and an exported Maestro YAML flow.
The MCP interface has a few compact tools:
devices,screen,screenshot— find a device and read what is on it.run_flow— run exact Maestro steps.run_goal— hand over a task and get a compact outcome.run_report— wait for a long run, or look at recent steps to debug.run_cancel— stop a run and its Maestro driver.
The aim is to use less of your agent's context; see the first result above. The server runs locally. Choose TypeSafe's hosted Jev API or a local Laya model.
On the development Mac, Laya is already installed as a background service. From
the jev-mobile directory, check it and run a real inference smoke test:
uv sync --locked --extra laya
curl --fail http://127.0.0.1:8081/health
uv run --extra laya python examples/laya_smoke.pyThe smoke test without --device makes local model requests and does not touch a simulator.
Expect three classification results and "passed": true. If the health check cannot connect,
restart the installed service, wait a few seconds, and retry:
launchctl kickstart -k "gui/$(id -u)/com.jev-mobile.laya"For a new installation, use the foreground server below. Skip this if the health check already succeeds; only one process can use port 8081.
Apple Silicon Macs can run the optional Laya MLX runtime on their GPU. No TypeSafe key is needed. The first start downloads a pinned, roughly 843 MB typed-decisions checkpoint; subsequent inference runs locally.
uv sync --locked --extra laya
uv run --extra laya jev-mobile laya-serveKeep that process running. In another terminal:
curl http://127.0.0.1:8081/health
uv run --extra laya python examples/laya_smoke.pyTo attempt the real Settings navigation test, first find a booted simulator:
uv run --extra laya jev-mobile doctor
uv run --extra laya jev-mobile devices
uv run --extra laya python examples/laya_smoke.py \
--device YOUR_SIMULATOR_UDID --output runs/laya-ios-testThis opens Settings and asks Laya to navigate General → About. Inspect
runs/laya-ios-test/smoke.json and the nested run reports. This mobile goal failed in
local validation, although classification passed. The default confidence threshold is 0.5.
For MCP, use jev-mobile serve --backend laya or set JEV_BACKEND=laya in the
server environment. LAYA_URL / --laya-url can select another loopback port.
The inference endpoint binds to 127.0.0.1:8081; it does not send your goals or
screens to TypeSafe and never falls back to a paid provider.
Laya support is experimental. Its smaller context and limited performance with large choice sets make it different from hosted Jev. The adapter compresses screen metadata, keeps the offered action IDs, and preserves confidence checks and independent completion assertions. Oversized state is rejected rather than silently truncated. Passing a simple classification check does not establish reliable mobile navigation.
See local deployment and test notes for service management and results.
Build and install your app on a simulator using its existing project workflow. Then point Jev Mobile at that simulator's UDID and the app's bundle identifier:
uv run --extra laya jev-mobile run --backend laya \
--device YOUR_SIMULATOR_UDID --app-id com.example.yourapp \
--goal 'Open the Profile screen' --expect-text 'Profile' \
--max-steps 5 --output runs/my-app-profileReplace the example goal and final text with something in your app. Only verified means
the supplied checks passed. This controller uses your existing app build and Maestro;
it does not replace the app's build system or existing deterministic test suite.
Follow the app integration guide for device/app discovery, text fields, MCP configuration, and debugging. An agent continuing this work should start with the handoff notes.
You'll need Python 3.11+, uv, Xcode, a booted iOS simulator,
and Maestro with MCP support. The tested
Maestro build is 2.10.0 and supports maestro mcp --no-viewer.
From your checkout:
uv sync --locked
uv run jev-mobile doctor
uv run jev-mobile devices
cp .env.example .envAdd your TypeSafe API key to .env:
TYPESAFE_API_KEY=your-key-hereFor a first task, open Settings on an English-language simulator and try:
uv run --env-file .env jev-mobile run \
--device YOUR_SIMULATOR_UDID \
--app-id com.apple.Preferences \
--goal 'Open General, then About' \
--expect-text 'About' \
--expect-text 'iOS Version'This is a suggested task to try; the live Jev demo is still on the todo list. Device discovery and the offline tests work without an API key.
Three commands from this checkout (run make to see every target):
make install # checks prerequisites, installs the
# jev-mobile command and the Laya service,
# and registers the MCP server user-wide
make apps # bundle IDs on booted simulators
make connect APP=com.example.yourapp PROJECT=~/code/your-app # optional per-app defaultmake install ends with make register, which runs jev-mobile setup --global, so every
project sees the tools. It discovers each Claude Code config (~/.claude.json plus
~/.claude-* profiles used with CLAUDE_CONFIG_DIR) and asks before registering in each;
YES=1 (or --yes) registers in all of them without asking.
make check lists anything missing with the command to install it. make doctor checks
Laya and Maestro afterwards. Without make, the equivalent is uv tool install --editable .
and then jev-mobile setup --app-id com.example.yourapp inside your app repository.
setup registers the server with every detected client: Claude Code (this project's
local scope, in each discovered config), Codex (~/.codex/config.toml, with a 10-minute tool timeout), and Cursor
(.cursor/mcp.json). Use --client to pick one, --global for user-wide Claude/Cursor
config, --device to pin a simulator, and --client json to print a config for other clients.
It defaults to the local Laya backend. The server exposes direct Maestro tools too
(screen, screenshot, run_flow), so it replaces a separate Maestro MCP server.
For a manual configuration, use the Laya MCP config for local inference, or the Jev MCP config for the hosted API. Replace the paths, simulator ID, and app ID with your own. Your client starts the Jev Mobile MCP process; the Laya inference service must already be running separately.
The local launch command is:
uv run --extra laya jev-mobile serve --backend laya \
--device YOUR_SIMULATOR_UDID \
--app-id com.apple.PreferencesWith the device and app configured, a run_goal call looks like this:
{
"goal": "Open General, then About",
"expect_text": ["About", "iOS Version"]
}Only verified means the checks you supplied passed. If you leave out expect_text,
the run can finish as done_unverified.
The server has no per-goal deadline by default (--timeout sets one), so long flows
that wait on network calls finish; make sure your client's tool timeout is long too. More options are in the MCP guide.
Text fields: pass values in an MCP call, or --values examples/values.json in
the CLI. It's a map of field IDs to the text you want entered:
{"search_field": "Coffee"}You'll need to know the field's accessibility/resource ID. The model chooses which field to fill; free-form text generation isn't implemented yet.
Saved runs: CLI output goes to runs/latest/. MCP output goes to
~/Library/Application Support/jev-mobile/runs/<run-id>/ (override with --output or JEV_RUNS). Completed CLI loops save their steps, a result summary, and flow.yaml;
an early CLI exception may leave only a partial trace. MCP goal errors also save a result report.
You can replay a CLI export with:
maestro test runs/latest/flow.yamlYou may need to adjust the starting state or add waits before reusing an exported flow.
The local MCP connection works, the offline tests pass, and the scripted iOS check navigates Settings → General → About. Live Jev runs and Android testing are next. Local Laya classification works, but its tested Settings goal did not complete. A separate full MCP goal timed out waiting for Maestro. There are no comparative speed or cost benchmarks.
Right now this works with UI elements Maestro can identify. Unlabelled controls, custom-drawn screens, complex gestures, and ambiguous targets still need work. For the details, see the validation notes.
Live runs using the Jev backend send screen element data, your goal, and supplied field values
to TypeSafe. The Laya backend sends them only to the configured loopback server.
Traces and exported flows can contain entered text. .env and runs/ are ignored by
Git; use demo data when sharing a recording.
uv sync --locked --extra laya
uv run --extra laya pytest
uv run --extra laya ruff check .
uv buildCheck the local MCP connection without paid calls or device actions:
uv run python examples/check_mcp.pyTry the scripted Settings check without a Jev key:
uv run python examples/ios_settings_smoke.py --device YOUR_SIMULATOR_UDIDI'd welcome help with a live demo, better text input, Android support, and measuring how much agent context this saves. See CONTRIBUTING.md.
Built with Maestro and TypeSafe Jev. Thanks to the Browser Use team for sharing jev-ultrafast, which inspired this project.
This is my independent project, not an official project from those teams. MIT license · Third-party notices