Tools¶
Every tool below can be driven three ways: from the CLI
(bunx @kaeawc/auto-mobile --cli <tool>), over MCP from an AI client, or
directly against the daemon’s HTTP endpoint. The tool names and arguments are
the same across all three.
This page reflects the current tool schema. Availability can still vary by platform, runner, and enabled feature gates; inspect the registered schema for the exact arguments supported by your connection.
Shared device and session options¶
The following tools expose sessionUuid and keepScreenAwake:
accessibility, accessibilityFocus, appLifecycle, barrier, biometricAuth,
changeLocalization, clearKeyValueFile, clearMockNetwork, clipboard, crashApp,
criticalSection, deleteDevice, deviceSnapshot, displayConfig, dragAndDrop,
executePlan, explore, exportPlan, getAppPermissions, getDataStore, getDeepLinks,
getDeviceState, getIosSimulatorCapabilities, getNavigationGraph, getNetworkGraph,
getNotificationPolicy, getPreference, highlight, hitTest, homeScreen,
identifyInteractions, inspectPackageSigning, installApp, keyboard, launchApp, listApps, listDataStores,
mockNetwork, navigateTo, network, observe, openLink, phoneCall, pinchOn,
postNotification, pressButton, prototype, putAppFile, recentApps,
reconcileDeviceResources, recordDeviceGestures, recordToolCalls, removeKeyValue,
resetAppLogs, resetKeychain, rotate, selectAllText, sendKeys, sendSms,
setActiveDevice, setAppPermissions, setDeviceResources, setDeviceState, setKeyValue,
setNotificationPolicy, setPosture, setPreference, setUIState, shake, snapshotOf,
sqlQuery,
swipeOn, systemTray, tapAny, tapAt, tapOn, terminateApp, uninstallApp,
videoRecording, wakeAndUnlock.
sessionUuid selects a daemon device session. When an Android session first
runs device setup, keepScreenAwake defaults to true. On a detected physical
Android device, AutoMobile attempts to wake the screen and enable staying awake
while plugged in; if that fails, it attempts stay-on and screen-timeout settings.
Pass keepScreenAwake: false on the call that first runs this setup to skip it.
The result (including a skip or failure) is cached for the session, so later calls
cannot toggle it. Emulators are skipped; iOS does not apply it. Applied settings
are restored on session release when their original values are known, on a
best-effort basis. The option has no effect outside daemon session setup.
For the tools listed above, use the routing fields their registered schema
exposes: platform selects Android or iOS, and deviceId identifies the target
device. A string device selects a previously allocated plan device label and
requires sessionUuid; it takes precedence over deviceId. A label requires an
active daemon session. Not every listed tool exposes every routing field.
Shared observation output options¶
These tools accept raw to return the raw hierarchy and project to choose
"skeleton" (default) or "full" observation output:
biometricAuth, dragAndDrop, homeScreen, launchApp, observe, openLink,
pinchOn, pressButton, recentApps, rotate, selectAllText, sendKeys,
shake, swipeOn, systemTray, tapAny, tapAt, tapOn, terminateApp.
Skeleton output contains actionable entries; the collapsed keyboard marker <ime> is not a
selector. Use sendKeys or keyboard for keyboard input.
Observe & navigate¶
The default skeleton projection optionally includes windowTruncations:
[{ windowId: 67, package: "com.android.systemui", reasons: ["max_nodes"] }].
There is one entry for each window with capture truncation reasons. Complete
windows have no entry; when none are truncated, the field is absent, adding zero
bytes to the output. package is optional and comes only from the window’s own
metadata or its linked hierarchy root, never from bounds or another window.
The existing flat truncationReasons field keeps its meaning and behavior.
max_nodes: this window’s share of the node budget was exhausted.max_depth: the tree was deeper than the depth cap.max_children: a node exceeded the device’s 256-child cap; its later children are missing even from the raw capture.cancelled: the capture was cancelled mid-walk.
Unknown reason codes from newer APKs pass through unchanged. Reasons within an
entry are deduplicated in capture order; host-output max_children[...] caps
are excluded because they do not describe window capture loss.
Observe again after a cancellation, or narrow the next read with an element
query, selector, or the configured observeScope experiments instead of
re-reading the whole screen. If the relevant window is complete, act on that
window. The host’s observe API does not expose node/depth limit controls: its
hierarchy request omits those optional protocol values, so the APK uses its
defaults (10,000 nodes and depth 100).
For project: "full" or raw: true, per-window reasons remain at
viewHierarchy.windows[].truncationReasons; windowTruncations is not duplicated
there. An action diff includes windowTruncations from the current observation
only, even when full/raw was requested; flat diff reasons can also describe the
baseline. With display: "all", each skeleton display entry carries its own
windowTruncations. An APK built before PR #8798 never sends per-window reasons,
so the new field is simply absent: this is additive and nothing else changes.
iOS has no per-window capture reasons.
observe accepts an optional display panel key, a panel role (inner,
cover, rear, or external), "active", or opt-in Android "all". Selection precedence is explicit display > session display pin >
focused window > posture default (closed → cover, opened/rear_display → inner).
Explicit display: "active" or "all" bypasses the pin ("all" still reads every panel); "active" follows the live focused/active
panel. The returned display stamp identifies the panel actually observed.
For Android it uses the physical panel key from the hierarchy’s panelUniqueId
or mapped displayId; for iOS it uses the matched simulator screen name. Its
role comes from inventory. Android foldables include the current device-state
posture when available. Multi-panel iOS simulators infer posture from the active
panel (cover → closed, inner → opened), retaining a successful setPosture
value such as half_opened while that panel matches and no transition occurs.
This is not read from the device. Single-panel simulators and physical iOS
devices report unknown.
When inventory has two or more panels, otherDisplays lists each remaining
panel’s key, role, and pixel size (width, height).
Android routes explicit hierarchy and screenshot reads to the selected display;
on iOS, only the currently live simulator panel can be observed.
observe({ display: "all" }) opts into the proposed additive shape for #8256
(owner confirmation is pending): the top level retains the ordinary active-panel
observation, and displays contains every inventory panel, including the active
one. Each entry has display, screenSize, freshness, and the requested
viewHierarchy or skeleton/context projection (including keyboard, capture
truncation metadata, and observeScope when present). Panels are read sequentially within the observe
deadline (the existing 15-second hierarchy budget when no transport deadline is
supplied). Unavailable panels remain in the list with a typed freshness reason;
panels left after the deadline carry request_timed_out.
With a one-panel inventory, displays contains that panel. Without inventory
(including ordinary single-screen discovery), displays is omitted and no new
field is added. waitFor, raw: true, and includeScreenshotImage: true are
rejected with "all"; use a separate single-panel observe. iOS rejects "all".
The active entry may include the normal settled screenshotPath; extra-panel
screenshot paths are omitted to avoid overwriting shared screenshot state.
Session-less deviceId reads support "all" with the same read-only service
access as explicit-panel reads; they never start or recover a service.
An aggregate read does not update observation caches, session baselines,
snapshot references, or display-transition fences. Observe one explicit panel
before acting on it; "all" is an observe selector only. The ordinary output
size limit still applies: a large aggregate is stored intact in a tool-output
artifact and the response supplies its artifact pointer.
setActiveDevice always returns a text JSON envelope containing message,
deviceId, and sessionUuid when provided. Its additive displayPin result
field reports the resulting selector string, or null after an explicit clear
or a device rebind that clears a pin. The next plain call after a clear omits
displayPin, as does a never-pinned session, preserving the existing response bytes. No output schema or
structuredContent is added for this tool.
Use setActiveDevice({deviceId, sessionUuid, display}) to pin a panel key or role
(inner, cover, rear, external) for that session and device. Omission leaves
the pin unchanged; a string replaces it; null clears it. The selector must exist
in the same inventory used for observe and actions at set time. Ordinary single-display
devices accept the fallback key "0". On a single-display device, "active" resolves
to the sole key and stores that key; on multi-display devices, "active" cannot be
pinned. "all", empty strings, and non-string values cannot be pinned. Display pins require an initialized
daemon session and are unsupported in direct mode. The result additively reports
displayPin (string or null for daemon sessions; omitted on the legacy path).
Pins clear on session release or device rebind, survive observation cache
invalidation, and are not persisted or inherited by a new session.
A pinned role remains that physical role in a closed posture: it never falls
back to cover. A missing panel fails before any capture or input dispatch with
PinnedDisplayUnavailableError, naming the pin and the available panels, and
instructing setActiveDevice {display: null} (include deviceId and sessionUuid).
If an inventoried panel is not connected on Android or not live on iOS, the
existing display routing refusal becomes the same typed pin error before input
or screenshot dispatch. Inventory/hierarchy probes may be needed to establish
liveness. Action failures carry error and
pinnedDisplay: {pin, availablePanels: [{key, role}]} in their existing result
channel; observation failures throw the actionable typed error.
When a session has a display pin, an unreadable or degraded display inventory
produces DisplayInventoryUnavailableError and asks the caller to retry. The pin
is preserved and no capture or input is dispatched. Actions report error plus
displayInventory: {pin?, retryable: true}; observation throws the typed error.
Setting a pin also fails with this error if the inventory cannot be read. A
readable inventory that lacks the pinned panel still produces
PinnedDisplayUnavailableError with available panels and pin-clearing guidance.
A pin-selected observation stamp adds optional pinned: true; explicit selector
calls omit that marker. It describes selection provenance and does not change
panel identity, display generation, or coordinate fences. This shared default
also applies to the display-aware gestures, hitTest, sendKeys, observation
screenshot captures, and videoRecording starts; stopping a recording is permitted even if its panel
has disappeared.
tapOn, tapAt, swipeOn, pinchOn, dragAndDrop, and sendKeys accept the
same optional display selector. First observe the target panel, then pass the
same panel to the action. An explicit action rejects coordinates from another
panel and asks you to re-observe the target. A display-transition refusal carries
staleDisplay: { observedGeneration, currentGeneration, currentDisplayKey?, retry: "observe" }
on the failure result (and in MCP structuredContent), with the same generations
and re-observe instruction in error. Android gestures use CtrlProxy with the
selected panel’s logical displayId when the APK advertises gesture_display_id_v1.
Older APKs retain input touchscreen -d routing for taps, swipes, and drags;
pinchOn on a non-default display reports the existing support limitation.
dragAndDrop uses the same default durations (press 600 ms, drag 300 ms, hold 100 ms) with or without display, including the adb fallback for older APKs, which accepts only a drag duration.
swipeOn on an Android display honours speed, boomerang, apexPause, and returnSpeed (each boomerang leg re-checks the display fence). Android also supports lookFor with display: scroll until found, observing and swiping the selected display on every iteration, with the display fence re-checked before each swipe. As on the default path, boomerang cannot be combined with lookFor. includeSystemInsets is supported only when the selected display reports its own available system insets; an explicit value fails before dispatch if those insets are unavailable. false excludes those insets and true uses the full rect. A plain display swipe with this option omitted retains its existing full-rect behaviour; a search excludes available selected-display insets and ignores unavailable inset values. autoTarget is supported with Android display as opt-in: true selects a scrollable container from the selected display; omitted or false keeps a plain display swipe. Explicit container and lookFor ignore autoTarget. Internal scrollMode selects the dispatch route: "adb" forces display-targeted adb input, "a11y" requires CtrlProxy display capability for a non-default display, and omitted keeps capability-based routing. A CtrlProxy display-dispatch failure is returned directly without retrying through adb. focusTarget remains rejected with display. On iOS, lookFor, includeSystemInsets, autoTarget, and scrollMode remain rejected with display.
Default-display gestures omit displayId. A CtrlProxy display-dispatch failure
is returned directly without retrying through adb. iOS accepts only its live panel.
On iOS, tapOn, swipeOn, dragAndDrop, and pinchOn validate the selected
panel, then use their existing CtrlProxy gesture path on that live panel.
For Android sendKeys, text, clear, and IME actions require a selector when
display is set so the input field can be focused on that panel. Discrete key
events use input -d directly.
Gesture timing bounds¶
dragAndDrop accepts pressDurationMs from 600 to 3000 ms,
dragDurationMs from 300 to 2000 ms, and holdDurationMs from 100 to 3000 ms.
The schema and action share these bounds.
With swipeOn({ boomerang: true }), apexPause accepts finite values from
0 to 3000 ms, and returnSpeed accepts finite multipliers greater than 0 and
at most 3000. Their defaults remain 100 ms and 1 respectively. The rounded return
duration must be at most 3000 ms, and forward + pause + return must be at most
5000 ms. Both schema and action reject unsafe timing before the forward swipe;
increase returnSpeed or shorten the forward duration or pause to fit the budget.
These timing options require boomerang: true.
pinchOn requires positive scale, distanceStart, and distanceEnd when
supplied; nonpositive values are rejected before gesture dispatch. Its optional
duration must be a positive integer from 1 to 10000 ms; the default remains
300 ms. This is a deliberate input tightening: zero, negative, fractional, and
over-10000 ms durations are rejected by both the schema and action.
For tapOn and tapAny with action: "longPress", duration accepts values
from 0 to 60000 ms; zero or omission keeps the platform default. Both the schema
and action reject durations above 60000 ms. When a request deadline is available,
the duration plus 2000 ms dispatch headroom must fit the remaining budget;
otherwise the press is rejected before it starts. If an Android adb long press is
interrupted, its error warns that the press may still be held on the device for
up to the requested duration; wait that duration before retrying touch input.
Screen-coordinate contract¶
snapshotOf is an opt-in tool. Enable it with setToolEnabled, then pass
either elementId for one uniquely exposed element or rectangle with
left, top, right, and bottom in the platform-native screen units below.
It captures one screen PNG, clips the rectangle to visible bounds, and saves a
full-resolution PNG crop. The response contains the file path, requested and
clipped bounds, screen and image sizes, effective pixels per native unit, scale
provenance, screenshot orientation, and clipping status. It does not return image
bytes. Missing or ambiguous IDs and empty or off-screen rectangles are errors.
observe reports platform-native, current-orientation screen coordinates. The
origin is the top-left of the complete current screen, including system UI:
- Android uses physical pixels; iOS uses XCTest logical points.
screenSize, skeleton bounds, full-hierarchy bounds, and absolutetapAtinput use that same platform-native coordinate space. An embedded action observation carries the samescreenSizein full, skeleton, and diff output.- Valid coordinates are half-open:
0 <= x < widthand0 <= y < height. - A point already in the platform-native space is not density-, inset-, Retina-scale-, canonical-pixel-, or rotation-transformed.
- iPad windowed apps (iOS 27 “Windowed Apps”, #6635) are the exception: XCTest
reports the app and its hierarchy relative to the app window, so
observereports the window’s size asscreenSizeand window-relative bounds.tapAt,tapOn,swipeOn, anddragAndDroptake points in that window space, and the iOS runner adds the window’s on-screen origin before it delivers the gesture. SpringBoard system alerts shown over the app are reported in the same window space, so their bounds can be negative or extend past the window. Points outside the window (other apps, the Dock) are not addressable while a windowed app is observed. A full-screen screenshot is not offset by the window origin; subtract it to compare screenshot pixels with observed bounds.
tapAt({ x, y }) performs one tap in those native units. Set
coordinateSpace: "normalized" for values from 0 to 1, or "percent" for
values from 0 to 100. Both axes resolve against the same observed screenSize;
the right and bottom endpoints resolve to the last in-bounds native point.
Values outside those ranges and non-finite values are rejected. The result
includes the resolved native x and y.
For hierarchy-derived points use native tapAt({ x, y }) (the default
coordinateSpace: "absolute"). For screenshot-derived points prefer:
tapAt({
image: {
unit: "normalized",
x: 0.25,
y: 0.5,
source: { crop: observation.crop, rotation: observation.rotation },
},
snapshotId: observation.snapshotReference.snapshotId,
});
Pass back the whole crop object from the same observe; cropPath is optional
and ignored. For a full screenshot use source: { screenshot: { screenSize,
screenshotOrientation, rotation } } from that observe instead. Native-oriented
rasters require rotation; display-oriented rasters need no turn. Image input
is mutually exclusive with outer x, y, and coordinateSpace. Add snapshotId
for existing stale-frame validation; changed screen dimensions always reject.
Fractions survive preview resizing. unit: "pixels" is for callers measuring
the real file, with half-open ranges [0, imageWidth) / [0, imageHeight);
full-screen pixels additionally require caller-supplied imageSize: { width,
height }, since observe does not return full raster dimensions. Out-of-range
unit mix-ups reject; in-range mix-ups cannot be detected, so agents should use
normalized. Normalized endpoints 1 resolve just inside the image. For fractional
crops, normalized spans the clipped native bounds; pixels preserve the raw
floor/ceil-snapped raster padding using its origin and actual scale. Orientation
is aligned automatically, and results report native x / y.
Set action: "longPress" or "doubleTap" for another coordinate gesture.
Long press defaults to 1000 ms and accepts durationMs from 500 to 10000;
durationMs is valid only for long press. Double tap uses two native taps
200 ms apart. The result includes action. All variants accept display.
tapAt, tapOn, tapAny, swipeOn, dragAndDrop, and pinchOn fence
coordinates when the display transitions after the caller’s observation or while
the action is preparing or dispatching. These failures include staleDisplay
with the observed and current identity generations and retry: "observe".
Observe again and choose the target from the new panel before retrying. A caller
without a prior observation skips the entry fence; in-flight transitions still
reject stale work.
This is separate from the daemon observation-stream’s
canonical-pixel mapping.
That stream contract is intentional and does not transform MCP observe or
native absolute-input coordinates.
observe, observe.screenSize, and tapAt use the device’s current-orientation
native coordinate space described above. For a fresh screenshot matching an
observation, call observe({ screenshot: "settled" }) and read its
screenshotPath.
Every returned full-screen fresh (settled or device-read), cached fallback, per-display,
and crop (crop-* and snapshot-of-*) path is kept for at least 10 minutes after return unless capacity pressure evicts it early (below). A flat <x>Path has a sibling
<x>ExpiresAt: top-level and per-display screenshotExpiresAt; objects owning a path have
expiresAt (crop.expiresAt and snapshotOf’s expiresAt). These optional numbers are
host-clock epoch milliseconds and are not a promise that the file survives that long.
Returning a cached path again extends its guarantee and recomputes the deadline.
Admission enforces a cap of 128 MiB and 4096 files for the shared screenshots directory across all devices and sessions in a process. A new capture is never refused. When it would exceed either cap, the least recently written or returned screenshots are evicted, in that order, even inside their guarantee, until it fits. Files no live observe cache entry or screenshot state references, and not touched in the last 5 seconds, go first; if they are not enough, eviction continues into referenced and recent files, still least recently used first, so a returned path (even one a live cache still references) may no longer exist when a later call uses it. Only a capture’s own in-flight write is never deleted. A single frame larger than the cap is admitted anyway and logged as a warning. Large-screen devices (iPhone Pro at ~3.3 MB per frame, a foldable’s inner panel) reach 128 MiB within one benchmark run, so copy a returned path promptly when a session captures many large frames. Observations without screenshots are unaffected. A per-process in-memory inventory tracks capacity. It reconciles with the directory on periodic or explicit sweeps and near either cap, picking up files from other processes. Concurrent processes can exceed the aggregate cap before reconciliation; discovered files count against subsequent admission. Cleanup failures are logged and retried; retained files whose cleanup failed still count against admission, so failure cannot allow unbounded growth.
The guarantee is the maximum of the return lease, file mtime plus ten minutes, and process-start grace. At the first sweep/capture after restart, pre-existing files without an in-memory lease receive grace through process start plus ten minutes: the previous process could have returned an old cached path immediately before it crashed. No persisted index is required. Other processes sharing the directory cannot see local leases and only honor the ten-minute mtime floor; re-return leases are guaranteed by the returning process’s cleaners, with this cross-process limitation.
Expired, unreferenced files are swept at initial inventory, near capacity, and every minute on an unref’d host Timer while idle; the idle sweep skips live files. Session release and device removal only drop cache references: their files remain until expiry and a subsequent sweep. Abandoned files are recovered after restart and swept when the grace expires. Copy files needed beyond the reported window; no copy is needed within that window when using one returning process.
For encoded captures, pass screenshotOptions with screenshot: "settled",
includeScreenshotImage: true, or crop,
for example observe({ screenshot: "settled", screenshotOptions: { format: "webp", quality: 80 } }).
Omitting options requests PNG. JPEG and WebP accept integer quality from 1 to 100. WebP also accepts lossless: true, which cannot be combined with
quality; PNG accepts neither. The returned screenshotFormat,
screenshotMimeType, and screenshotPath extension describe the saved bytes
after a platform capture fallback. The path does not depend on reading an
in-protocol screenshot resource. screenshotImageSize: { width, height } reports
the full file’s raster pixels as written after screenshotOptions encoding or
downscaling. screenshotPixelsPerNativeUnit: { x, y } reports raster pixels per
native unit, accounting for iOS native-orientation rasters after a quarter turn.
screenshotScaleProvenance is raster-dimensions, or native-scale-confirmed
when the hierarchy’s nativeScale agrees within 0.02. These fields describe the
file at screenshotPath, including a cached fallback, and remain in default
skeleton and diff output. They are omitted when dimensions are unreadable;
incompatible screen/raster aspect ratios retain screenshotImageSize but omit
the two scale fields. These best-effort failures log a warning and do not fail
observe. The screenshot’s orientation follows the device framebuffer:
on the iOS Simulator, the framebuffer can remain portrait after rotate, even
while the device orientation is landscape (this is simulator framebuffer
behavior, not an AutoMobile bug); on Android, the raster rotates with the
device. Therefore, after rotation, callers must apply a platform- and orientation-specific
transform before correlating iOS observe or tapAt coordinates with
observe screenshot pixels. No such transform is needed on Android. The
screenshotOrientation field identifies the returned raster orientation.
When observe has verifiable geometry and a frame context, it returns
snapshotReference: { snapshotId, expiresAt }. Pass snapshotId with native
screen coordinates to tapAt to bind the tap to that observation. References
are process-local and expire within five minutes. A tap rejects a reference after
device restart or reassignment, runner restart, or a change to the display panel,
role, posture, rotation, screen size, native scale, or app id. Android also
compares a known activity name, window type, and focused window identity and
bounds when both observations provide them. iOS does not provide Android
activity names; its available app-window metadata (when present) describes the
application window and does not detect in-app navigation. A display revision or
runner frame-event counter advancing by itself does not invalidate the reference.
A rejected tap does not silently recapture or retarget.
When a session observe cannot produce a reference, it returns
snapshotReferenceUnavailable, a string[] naming the missing capture
preconditions: display, screenSize, rotation, nativeScale, frameContext
(in that order). This field is absent when a reference is returned and on
deviceId reads, which never return a reference. On iOS, observe.rotation uses
runner-reported interface orientation when known; otherwise the settled screen
shape supplies 0 for portrait (width < height) or 1 for landscape (including
square screens). This fallback cannot distinguish upside-down portrait or
landscape direction. Runner landscape on a fixed portrait display resolves to 0;
runner portrait on a landscape-shaped unfolded panel is preserved.
Rotation values are 0 portrait; 1 landscape with the device top toward the left
(counter-clockwise, iOS .landscapeLeft, Android ROTATION_90); 2 portrait upside
down; 3 landscape right (iOS .landscapeRight, Android ROTATION_270). Values 2
and 3 on iOS require runner evidence. The host fallback affects only
observe.rotation, preserving the runner’s viewHierarchy.rotation for screenshot
crop direction.
This reference covers the full screen; it does not convert screenshot pixels into native coordinates or account for a crop. Unchanged geometry and window context do not prove unchanged visual content — re-observe after UI changes. The comparison deliberately does not detect scrolling, in-layout sheets, single-activity navigation, or WebView page changes.
hitTest previews the same {"x":120,"y":240} native screen target accepted
by tapAt, including its optional display selector. It is opt-in. Its
point, screenSize, and reference report the resolved screen geometry;
firstCandidate is null when no accessible bounds contain the point.
candidates contains at most 25 hierarchy nodes, ordered by reported window
layer, then interactive status, smaller containing bounds, and depth. The
response always says "method":"hierarchy-bounds" and
"dispatchGuaranteed":false. It sends no input. Gesture interception,
transformed hit regions, custom drawing inside a canvas, and screen-reader
behavior can all make the actual native event recipient differ from this
estimate. Re-observe after navigation, scrolling, or animation before using the
point with tapAt.
Stable automation IDs for Android Views¶
For legacy Android Views, use the accessibility extra test-tag as the stable
per-View automation ID. Set it in an AccessibilityDelegateCompat override of
onInitializeAccessibilityNodeInfo, after calling super:
info.extras.putString("test-tag", "widget_<id>")
Keep contentDescription as the accessibility-owned label. observe exposes the
ID as testTag in its searchable output; select it with
tapOn({ testTag: "widget_<id>" }). The raw hierarchy field is test-tag.
Compose with testTagsAsResourceId = true (including AutoMobile’s prototypes)
reports Modifier.testTag as a bare resource-id and no test-tag. A
testTag selector therefore matches nodes by test-tag first; only when no
node carries that tag does it match a node without a test-tag whose
resource-id equals the tag exactly (no pkg:id/ suffix matching).
Semantic node actions using testTag, uniqueId, or collection row + column
(with a stable ID) require a CtrlProxy runner that advertises node-action selector
support. Without that support, taps use coordinate routing and long presses use
coordinate input. Ordinary taps already use coordinates; semantic taps are used
for TalkBack activation and eligible DocumentsUI rows. A failed result for an
advertised semantic long_click is reported without silent coordinate fallback.
The non-TalkBack long-press path logs a thrown runner error and currently permits
coordinate fallback.
View.setTag(Object) and View.setTag(int, Object) are not supported automation
IDs: the AccessibilityService client receives AccessibilityNodeInfo, not live
Views, so those View tags are never serialized into the observed hierarchy.
Observation and screenshot resource failures¶
The observation resources are:
automobile:observation/latestautomobile:observation/latest/screenshotautomobile:observation/{deviceId}/{observationId}/screenshotautomobile:observation/session/{sessionUuid}/latestautomobile:observation/session/{sessionUuid}/latest/screenshotautomobile:device-session/{sessionUuid}/screenshot
An image response has an image/* MIME type and a base64 blob, with no text;
its PNG, JPEG, or WebP bytes and actual MIME type are preserved. A failure has
mimeType: "application/json", a JSON text body containing code, retryable,
and error, and no blob. Successful JSON observations remain JSON observations,
without a failure code.
retryable means reading the same URI again can succeed. Pending captures
also provide the positive integer retryAfterMs (currently 1000 ms). Cached
failures do not start another capture. Observation-scoped URIs identify an
immutable device observation: they never substitute a newer observation’s image
or another device’s image. A superseded or evicted observation requires a new
observe call and its new screenshot URI.
| Code | Retryable | Remedy |
|---|---|---|
INVALID_REQUEST |
false | Correct the malformed percent-encoded URI segment. |
OBSERVATION_NOT_RETAINED |
false | Call observe again and read the new observation URI. |
SCREENSHOT_NOT_READY |
true | Wait retryAfterMs, then read the same URI again. |
SCREENSHOT_CAPTURE_FAILED |
false for cached resources; true for fresh-session screenshots | Call observe again for a new cached capture; retry the same fresh-session URI to recapture. |
SCREENSHOT_CAPTURE_CANCELLED |
false | Start a new capture using observe, or request a new fresh-session capture. |
SCREENSHOT_CAPTURE_SUPERSEDED |
false | Call observe again and use the new observation URI. |
SCREENSHOT_NOT_CAPTURED |
false | Call observe with screenshot capture enabled. |
SCREENSHOT_FILE_UNAVAILABLE |
false | Call observe again to obtain a new retained file. |
SCREENSHOT_ACCESS_DENIED |
false | Use the owning session or restore screenshot file access, then obtain a new capture. |
SCREENSHOT_READ_FAILED |
false | Resolve the file/protection failure, then obtain a new capture. |
OBSERVATION_NOT_AVAILABLE |
false | Call observe first, then read its resources. |
OBSERVATION_READ_FAILED |
false | Resolve the observation read failure, then call observe again. |
SESSION_NOT_BOUND |
false | Read through the bound/owning device session. |
SESSION_NOT_ACTIVE |
false | Bind an active device session and use its URI. |
SESSION_OWNERSHIP_LOST (fresh-session only) |
false | Bind an active device session and request a new capture. |
When known, cached-resource failures include deviceId, observationId, and/or
sessionUuid; unknown or empty identifiers are omitted. Unauthorized session
reads include only the session identity and never reveal device data. Fields are
additive: consumers should tolerate additional fields and use code for
classification rather than parsing error. Stored cancellation/supersession
reasons are classified by exact recorder constants; retained-file failures use
errno codes (ENOENT/ENOTDIR versus EACCES/EPERM), independently of image MIME.
Fresh-session failure bodies retain their existing { code, retryable, error }
shape and classification.
Cropping an observe screenshot¶
observe({ crop: { element: { text: "Gmail" } } }) saves a PNG of one exposed
bounded element. element uses the same selector union as tapOn: elementId,
testTag, text, textAny, or accessibilityLink. Resolution uses the shared
ElementResolver with inspection intent, without promoting to a tap target.
The match must be unique; there is no first/random choice or index fallback.
textAny tries ordered variants and stops at the first matching variant;
an ambiguous matching variant fails. Embedded semantic accessibility links have
no independent element bounds and fail with an actionable request to select the
owning element instead.
For a screen rectangle, use
observe({ crop: { rect: { x: 10, y: 20, width: 30, height: 40 } } }).
Exactly one of element or rect is required inside crop. Rectangle numbers
must be finite, with positive width and height. Units are physical pixels on
Android and logical points on iOS, matching screenSize and tapAt.
crop implies a settled screenshot when screenshot is omitted. Explicit
screenshot: "async" or "none" is a validation error: async is rejected rather
than upgraded because a crop requires waiting for the completed capture.
screenshotOptions may encode the full screenshot as PNG, JPEG, or WebP;
the crop always uses that captured raster and encodes PNG without resizing.
Lossy full-screen encoding therefore also affects the crop’s source pixels.
includeScreenshotImage retains its existing meaning for the full screenshot
only; crop bytes are never embedded. Cropping reads the captured file even when
inline image delivery is disabled.
Observe captures the screen once through its settled path, then crops that file
using the returned hierarchy’s screen size, rotation, and native scale. With
waitFor (and its optional settled quiet gate), this is the final capture after
polling completes. A timed-out wait retains observe’s usual timeout metadata and
crops its terminal observation. The original screenshotPath and
screenshotOrientation remain present alongside crop:
cropPath: local filesystem path to the securely written PNG.unit:"pixels"or"points".requestedBounds,clippedBounds: native{ left, top, right, bottom }bounds.clipped: whether visible-screen clipping changed the requested bounds.screenSize,imageSize: native screen dimensions and upright output crop PNG dimensions.pixelsPerNativeUnit: raster scale{ x, y }; actual raster dimensions handle Display Zoom and downsampled devices, with floor/ceil covering fractional points.scaleProvenance:"native-scale-confirmed"or"raster-dimensions".rasterBounds: integer bounds read from the captured source raster before orientation normalization.screenshotOrientation: orientation of the output crop PNG ("display"). Native iOS framebuffer crops are rotated upright: rotation 1 maps display(x, y)to(screenHeight - y, x)and rotates the extracted pixels 270° clockwise; rotation 3 maps to(y, screenWidth - x)and rotates 90° clockwise; rotation 2 maps to(screenWidth - x, screenHeight - y)and rotates 180°. A raster already reported in display orientation is neither remapped nor rotated. No downscaling occurs. The full screenshot retains its ownscreenshotOrientation. The same crop mapping and normalization apply tosnapshotOf.
The hierarchy/elements are unchanged by crop. Element resolution uses the full
filtered exposed hierarchy before raw append or skeleton projection; raw does
not switch crop to a separately fetched raw tree. project: "full" (including
raw’s default full projection) applies scope to element lookup exactly as it
does to the returned hierarchy. Skeleton projection already ignores structural
scope transforms, so element lookup also retains the full exposed tree in that
mode. Rectangles always use the selected display’s full screen coordinates;
scope does not shift their origin or clip them to a subtree.
Sessionless deviceId reads support crop against that read’s hierarchy and a
fresh settled screenshot. Their existing waitFor, raw, and skipBackStack
restrictions still apply. Crop rejects capture failures before cached screenshot
fallback. display selects the same panel for hierarchy, screenshot and crop;
non-default panel coordinates and dimensions are used. display: "all" is
explicitly rejected with crop; choose one panel.
Invalid input forms or screenshot modes fail validation. Missing or ambiguous
elements, empty/off-screen rectangles, missing hierarchy geometry, missing or
invalid screenshots, capture failures and crop/write failures throw
ActionableError. Partially visible rectangles are clipped to the screen.
There is no full-screen fallback, cached crop, silent retargeting, or successful
result with screenshotSettled: false for a crop request.
Screenshot delivery to local and remote clients¶
For a local client with access to the AutoMobile host filesystem, observe returns
the screenshot path in its structured observation. This remains the default: omitting
includeScreenshotImage or setting it to false never embeds image bytes; without
crop, it also avoids reading them for tool delivery.
Use screenshot: "settled" when the path must correspond to the completed observation.
For a remote client that cannot read that path, call
observe({ screenshot: "settled", includeScreenshotImage: true }). The tool keeps
the structured observation and its path, and adds an MCP image content block with
the exact captured bytes and the observation’s reported MIME type when available
(otherwise detected from bytes or file extension). Setting includeScreenshotImage:
true alone also selects settled capture. It cannot be combined with explicit
screenshot: "async" or "none".
Inline delivery supports PNG, JPEG, and WebP, with a 5 MiB file limit. The
structured result includes screenshotImage: { included: true, mimeType, sizeBytes }
when delivery succeeds. If the path is missing, the image exceeds the limit,
its format is unsupported, or reading fails, the observation still returns with
screenshotImage: { included: false, reason, sizeBytes?, capBytes? } and its
usual screenshot path when available. Use that path (local clients) or the
observation screenshot resource (remote clients) for the completed capture.
Cancellation before or during delivery stops the response. The opt-in can substantially increase MCP
response size, so use it only when the client needs image bytes in the tool result.
| Tool | What it does |
|---|---|
📸 snapshotOf |
Saves a PNG crop of an element or screen rectangle (opt-in). |
👀 observe |
Gets screen hierarchy and screenshot, with optional PNG crop. |
🎯 hitTest |
Estimates hierarchy nodes beneath a coordinate without dispatching input. |
🔍 explore |
Explores an app to build a navigation graph. |
🗺️ navigateTo |
Navigates using the learned navigation graph. |
📊 getNavigationGraph |
Retrieves the navigation graph for debugging. |
🔗 identifyInteractions |
Suggests likely interactions. |
🪟 prototype |
Shows, dismisses, awaits events, or reports prototypes. |
🖍️ highlight |
Draws a visual highlight around a UI element. |
prototype¶
The prototype tool (Android and iOS simulators) is omitted from discovery by default. Enable it
with setToolEnabled { toolName: "prototype", enabled: true }. Its action is
show, dismiss, status, inspect, or awaitEvent. show requires a full spec (id,
window, optional state, root) and always renders the whole spec. Instead of an inline
spec, show accepts specPath, the absolute path of a local JSON file with the same spec
(exactly one of the two): the daemon reads it, refuses a file over the spec byte limit (1 MiB)
before reading it, validates it like an inline spec, and names the file in every error.
The path is never sent to the device. A spec holds up to 2000 nodes, and a repeat up to
128 items. dismiss
requires either id or all: true. spec.window.opacity is an integer
percentage from 0 to 100, default 100; show the spec again to change it.
A show whose spec.id is the prototype already on screen replaces it in place:
it stays on the display it is on, and each pager keeps its current page (matched
by pager id, clamped to the new page count). The new spec’s state is
authoritative: values the user changed by tapping or typing are not carried
over unless the spec includes them, and the rebuilt tree starts any text field
from the spec’s value. reset: true starts it fresh instead: pages come from
the spec and display is resolved again. A show with a different id, or with
nothing on screen, replaces any other prototype as before. A CtrlProxy that
predates in-place replacement ignores reset and always starts fresh.
Two optional window fields need a CtrlProxy advertising
prototype_window_options_v1; an older one is refused before anything is sent.
spec.window.layer is system (default; an accessibility overlay above system
UI) or app (an application overlay just above apps, so the notification shade,
keyboard, toasts and the screenshot flash and preview draw over the prototype,
and the status and navigation bars draw over a fullscreen one). Before an app
show the daemon runs
adb shell appops set dev.jasonpearson.automobile.ctrlproxy SYSTEM_ALERT_WINDOW allow;
if the permission is still missing the device fails the show with that command.
spec.window.persistence is session (default) or device: the prototype stays
interactive after the last host client disconnects (USB unplugged, adb or the
daemon gone) and after session end, has no idle timeout, and keeps its uploaded
assets. setPage, setState, text fields and the dismiss action keep working
offline. It goes away only through its own close control (the fullscreen dismiss
row, or a Close button on sheet and floating windows), an explicit dismiss, a
replacing show, or the CtrlProxy service stopping. Host-side status and event
buffers are still cleared on session release.
With no host connected, a persisted prototype’s events (tap emits, page changes,
text input, a close) are kept on the device, the most recent 200, dropping the
oldest and counting what it dropped. They are delivered, oldest first, when a
host connects or on inspect, and sequences continue from the device’s ledger
with no rewind. status is host memory, so after a session release it shows
nothing; inspect asks the device which prototypes it is showing and adopts them
(adopted: true, with the last known pages and state), so status,
dismiss and awaitEvent work again. It also returns
deviceDroppedEvents. inspect needs a CtrlProxy advertising
prototype_persistence_replay_v1 and is refused with an error naming the flag
otherwise.
The showVariants and update actions were removed (#10489, #10490); calling
either returns an error naming show as the replacement. To present
alternatives, show one design, describe it and the others in the conversation
(what each is, what changed between them, which you recommend), and show the
next on request. Or show one spec whose pager holds every design, with a
visible label on each page. Either way, ask the user in chat which they prefer;
never wait on the device for a choice.
Target via deviceId, platform, device, or sessionUuid; the shared
keepScreenAwake option also applies. timeoutMs bounds device requests
(default 5000 ms). Validation uses the existing prototype schema and limits
before contacting CtrlProxy. Verify rendering with observe; prototype returns
no screenshot. Nodes include box/row/column, text/image/icon/spacer/textField,
Material switch/checkbox/button/radioGroup/listItem/slider/chip/card/iconButton/fab/segmentedButton/topAppBar/divider/badge/progress/dialog/snackbar/timePicker/datePicker
bound to state keys, and scroll/pager/tabBar/bottomNav/bottomSheet; actions are
emit/setPage/setState/toggle/increment/decrement/dismiss.
A spec may also carry a theme (light/dark mode, a seed colour, per-role colour
overrides, typography and shapes), per-node style and styleWhen fields,
transition on visibleWhen nodes, and a spec-level motion of none to make
every change instant. Android also accepts element anchors, which place a node on
an app element resolved at show time; the result lists the resolved anchors.
See the prototype vocabulary.
Light and dark mode¶
A shown prototype has one resolved mode, light or dark. The first of these that applies decides it, on Android and on iOS:
theme.modelightordark.- The luminance of the flat
theme.colors.backgroundoverride, elsetheme.colors.surface. - The first opaque authored background on the root’s leading chain.
- The system setting: the show’s
appearancewhen it islightordark, else the device’s own setting.
theme.mode: "system" asks for the system setting outright and skips steps 2 and 3.
show accepts appearance: device (the default), light or dark. light and dark pin
what the system setting means for that one prototype, so a spec with theme.mode: "system" or no
mode can be checked in both modes. The device itself and the app behind are not changed; use
displayConfig (and restore the previous value it returns) to put the app into the same mode.
The override replaces the system setting only (step 4), so it never beats what the spec itself
says. It belongs to the show: a later show of the same id without it follows the device again.
light and dark need a device advertising prototype_appearance_v1; without the flag the
call is refused before anything is sent, with an update hint on Android and a relaunch hint on
iOS. device sends nothing and works on every device.
A device advertising prototype_appearance_v1 reports
appearance: { mode, source, deviceDark }. mode is light or dark. source names the step
that decided it: explicit, roleLuminance, authoredBackground, override or system.
deviceDark is the device’s own setting whatever decided the mode. Only a device answer states it,
and only as of that answer: the show result and each inspect entry. The host never infers it,
and the host-local status action never reports it: the device can flip while a pinned or
authored mode stays put, which sends no event, so a remembered value could be wrong. Call
inspect for the current one. A successful show returns the object in lastResult, and
inspect returns it on each prototype; status returns mode and source only. The field is
absent when the device lacks the flag, on dismiss and on a failed show; a failed same-id show
keeps the mode and source of the prototype still on screen.
While a prototype is shown it follows the device live. On any change of the resolved mode (the
device flipping, or prototype state changing a background the mode is inferred from) the device
sends one prototype_event with kind: "appearance_changed", name: null and
payload: { mode, source }. Nothing is sent when the mode stays the same, nor for the show
itself. Wait for it with awaitEvent and kind: "appearance_changed" (its name is null, so
eventName does not match it). The event also refreshes the appearance that status reports:
mode and source come from the event. deviceDark is not part of status at all (see above);
the next show result or inspect states it.
In a scrim slot (window.placement.scrim, a bottomSheet scrim) only the scrim role is drawn
translucent, at 0.4 alpha; any other role is drawn unchanged. The per-mode forms
({light, dark} colour and image pairs, role names in gradient stops and scrims,
theme.colors.light / theme.colors.dark) need a device advertising
prototype_theme_modes_v1.
iOS simulators¶
On iOS simulators prototype is backed by a prototype agent injected into the app at launch
(launchApp { prototype: true }), with no SDK in the app. It works on simulators only, only for
apps launched with the agent (a relaunch loses app state; SpringBoard is not covered), and
offers show, dismiss, status and awaitEvent; showing
the same id again updates the prototype. For apps run from Xcode, add DYLD_INSERT_LIBRARIES
to the scheme. See the iOS prototype agent.
show accepts the same optional display selector as the tap tools (a panel
key, a role such as inner or cover, or active), resolved with the same
precedence: an explicit display, then the session display pin, then the default
display. Omitting it sends no display and behaves exactly as before. A resolved
non-default display is sent as the panel’s logical displayId and needs a
CtrlProxy advertising prototype_display_id_v1; an older APK is refused with an
error rather than showing the prototype on the default display. An unknown or
disconnected panel is refused with the usual disconnected-panel guidance. If the
panel disappears while the prototype is up (fold), the device dismisses it with
reason teardown; it is never moved to another display. dismiss acts on the
prototype where it is shown and does not take display. A same-id show without
reset keeps the display the prototype is on: a display (explicit or from the
session pin) that resolves elsewhere is ignored, and the result carries a
warning saying so; pass reset: true to move it.
display and assets combine on show: the display is resolved and checked first, so a
refused display uploads nothing; assets are then uploaded and the prototype is shown on that
display. The one missing-asset re-send goes to the same resolved display without re-reading the
display inventory. A same-id show with assets stays on the display the prototype is already on.
show accepts assets: an array of { id, path }
or { id, observation } that uploads images (or, with path, TTF/OTF fonts) before the prototype is sent, so no
separate upload step is needed. path is an absolute path the daemon can read
(relative paths are rejected); observation is an
automobile:observation/{deviceId}/{observationId}/screenshot URI (the
observationScreenshotResourceUri that observe returns) and resolves exactly
as reading that resource does: the observation must still be its device’s
current one, a capture still in flight is awaited, and no session ownership is
needed because the resource itself needs none. Each entry has exactly one of
the two. The type is detected from the bytes’ signature and must be PNG, JPEG
or WebP, up to 4 MiB per asset, or a TrueType/OpenType font (.ttf/.otf,
path only), up to 2 MiB per font; 16 MiB and 32 assets per call in total, with
unique ids. Image nodes reference an id (image.asset; nav items use image);
style.fontFamily: {"asset": "<id>"} references an uploaded font; the spec
never carries paths or bytes. Every file is read and checked first, so an
unreadable file, unsupported format or exceeded cap fails the call with nothing
sent. Uploads then run one at a time. Any failure (a device refusal, an old
CtrlProxy without asset support, a cancelled request, or a write that was never
answered, reported as indeterminate) fails the call before the prototype is shown
or replaced; the error and uploadedAssets name the assets already stored, which
stay on the device until the prototype session ends and are replaced if the call is
repeated. On success uploadedAssets lists each { id, mimeType, bytes }.
When the device accepts a show but lists referenced asset ids it has
no copy of, the result stays successful and adds missingAssets (the ids; absent
when none) and a warning naming what to upload. If the same call supplied
assets for some of those ids (the upload-then-cleared race), they are
re-uploaded from the bytes already read and the prototype re-sent exactly once; the
result is the re-sent one. A retry that fails, or a cancelled request, keeps the
first result and says so in warning. The MCP request deadline grows by 15 s per
asset plus the request’s timeoutMs (default 5000 ms), doubled for that one
retry, plus 10 s per observation source for a capture still in flight, and
30 s of headroom.
status performs no device request. It reports only prototypes successfully
shown by this host in the current session and device, with their last action,
result, and host timestamp in milliseconds, plus the logical displayId when a
display was requested. It also returns the last attempted
mutation as lastResult, including a failed show without claiming it is shown.
A failed same-id show or dismissal retains known presence. Successful dismissal removes
the id from that device’s host records; successful dismiss-all clears that device’s entries across host sessions.
After an event arrives, each shown entry also reports pendingCount, lastSequence
(the highest accepted sequence), and cumulative overflow droppedCount. Before any
event, these optional fields are omitted to preserve existing responses.
Each shown entry also includes pages (pager id to zero-based page index), flat
state, and lastKnown: true from its latest accepted prototype_event. These
snapshots survive event consumption and failed show attempts; a successful show (same id or not) clears them
until another event arrives. Requested state is never reported as observed state.
Accepted events are pushed once to telemetry under category prototype, with
owning device/session ids, event id, kind, name, sequence, pages and state. This
telemetry is push-only, with no database persistence or historical backfill.
A device-side dismissed event removes shown presence across that device’s host
sessions. A page_changed event has the pager id as name and the new zero-based page index as
payload, on Android and iOS, so awaitEvent with kind: "page_changed" and eventName set to a
pager id waits for that pager; its pages map still carries every pager. page_changed events
update the last known snapshot and event bookkeeping, preserving host mutation
status. Raw transport disconnects are not observed; session release, device removal,
and device unbinding clear the corresponding buffers and host status.
awaitEvent requires id; it waits for one event in the current session/device/id
scope. Optional eventName (matching name), kind (emit, page_changed,
dismissed, or appearance_changed), and afterSequence (a nonnegative integer, strictly exclusive cursor)
are valid only for awaitEvent. An event of a kind this host does not know (a newer device) is
logged and skipped: it is never returned, but its sequence still counts toward lastSequence. Its timeoutMs defaults to 30000 ms and cannot
exceed 60000 ms; the MCP request deadline for an awaitEvent call is that wait plus 30 s of
headroom, so a quiet wait ends with timedOut: true rather than a transport timeout. Request cancellation preserves the abort reason and removes the
waiter’s timer and abort listener. When the client supplies an MCP progress callback,
wait start/finish notifications are best-effort and do not delay event delivery. Background subscriptions for shown prototypes
remain active to buffer events between calls.
The result is { success: true, event, pendingCount, lastSequence, droppedCount },
with event containing id, sequence, kind, name, payload, state, pages,
and timestamp (device clock milliseconds). If the wait expires, it returns
success: true, timedOut: true with counts and no event. If the scope ends without
a matching event, it returns success: true, reason: "dismissed" with counts.
The exported PROTOTYPE_EVENT_BUFFER_CAPACITY is 64 events per scope. Overflow drops
the oldest event and increments droppedCount; that count is cumulative until
explicit dismiss or scope release. Calls consume one matching event; excluded events
remain pending, including those skipped by a cursor. Accepted events are ordered
by sequence. Lower-or-equal sequences are ignored across reconnects, including
previously unseen late lower arrivals; this implements the wire high-water rule.
Because pushes carry no session id, the latest show of a device/id owns its events;
awaiting that id from another scope is rejected until it is shown in that scope.
Awaiting an unknown id can establish ownership without a show; an unused scope is
removed on timeout/abort. Multiple prototypes on a device share one subscription.
Explicit successful dismiss (id or all) clears buffers across that device’s host
sessions. Each show, including a same-id show, starts a fresh sequence epoch for that id: pending events and the
sequence high-water mark are cleared (the device’s sequence ledger is in memory, so a
CtrlProxy restart restarts a re-shown id at 1), while the cumulative droppedCount
is kept until explicit dismiss or scope release. Events carry only id, sequence and
timestamp, so a late event from the previous showing cannot be told apart from the new
showing’s and is accepted if it arrives after the new show. A successful show of
another id replaces the device’s prototype: the replaced prototype’s waiters settle with
reason: "dismissed" and its buffers are removed. A terminal dismissed event remains
buffered until consumed or the next explicit show/dismiss. Consuming it clears all
remaining pending events and, once no waiter remains, removes the entry, so a later
await behaves as for an unknown id (it waits, then times out). Session release clears
the buffers, waiters and subscription of every scope on each device the release
clears from host status, so event state and status never disagree.
The device subscription ends when no nonterminal prototypes remain.
{
"action": "show",
"spec": {
"id": "demo",
"window": { "placement": { "type": "fullscreen" }, "opacity": 80 },
"root": { "type": "text", "text": "Hello" }
}
}
Navigation and highlight options¶
explore.timeoutMs bounds exploration (default 300000 ms); strategy is
breadth-first, depth-first, or weighted (default). mode is discover,
validate, or hybrid (default), and packageName limits exploration to a package.
getNavigationGraph.appId scopes the graph to that app instead of the foreground app.
explore, navigateTo, and getNavigationGraph are listed without --debug. They remain
off by default and require embedded SDK mode (--embedded-sdk).
identifyInteractions.filter accepts types (navigation, input, action,
scroll, toggle), minConfidence from 0 to 1, and a positive integer limit.
highlight takes either shape (a circle with bounds) or an elementId/text
selector, never both. elementId is a resource ID; text matches text,
content description, or placeholder. selectionStrategy is first (default),
random, or unique (ambiguity returns the resolver failure). container accepts
a nested chain with per-level index and selectionStrategy, resolved outermost
first through the same resolver as tapOn. description labels the highlight,
and timeoutMs bounds the highlight request (default 5000 ms).
explore accepts positive integer maxInteractions (default 200), a positive
timeoutMs (default 300000 ms), resetToHome to return home
periodically (default false), positive integer resetInterval (default 15 interactions), and
dryRun to explore without performing interactions.
navigateTo.targetScreen names the target screen in the learned graph.
highlight.containerOf highlights the selected element’s container.
identifyInteractions.includeContext controls navigationGraph predictions,
elementDetails, and suggestedParams; each is included unless set to false.
Android back stack user IDs¶
observe.backStack.activities[] lists activities across Android users/profiles.
Each activity and currentActivity includes optional userId, the Android
user/profile ID when printed by dumpsys (including 0 for the personal user).
tasks[].userId comes from the task header. Task IDs are global across users;
currentTaskId identifies the foreground task, and depth counts that task’s
activities minus one. Other users’ tasks do not contribute to its depth.
Observe a booted device by ID¶
observe {"deviceId":"emulator-5554"} returns the normal screen observation,
including hierarchy or skeleton, active window, screen size, display, device lock,
and an available screenshot with its fresh or cached label. With sessionUuid,
the call is a session observe, and deviceId must match that session’s device.
Reading a device you do not own (deviceId)¶
A read never writes settings on a device it does not hold, with one exception: readiness needed to answer the read.
A sessionless read that names a running device nobody holds may ready it in order to answer: on Android it may
install the CtrlProxy APK, write the accessibility settings, and force-stop and rebind the service; on an iOS
simulator it may install and launch the runner. The exception never applies to a device held, slot-assigned or
reserved by another session: those reads use what is already installed and never run readiness.
(Owner decision 2026-10-12, #11964.) See
Which read tools ready an unheld device
for the per-platform registry readiness table and observe’s separate service-start path.
A session-less deviceId read may start the hierarchy service on an unowned
booted device when its initial connection fails. It uses the session acquisition
setup (including --skip-ctrl-proxy-download) within the read’s own hierarchy
deadline, serialized with acquisition by the per-device readiness lock. Setup
requires an initialized daemon, an idle device with no session, and no ownership
or pool transition; these conditions are checked before setup and again under
the lock. Concurrent reads share one setup; each reader retains its own wait
deadline and cancellation. The initiating read supplies the shared setup’s deadline.
On an owned device, a deviceId read never starts, restarts or reconfigures the
service. A disconnected owner client stays connect-only with the recovery detail
below. If ownership cannot be established (including an uninitialized daemon),
the read stays connect-only. No session is created, no device is assigned, and an
unowned device’s pool status stays idle. A successful read that participated in
starting the service includes hierarchyServiceStarted: true; otherwise that
optional field is omitted. This does not degrade freshness.
The started service and its resident singleton client remain running afterwards, as they do after a session ends. On iOS this includes the manager’s runner process. There is no cleanup or idle teardown in this read path.
Unavailable hierarchies have freshness.category: "unavailable". The table shows
exact unavailableDetail templates (${deviceId} and ${timeoutMs} are replaced
with the device ID and connection budget; the default hierarchy budget is 15000ms).
The detail is also appended to freshness.warning and limited to 500 characters.
unavailableReason |
unavailableDetail |
Caller recovery |
|---|---|---|
connection_lost |
Device ${deviceId} has no reachable hierarchy service |
The read may append why starting the service failed or was declined; retry after the reported ownership/transition or setup problem clears. |
connection_lost |
Device ${deviceId} is session-owned and has no connected hierarchy service: the owning session's hierarchy client is disconnected. Run a session observe as the owner to reconnect it; a deviceId read only connects to an already-running service. |
The owner must run a session observe to reconnect. This read never creates a second client next to the owner’s. |
connection_lost |
Device ${deviceId} hierarchy service did not answer |
The owned client’s socket was connected but the read received no hierarchy; retry, or have the owner check the service through a session observe. |
connection_lost |
Device ${deviceId} hierarchy read timed out |
The connection consumed the hierarchy budget before extraction; retry. |
connection_lost |
Observer hierarchy connection for ${deviceId} timed out after ${timeoutMs}ms |
The transient connection exceeded its deadline; check service reachability and retry. |
request_timed_out |
Owner's in-flight request exceeded the observer hierarchy deadline |
Let the owner’s tracked request finish, then retry. |
connection_lost |
Other caught connection/conversion error text | Check the reported error and retry; cancellation propagates as an error instead of an unavailable observation. |
incomplete_capture |
Absent for a rootless incomplete Android capture | Retry after the UI settles; follow the cause-specific freshness warning if the capture remains incomplete. Observer reads do not run the device-writing UIAutomator fallback. |
runner_unavailable |
Android: Automatic CtrlProxy recovery is exhausted/suspended after N attempt(s); last failure: ... |
Automatic recovery will not run again; run startDevice or begin a new session to rearm it. |
unknown |
Absent for a returned error hierarchy or failed platform validation without a typed cause | Retry and inspect the service’s capture; this is the freshness fallback for an unclassified unavailable hierarchy. |
The observer’s result does not update the owner’s session baseline, snapshot
references, observe cache or its generation, screenshot state, navigation graph,
stream, active-window cache, or display-transition tracker. Android display/posture probes may read
shared mappings but never populate or refresh them. Once hierarchy setup is complete or declined, Android observation commands issued
through ADB are read-only (including streamed screencap, dumpsys, and display
and device-state queries); they do not delay, abort, fail or reorder an ADB-driven
owner action. A read during an action can show intermediate UI: it is not an
atomic snapshot of that action. Hierarchy reads queue behind requests tracked by
the service client, within the hierarchy budget. Android still delivers a changed
observer hierarchy frame through the normal native push path; those ordinary
service updates can update the stream, navigation and display state independently
of observation assembly. An unchanged observer frame skips that path. Reads close any temporary client and release its host port forward/allocation; a
resident client registered by successful readiness setup stays connected.
Android ADB screenshots share a capture lock; the observer waits at most 10 seconds
without cancelling the owner’s capture. Android can capture without CtrlProxy,
and iOS simulators can capture via simctl. An unowned physical iOS device without
a reachable runner has no host-side screenshot capture path. Its exact
screenshotSettledError is:
No screenshot could be captured: this unowned physical iOS device has no reachable runner. Physical iOS has no host-side screenshot capture path without the runner.
With no eligible cached screenshot, screenshotPath is absent. Otherwise the
cached screenshot is returned with screenshotSource: "cached" and the same
capture failure detail. In both cases screenshotSettled is false. The hierarchy
connection failure still reports unavailableReason: "connection_lost".
Session-less device reads reject waitFor, raw: true, and skipBackStack: true
before capture. Use a session observe (sessionUuid) for waiting; settled
requires waitFor and is covered by that rejection. Use project: "full" for the
full filtered hierarchy. Device reads omit snapshotReference.
The default screenshot: "settled" awaits a fresh validated capture; it does not
wait for action history to settle. screenshot: "async" also awaits capture on
device reads, while "none" skips it. Capture failures retain eligible cached
screenshots with their cached label and failure details.
With crop, capture failure throws before cached fallback; only a fresh validated
settled screenshot is eligible.
Interact with the UI¶
| Tool | What it does |
|---|---|
📍 tapAt |
Taps screen coordinates or a point measured in an observation image; supports long press and double tap. |
👆 tapOn |
Taps by text, content description, resource ID, or Android test tag; supports nested containers and first/random/unique selection; can ensure toggle state. |
🎯 tapAny |
Taps any clickable element, optionally scoped to nested containers; supports first/random/unique selection. |
👉 swipeOn |
Swipes or scrolls the screen or an element; container and lookFor support nested scopes and first/random/unique selection. |
↔️ dragAndDrop |
Drags one element to another; each endpoint supports nested containers and first/random/unique selection. |
🤏 pinchOn |
Pinches to zoom, optionally scoped by nested containers. |
⌨️ sendKeys |
Runs ordered text, clear, raw-key, and semantic-key commands. |
🧩 setUIState |
Sets multiple form fields to a desired state. |
✨ selectAllText |
Selects all text in the focused input. |
🔘 pressButton |
Presses a device or navigation button. iOS simulators support volume and power; iOS does not support menu. |
⌨️ keyboard |
Opens, closes, or detects the keyboard; selects AutoMobile profiles or installed Android IMEs. |
📋 clipboard |
Copies, pastes, clears, or reads the clipboard. |
Tap, swipe, and form search options¶
tapOn.searchUntil polls for the element before tapping; its optional duration
is 100–12000 ms (default 1500). preTapStability requires stable bounds before
tapping, and retryIfNoChange retries once when the hierarchy is unchanged after
a tap. ensureTap enables both checks. Direct semantic-link activation cannot
use searchUntil.
tapAny.searchUntil polls for an eligible clickable element with the same
duration range and default. scrollableContainer restricts its search to
scrollable containers/lists.
swipeOn.gestureType chooses whether direction describes finger movement
(swipeFingerTowardsDirection, the default) or content scrolling
(scrollTowardsDirection). setUIState.scrollDirection sets the initial
search scroll direction.
A successful swipeOn that can compare the screen before and after reports
navigated. navigated: true means the screen identity changed, so the swipe
probably acted as a tap and opened the row under it instead of scrolling, and
warning says so; navigated: false means the screen is the same. The field is
absent when the two observations carry no comparable screen identity.
On Android, keyboard can list installed input methods with
{"action":"listImes"} and select an enabled component with
{"action":"setIme","imeId":"…"}. To exercise one visible key in an installed
IME, focus an editor first, then call
{"action":"tapImeKey","imeId":"…","key":"a"}. This action selects the requested
IME for one frame-bound physical tap, reports whether the same focused editor
changed without returning its text, and restores the original active IME.
The requested component must already be installed and enabled; the key must
have one visible, package-owned accessibility match inside an IME window.
Unobservable or ambiguous keys fail closed. If restoration cannot be verified,
AutoMobile quarantines further IME changes until restart.
If a previously observed focused editor or IME window disappears while waiting
for a key, the session reports focus loss and restores the original keyboard.
Enabled-set drift is reported with bounded component IDs; the session never
overwrites external enable/disable changes.
tapOn and tapAny accept recursive container selectors. The outermost
container resolves first; every inner container and the target must be strict
descendants of the previous level, including across anonymous wrappers:
{
"selector": { "elementId": "remove" },
"container": {
"elementId": "item_42",
"container": { "elementId": "cart_A" }
},
"selectionStrategy": "unique"
}
For tapAny, omit selector to select any eligible clickable descendant.
Like tapOn, tapAny accepts display (panel key, role, or active); an omitted
value uses the session display pin when present. Android selection, polling,
retry, and input stay on that panel; iOS requires the live panel. Explicit panel
targeting requires a prior observation of that panel, as with tapOn.
The default remains first; random keeps its existing behavior. unique
requires exactly one eligible target and exactly one match at every unindexed
container level, even when that level specifies first or random. A
container’s explicit zero-based index selects within that level’s scoped
candidate set; tapOn.index similarly overrides leaf uniqueness. Missing or
ambiguous levels fail without a tap or a global fallback. Errors name the
container level (outermost is 1) or target and show up to five ambiguity
candidates with resource IDs, text, and bounds.
For ordered textAny selectors, unique skips missing text variants within the
same scope, but fails immediately on an ambiguous variant or container.
unique supports ensureChecked and owner-scoped subtext. It cannot combine
with sibling or direct accessibilityLink; select a unique owner with
subtext for semantic links. The existing random/subtext and indexed-owner
restrictions still apply. Nested or unique taps use the selected coordinates or
bounds for native dispatch instead of a global resource-ID lookup. With iOS
VoiceOver enabled, such tapAny calls require a label for activation at the
selected bounds; an ID-only target fails without an action.
dragAndDrop.source and dragAndDrop.target each accept exactly one of
elementId or text, plus their own optional recursive container and
selectionStrategy (first, random, or unique; default first):
{
"source": {
"elementId": "remove",
"container": {
"elementId": "item_42",
"container": { "elementId": "cart_A" }
},
"selectionStrategy": "unique"
},
"target": { "elementId": "cart_B" }
}
Both endpoints resolve independently before any drag starts. The same outermost
first scope rules and container indices apply. unique requires one eligible
leaf and one match at every unindexed container level. Missing or ambiguous
scopes and leaves fail without a drag or global fallback; errors identify
source or target, retain the container level or target failure, and list
ambiguity candidates. Scoped and unscoped endpoints can be mixed. These fields
belong inside each endpoint, not at the top level; unknown endpoint keys and
malformed recursive containers are rejected.
Hierarchy layer¶
observe, tapOn, tapAny, sendKeys, highlight, dragAndDrop, swipeOn,
pinchOn, tapAt, selectAllText, and identifyInteractions accept an optional top-level layer
("app" or "prototype") that scopes the call to one layer of the screen. app
excludes AutoMobile’s own prototype window; prototype keeps only prototype nodes and
fails with an actionable error when no prototype is showing. Omit it to search both,
topmost first. observe applies it to the returned hierarchy and to waitFor
element conditions. identifyInteractions.layer analyzes only that layer’s
elements. dragAndDrop.layer scopes both the source and the target
drop-target resolution; swipeOn.layer scopes container, auto-target, and
lookFor resolution; pinchOn.layer scopes container and auto-target resolution.
With layer: "app", an Android CtrlProxy that advertises
screenshot_hide_prototype_v1 hides its prototype for the observe capture: it hides
the window, waits for a rendered frame, captures and restores, all on the device in
one request, so a cancelled observe never leaves the prototype hidden. While an
prototype is showing, the result then reports screenshotIncludesPrototype: false; a
capture that cannot confirm the hide fails instead of falling back to ADB. Older
CtrlProxy builds, iOS, and deviceId reads (which capture through ADB) still
include the prototype in the screenshot or crop, and the result says so with
screenshotIncludesPrototype: true. Navigation-graph
screen identity always uses the app’s windows only, so showing, paging, or
dismissing a prototype records no navigation.
Touches go to the window under the point where a finger goes down, so gestures
are checked there before dispatch: the tap point (tapAt, tapOn, tapAny), the
swipe start (swipeOn), both finger start points (pinchOn), and both drag
endpoints (dragAndDrop). With layer: "app" a gesture whose point lies under an
prototype window is refused. With layer: "prototype", tapAt, swipeOn, and
pinchOn also refuse a point outside every prototype window, because it would
reach the app. There is no touch-through mode: hide or move the prototype to reach
the app beneath it.
selectAllText acts on the input-focused field and is refused when that field is
on the other layer. layer on sendKeys and highlight requires a selector,
tapOn rejects it together with accessibilityLink or subtext, and swipeOn
rejects it together with display.
swipeOn.container identifies the element to swipe within and accepts the same
recursive container, per-level index, and selectionStrategy fields. lookFor
accepts exactly one of elementId or text, plus its own recursive container
and selectionStrategy (first, random, or unique; default first):
{
"direction": "up",
"container": {
"elementId": "list",
"container": { "elementId": "panel_A" },
"selectionStrategy": "unique"
},
"lookFor": {
"text": "Target",
"container": { "elementId": "section" },
"selectionStrategy": "unique"
}
}
The swipe container resolves before dispatch. A plain one-level container and
unscoped lookFor keep their existing behaviour, including falling back to an
auto-detected scrollable when the container misses during scroll-until-visible.
Nested chains, selectionStrategy, or container index opt into scoped resolution:
a swipe container miss or ambiguity fails before a gesture without automatic or
global fallback. The outermost lookFor scope resolves within the swipe container,
and every later level and leaf uses strict ancestry, traversing anonymous wrappers
and allowing inert containers. With a scoped lookFor and a legacy swipe container,
the swipe container keeps its existing fallback behaviour and constrains the
lookFor chain only when it actually resolves in that observation. The complete
scope is re-resolved on each search observation within the existing scroll budget. Missing lookFor scopes or targets remain scoped
while scrolling and report a not-found error naming the scope; another list’s
match never satisfies the search. unique rejects ambiguity at every unindexed
scope and the target, listing candidates before another gesture. A unique
zero-match target keeps searching within the budget. Random scope selection is
bound to the selected swipe container for each observation. Strategy fields
belong inside container or lookFor; a strategy without a selector and
malformed recursive selectors are rejected. Existing screen and simple selector
calls retain their defaults.
waitFor accepts nested container scopes. This nested-scoping contract is not yet
available for observe subtree queries.
pinchOn.container accepts nested containers with per-level index and
selectionStrategy (first, random, or unique; default first). Strategy
selection is supported only inside each container level; pinchOn has no
top-level selectionStrategy.
sendKeys accepts one optional field selector and an ordered sequence of up to
100 commands. Each key command accepts at most 4 raw modifier entries
(shift, ctrl, alt, meta), including duplicates. Both limits are enforced
by the schema and preflighted by the action before any key is sent, including
modifiers on semantic keys:
{
"selector": { "text": "Email" },
"commands": [
{ "action": "type", "text": "name@example.com" },
{ "action": "key", "key": "tab" },
{ "action": "type", "text": "replacement", "operation": "replace", "mode": "a11y" },
{ "action": "key", "key": "enter", "modifiers": ["shift"] }
]
}
sendKeys.container accepts the same nested chain: the outermost scope resolves
first, then each inner container and the field resolve among strict descendants
of their immediate scope, across anonymous wrappers. Each container may specify
a zero-based index within its own scoped candidate set. selectionStrategy
accepts first (default), random, or unique; unique requires exactly one
eligible field and one match at every unindexed container level, even if a level
specifies another strategy. There is no top-level field index.
Both container and selectionStrategy require a selector naming the field
to focus. The field is focused once before any command, including clear and
IME keys, on the requested display when supplied. Missing, ambiguous, or stale
targets fail without executing any commands or falling back to a global match
or an unrelated focused field. Resolver errors distinguish missing containers,
missing fields within the container, and ambiguous containers or fields, with
up to five ambiguity candidates including resource IDs, text, and bounds.
{
"selector": { "elementId": "quantity" },
"container": {
"elementId": "item_42",
"container": { "elementId": "cart_A" }
},
"selectionStrategy": "unique",
"commands": [{ "action": "type", "text": "3" }]
}
Text defaults to operation: "insert" and mode: "auto". On Android, auto
uses AutoMobile’s IME when the installed CtrlProxy APK advertises commit and
cancellation support. Older APKs fall back to eventAll for insertion and
a11y for replacement. Modes ime, imeKeyEvents, a11y, eventLast,
eventAll, and eventOnly select a delivery strategy explicitly; eventAll
sends Android key events for apps that depend on them. imeKeyEvents is an
opt-in Android experiment that sends printable ASCII as soft-keyboard events
through the active InputConnection. It preflights the whole string and
rejects unsupported characters before sending any events; it requires an APK
advertising ime_key_events_v1. No additional Android permission or manifest
entry is required. A successful result confirms event dispatch, not the target
editor’s final text; inspect the returned observation when using this mode.
iOS accepts the same values for cross-platform plans and
reports the actual xcuiTypeText mechanism as
resolvedMode. Raw keys are enter, tab,
escape, backspace, delete, and the four arrow keys; they accept shift,
ctrl, alt, and meta. Semantic keys next, previous, done, search,
send, and go perform the corresponding IME action and ignore modifiers. A
standalone { "action": "clear" } command clears the focused field. On Android
its default (auto) and ime modes clear through the CtrlProxy IME
(ime_clear_field_v1), or with key-event deletes on an older APK or when the
IME cannot be activated, so a
rich-text editor keeps its live formatting; only mode: "a11y" uses the
accessibility ACTION_SET_TEXT clear. Execution
stops on the first failure and returns compact command metadata plus the final
observation without copying type-command text into the metadata.
| Platform / mode | Unicode text, including emoji | Delivery |
|---|---|---|
| iOS, any requested mode | Supported; the requested mode resolves to xcuiTypeText |
XCUITest typeText receives the whole string. |
Android a11y |
Supported | Accessibility ACTION_SET_TEXT writes the whole string. |
Android ime, or auto when IME is available |
Supported | CtrlProxy IME delivers complete graphemes through InputConnection; auto can fall back as described above. |
Android eventAll |
Supported; whole graphemes | Single printable ASCII characters with a key-event plan use key events; all other graphemes are inserted whole through accessibility. |
Android eventLast |
Supported with split delivery | ASCII uses key events; other text uses accessibility insertion. A keycap, decomposed letter, or ASCII followed by ZWJ can split after its ASCII base. |
Android eventOnly, imeKeyEvents |
ASCII only | eventOnly preflights available key events; imeKeyEvents accepts printable ASCII only. Unsupported text fails before editing. |
Android eventAll sends single printable ASCII characters with a key-event
plan through adb shell input keyevent and inserts all other graphemes whole
through accessibility. eventLast sends ASCII with key events and sends
unsupported runs through accessibility insertion; it can split a grapheme. They
do not use adb shell input text, so shell text escaping does not transform
their Unicode runs. imeKeyEvents also rejects all non-printable-ASCII text
during preflight, before any key event is sent.
Use ime, a11y, eventAll, or auto for complete grapheme delivery.
auto uses the IME when available; its Android insertion fallback uses
eventAll. A failed eventAll insertion reports the failed grapheme code
points and committedGraphemes, the count of complete clusters delivered
before the failed run.
A successful Android eventAll command may carry a warning if caret placement fails; all remaining text in that command is then inserted whole, using a remembered caret (same node, exact text, and unchanged reported selection, at most 5 seconds). Every insert refreshes the node; a previous insert’s text is awaited for up to 300 ms, with a warning if it never matches. Gestures, text commands, focus/window changes, and changed selection reports invalidate the remembered caret.
eventLast fails with partial application if its prefix insert cannot place the caret, because its tail requires a real key event. Segment warnings are retained on success and failure.
Before insertion, preceding key-event text is awaited by exact suffix for up to 300 ms; an existing identical suffix can pass early, while IME transformations can time out with a warning.
A lengths-only warning compares the planned UTF-16 length for replace when the final delivery is an insert and the APK returns a length; it skips insert, subsequent key events, old APKs, and other modes, and cannot detect field-side filtering.
SendKeysCommandResult.textLength counts Unicode code points, rather than
graphemes or UTF-16 code units. On iOS, XCUITest typeText supplies the text
independently of the active keyboard layout; the UI regression corpus checks
non-Latin strings with the simulator’s current keyboard configuration, but does
not switch among keyboard layouts. If simulator typing does not enter text,
check the Connect Hardware Keyboard setting.
Unicode behavior by sendKeys mode¶
The following describes the current delivery path. eventAll classifies whole
graphemes; eventLast can still split them.
| Mode | Non-ASCII | Emoji (including surrogate pairs and ZWJ sequences) | Combining marks | CJK | Notes |
|---|---|---|---|---|---|
iOS xcuiTypeText (all requested modes) |
Passed as one string | Passed as one string | Passed as one string | Passed as one string | Uses XCUITest typeText through the text client. |
Android a11y |
Passed intact | Passed intact, including surrogate pairs and ZWJ | Passed intact with its base | Passed intact | Uses accessibility text insertion. |
Android ime |
Committed intact | Committed as supplied | Committed with its base | Committed intact | Uses the CtrlProxy commit IME; final editor rendering depends on the IME/editor. |
Android eventAll |
Whole graphemes inserted | Whole emoji clusters inserted through accessibility | Inserted with their base | Inserted intact | Only single printable ASCII characters with a key-event plan use key events. Other clusters, including é and 1️⃣, are inserted whole; adjacent clusters may share an insert. |
Android eventLast |
Passed intact when no ASCII key-event character occurs | Passed intact when no ASCII key-event character occurs; ASCII neighbors may cause a split | A trailing mark after ASCII e is inserted separately after the e key event |
Passed intact when no ASCII key-event character occurs | Sends the last supported ASCII key event, with preceding and following text inserted through accessibility. |
Android eventOnly |
Rejected before mutation | Rejected before mutation | Rejected before mutation | Rejected before mutation | Preflights the entire string and fails at the first character without a key-event plan. |
Android imeKeyEvents |
Rejected before mutation | Rejected before mutation | Rejected before mutation | Rejected before mutation | Accepts printable ASCII only and preflights before sending key events. |
eventAll segments text into graphemes before choosing key events or
accessibility insertion. Its insertion runs begin and end at grapheme boundaries.
eventLast still iterates code points and can insert a combining mark, variation
selector, or ZWJ separately from an ASCII base. Tests pin the dispatched chunks;
they do not claim a device editor’s rendered result.
sendKeys is the text-input tool. Its type command enters text, while key
and clear commands send keys and clear the focused field, respectively.
Pinch rotation semantics
pinchOn.rotationDegrees describes how far the two-finger axis rotates
during the pinch. The fingers start horizontally and finish on the rotated
axis, so a non-zero value combines pinch and rotation. The default 0 is
a plain pinch. Android and iOS share this convention.
Semantic accessibility links¶
For iOS tapOn.accessibilityLink and tapOn.subtext, occurrence is the
zero-based index among links with the requested text (case-insensitive) within
the owning text element. subtext uses the resolved text element as its owner;
accessibilityLink with container uses that container’s owner identity.
Without an owner, iOS selects the first element carrying a matching semantic
link in document order, then resolves occurrence within that element. This
changes the previous tree-wide counting (#6631). It never skips to a later owner
when the first lacks the occurrence or link geometry. With multiple candidate
owners, a successful SDK activation adds the runner’s note to the result’s warnings, naming the
selected owner and candidate count; use container/subtext for a specific owner.
When SDK resolution fails, the XCUITest fallback remains available. Its flat
links query has no owner grouping, so owner-less occurrence > 0 is refused
with an error asking for container/subtext. Owner-less occurrence 0 activates
the first label-matching hittable link. Owner-scoped fallback indexes matching
links within the owner’s link descendants, including the owner itself if a link.
Android is unchanged: matching links count in document order within the owner’s
subtree, or the whole active-window tree when owner-less.
Apps, files & app data¶
| Tool | What it does |
|---|---|
📱 listApps |
Lists installed apps with optional label/launchability when reported (device, type, search, profile; default type=launchable). |
🚀 launchApp |
Launches an app by package name. On iOS, optional launchArguments start a fresh process and pass argv after the bundle ID. DEBUG storage writes require a launch-scoped mutation token; the daemon supplies it when --allow-storage-mutations is present. Android rejects non-empty launch arguments; an app already in the foreground is a success flagged alreadyForeground. On iOS simulators, prototype: true relaunches the app with the prototype agent injected (state is lost); physical iOS, Android and com.apple.* apps reject it. |
❌ terminateApp |
Terminates an app by package name. |
💥 crashApp |
Intentionally crashes a running app through the platform crash path. |
♻️ appLifecycle |
State-preserving background-process kill for saved-state restoration tests (Android only). |
📦 installApp |
Installs an APK, app bundle, or IPA. |
🗑️ uninstallApp |
Uninstalls an app by package name or bundle identifier. |
🔏 inspectPackageSigning |
Android: fresh read of a package’s presence (installed, absent, unknown) for one user and its SHA-256 signing certificates, including the complete signer set and rotation history. Never cached. |
🔗 getDeepLinks |
Queries an app’s deep links. |
📄 putAppFile |
Writes local-file, UTF-8, or base64 fixtures through one target/files contract: private app_containers, bounded platform-qualified user_files, or media_library. Default-enabled for every storage target; see the canonical call shape below. |
🧾 resetAppLogs |
Resets explicitly named app-container log files and their rotated siblings on the session device, with per-path outcomes. |
📁 stageSessionDownloads |
Deprecated session-bound alias of putAppFile target.domain user_files (Android Downloads); retains session ownership checks and remains until equivalent workflows are device-verified. |
⚙️ getPreference / ⚙️ setPreference |
Reads or writes Android system properties, SharedPreferences, or iOS UserDefaults. On iOS, requires appId and selects the store with suite (not name/fileName); omitted/Standard uses the default store. Uses an already connected embedded SDK, with simulator plist fallback when permitted. |
🔑 setKeyValue / 🔑 removeKeyValue / 🔑 clearKeyValueFile |
Manages an app key-value storage file. For iOS, an empty name, “standard” (any case), or the app bundle id selects standard UserDefaults; other names select a valid suite. Names must have no leading or trailing whitespace. Android uses a SharedPreferences file name without .xml. iOS write results include resolvedStore when supported by the SDK and runner. setKeyValue returns effectiveValueDiffers: true and appends a warning when the write persisted but the app reads a different effective value due to an override; remove/clear never produce this warning. |
🗃️ listDataStores / 🗃️ getDataStore |
Lists or reads Android Jetpack DataStore entries with the SDK adapter. |
🗄️ sqlQuery |
Executes SQL against an app SQLite database. |
🔐 resetKeychain |
Resets all Keychain data on an iOS Simulator after explicit confirmation; unsupported on Android and physical iOS devices. |
State-preserving app lifecycle contract
appLifecycle is the state-preserving kill for saved-state restoration
tests. Call it with appId and action: “background”, then
action: “killBackgrounded”, and relaunch with launchApp.
A foreground app is refused by killBackgrounded: background it first.
An already backgrounded app does not receive a Home press.
processReclaimed is true only from PID evidence: the old PID must be
gone from all package-owned processes in the selected Android user. A restarted
process still counts as reclaimed; pidAfter reports the new main PID,
or null when none exists. A not-reclaimed result is not an error: Android only
kills processes it considers safe to kill. Failed process-table reads cannot
prove reclaim and return an unverified advisory message if no poll succeeds.
terminateApp is the force-stop (discards saved state);
crashApp is the hard crash. This tool supports Android only; iOS
returns supported: false for both actions because no state-preserving
termination/PID contract is verified. Use homeScreen for iOS Home.
App launch, installation, links, and storage options¶
openLink.url is the URL to open. getAppPermissions, getDeepLinks,
getNotificationPolicy, clearKeyValueFile, and removeKeyValue use appId for
the Android package name or iOS bundle ID. clearKeyValueFile.fileName and
removeKeyValue.fileName are deprecated aliases for the store selector name.
setAppPermissions.action selects grant, revoke, or reset.
resetKeychain requires appId and confirm: true. On iOS Simulator the reset
erases every app’s Keychain, regardless of appId.
launchApp.coldBoot starts the app cold instead of resuming it (default false).
installApp.artifactPath is the host path to an .apk, .app, or .ipa.
uninstallApp.keepData retains app data after uninstall on Android (default
false; Android only).
Opt-in signing guards (Android only; iOS rejects them). installApp and uninstallApp accept
expectedSigningSha256, the complete signer set (SHA-256 of every signing certificate,
64 hex characters, colons optional, see inspectPackageSigning). Before anything destructive
the tool reads the installed package fresh for the user it will mutate and refuses with a
SigningGuardError carrying reason (mismatch, absent, presence-unknown,
signing-unavailable) when the signer set differs, the lookup is inconclusive, or the signers
cannot be read; the device is unchanged in every case. Matching is exact set equality on the
current signers: one signer of a multi-signer package never satisfies a larger expected set, and
a rotated package matches its current signer, not its history. For installApp the guard applies
to a copy being replaced (checked on the target user, or on another user holding the shared
package); a package that is not installed passes with signingGuard.status
no-existing-package. For uninstallApp an absent package refuses (absent). A guarded
uninstallApp confirms removal with a fresh presence read: removalVerification is absent on
success, and installed or unknown (lookup failed) make success false. installApp
allowDestructiveRecovery: false fails an INSTALL_FAILED_VERSION_DOWNGRADE instead of
uninstalling and reinstalling (default true). AutoMobile serializes its own Android installs and
uninstalls per device, so a guarded check-then-mutate cannot interleave with another AutoMobile
mutation; package changes made outside AutoMobile (adb in a terminal, another tool) are not
visible to that lock, so hold the device exclusively when that matters.
inspectPackageSigning (Android) takes appId and an optional userId; when omitted the
user is resolved the way uninstallApp does and reported as userId and userSource. Each
call reads the device (dumpsys package, pm path, and the installed base APK’s signing
block); nothing is cached, and observation records fresh: true, observedAt, the scope,
and the device apiLevel. presence is installed, absent, or unknown: absent requires
PackageManager to report no such package or installed=false for that user, and a failed,
timed-out, malformed or cancelled lookup is unknown, never absent. signing is either
{status: "available", scheme, signerSha256, signers, history?} or
{status: "unavailable", reason}. signerSha256 is the complete sorted signer set (a
multi-signer package lists every signer; all of them form the identity). Signers are chosen as
PackageManager would for the device API level: v3.1, then v3, then v2. For a rotated package
history is the certificate lineage, oldest first with the current signer last, copied from the
installed APK without re-verifying its signatures. JAR (v1)-only APKs, ZIP64 APKs, and a v3.1
package on a device whose API level cannot be read report unavailable. The signing read pulls
the installed base APK to a host temp file and removes it afterwards. It does not use CtrlProxy,
so helper version does not affect the result.
openLink.acceptOpenAlert automatically taps Open on an iOS system
“Open in chooserAppPackage selects the exact package
when opening the URL displays an intent chooser.
sqlQuery.databasePath selects the database path; for iOS SDK databases, use the
absolute registered path reported by the App Databases resource. On Android,
sqlQuery rows and the table-data resource return integers within ±(2^53 - 1) as
JSON numbers; an integer outside that range is returned as its exact decimal
string, and the response lists the zero-based columns that hold such strings in
bigIntegerColumns so they can be told apart from TEXT.
getDataStore.adapterName and listDataStores.adapterName select the name under
which the host app registered its AutoMobile SDK DataStore adapter.
setAppPermissions.notificationsEnabled controls Android notification state
independently of POST_NOTIFICATIONS. notificationPolicyAccess sets Android
Do Not Disturb policy access, and scheduleExactAlarm sets the
SCHEDULE_EXACT_ALARM appop to allow or deny.
setNotificationPolicy.policyAccess likewise controls Android DND policy access.
Intentional crash contract
crashApp accepts only an appId; it never accepts a PID,
signal, or shell command. Android uses ActivityManager’s VM-crash path for the
resolved user. iOS Simulator sends SIGABRT to the exact launchd application
process. Physical iOS devices return supported: false and never fall
back to normal termination.
Every result reports success, supported,
platform, appId, mechanism,
timestamp, and confirmed. It reports
wasRunning whenever preflight established process state;
confirmed crashes also report processId when available and include
immediate OS diagnostic evidence. success: true and
confirmed: true require fresh, target-specific crash evidence, not
merely command dispatch or process disappearance.
putAppFile canonical call shape and platform-qualified examples
Breaking change: stageSharedStorage and stageSharedStorageFixtures have been removed.
Replace either call with default-enabled putAppFile:
{
"name": "putAppFile",
"arguments": {
"target": {
"domain": "user_files",
"namespace": "fixtures",
"reset": false,
"indexMedia": true
},
"files": [{ "destinationPath": "fixture.txt", "contentText": "fixture" }]
}
}
Move namespace, reset, and indexMedia into target; keep files and device/session
options at the top level. Each file requires destinationPath and exactly one of
sourcePath, contentText, or contentBase64. Set indexMedia: true to preserve the
removed tools’ Android indexing default; putAppFile defaults it to false.
Every target uses target plus a non-empty files array. Each file has a
normalized relative destinationPath and exactly one of sourcePath,
contentText, or contentBase64. Device selection uses the existing platform,
deviceId, or sessionUuid fields. Optional userId selects an Android profile.
putAppFile never terminates the app. After a successful app_containers write,
result.warning appears when the target app is known to be running (on Android,
in the written user profile): it may not see the change until it re-reads the file
or is relaunched. The warning is omitted when running state is unknown or the
app is not running, and for user_files and media_library writes.
Android app containers (private containers require a debuggable app):
{
"name": "putAppFile",
"arguments": {
"platform": "android",
"target": { "domain": "app_containers", "appId": "com.example.app", "container": "documents" },
"files": [{ "destinationPath": "fixtures/settings.json", "contentText": "{\"enabled\":true}" }]
}
}
iOS Simulator app containers (documents, library, cache, or tmp):
{
"name": "putAppFile",
"arguments": {
"platform": "ios",
"deviceId": "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
"target": { "domain": "app_containers", "appId": "com.example.app", "container": "documents" },
"files": [
{ "destinationPath": "fixtures/welcome.png", "sourcePath": "/Users/me/fixtures/welcome.png" }
]
}
}
iOS Simulator user_files uses the managed fixture app
dev.jasonpearson.automobile.FilesFixture, which is not yet shipped in this
repo. Install it before writing; missing-container resolution returns install
guidance without a fallback or auto-install. The provider resolves its data
container with simctl get_app_container and stages only
Documents/automobile/<namespace>/<relative destination>; reset removes only
that namespace. host_stage: completed confirms the copy, while
document_picker: unavailable remains the default unless a verifier observes
the exact destination. Physical iOS is unsupported without a future on-device
fixture-app integration. There is no iOS user_files list/read resource.
The accepted design records a picker experiment on iPhone 15 Pro Simulator, iOS 17.5, Xcode 26.3 (2026-08-28). This provider is unit-tested with fakes; the managed fixture app and production picker verification need a follow-up. See the accepted design.
Android user files (reset removes only this declared Downloads namespace;
indexMedia requests indexing and defaults to false on the unified surface):
{
"name": "putAppFile",
"arguments": {
"platform": "android",
"target": {
"domain": "user_files",
"namespace": "picker-fixtures",
"reset": true,
"indexMedia": true
},
"files": [{ "destinationPath": "photo.png", "sourcePath": "/Users/me/fixtures/photo.png" }]
}
}
Android media library (image, video, or audio filenames supported by MediaStore; indexing is required and discovery is verified by the provider):
{
"name": "putAppFile",
"arguments": {
"platform": "android",
"target": { "domain": "media_library" },
"files": [{ "destinationPath": "photo.png", "sourcePath": "/Users/me/fixtures/photo.png" }]
}
}
iOS Simulator media library (supported image/video files only; imports through
simctl addmedia, returns media_import with picker visibility unverified):
{
"name": "putAppFile",
"arguments": {
"platform": "ios",
"deviceId": "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
"target": { "domain": "media_library" },
"files": [{ "destinationPath": "photo.png", "sourcePath": "/Users/me/fixtures/photo.png" }]
}
}
Physical iOS has no production putAppFile app-container or media-library
provider. user_files writes support Android Downloads and iOS Simulator managed
fixture-app namespaces; user_files list/read resources remain Android-only.
Media libraries have no list/read resource.
Use sdk/capabilities to read the app SDK’s capability states and capture policy
(see SDK capabilities). Use storage/capabilities for
provider-derived operations and prerequisite states;
inspect structured per-file effects for indexing/import/discoverability outcomes.
These provider contracts do not establish device verification of legacy replacement.
Canonical read-only resources are:
automobile:devices/{deviceId}/storage-domains/app_containers/{appId}/{container}{?userId}(list), with/{path}before the query for read.automobile:devices/{deviceId}/storage-domains/user_files/{namespace}(list), with/{path}for read (bounded Android Downloads only).
Compatibility aliases remain readable during the transition until device
verification permits retirement: automobile:devices/{deviceId}/apps/{appId}/files/{container}[/{path}]{?userId}
and automobile:devices/{deviceId}/downloads/{namespace}[/{path}].
Returned write/list file URIs continue to use these aliases. Paths are normalized,
percent-encoded by segment, and cannot traverse out of the target.
Session enablement currently controls MCP discovery only; an unlisted tool stays
callable. putAppFile is default-enabled for app_containers, user_files, and
media_library. All domains resolve only its session override, then startup default,
then registration default. An explicit false override disables all three target
policies. Stored overrides under removed tool names are ignored without migration
or deletion. stageSessionDownloads stays default-disabled with its separate
session-bound policy and does not grant unified target enablement. No new MCP call
gate is introduced.
File containers
Android app containers accept optional userId, a non-negative safe
integer. Explicit IDs skip user discovery. When omitted, AutoMobile lists users
and checks pm list packages –user N for each one. A sole installed
user wins; for multiple installations, prefer the user of the foreground app
when it is the requested package and that user is a candidate, then the current
user if it is a candidate. Otherwise the error lists candidate IDs and
asks for userId. No installation reports the app and device; failed
user discovery asks for an explicit ID. Resolution happens once per operation,
including once for a multi-file batch. A single-user device needs one user-list
read and one package-list read, with no foreground-app or current-user probe.
Android externalFiles uses the same user resolution and maps user 0
to /sdcard/Android/data/{appId}/files, preserving the verified argv;
nonzero users map to /storage/emulated/{userId}/Android/data/{appId}/files.
It continues to use plain adb shell and adb push.
Unverified, from Android scoped-storage documentation: on Android 11+
(API 30+), shell read/list access to other apps’ Android/data
directories is not guaranteed and push may fail with permission denied. This is
an inference about shell access from the
Android storage restrictions,
not a device verification or an access bypass.
Private containers (documents, cache, and tmp)
use run-as and require a debuggable app. Nonzero users add
–user N; user 0 keeps the plain command. Missing-package and
non-debuggable errors name the nonzero user. If the output indicates an unsupported
run-as –user option, the error says it appears unsupported on this
Android version (unverified which API level). Omit userId only
if the app is installed for the primary user, or use a debuggable build through
an adb-user-0 session. Omission still follows the resolution rule above.
Shared-storage user_files and media_library retain their
existing user-resolution behavior. iOS simulator containers include
documents, library, cache, and tmp.
List/read app-file MCP resources use
automobile:devices/{deviceId}/apps/{appId}/files/{container}{?userId}
and automobile:devices/{deviceId}/apps/{appId}/files/{container}/{path}{?userId}.
Append ?userId=10 to select a work profile; omit it for the same
Android app-installation resolution as putAppFile. Invalid IDs are
rejected. Returned put and list file links pin the resolved Android user:
explicit IDs always round-trip, including ?userId=0, without extra
discovery. Auto-resolved nonzero users always retain ?userId=N;
auto-resolved user 0 retains ?userId=0 only when the app is installed
for several users. A sole user-0 installation keeps the existing query-free URI.
This rule also applies to externalFiles. iOS URIs have no user query.
Devices & system state¶
Devicectl-only simulator features such as orientation, per-display screenshots, and Duo hinge/active-panel support are Planned (#8349, #8350, #8351) and will require CoreDevice >= 651. No current tool is gated on CoreDevice version. The lazy production probe checks boot state before devicectl and memoizes captured unsupported feature IDs; doctor and automobile:host/toolchain retain the bounded devicectl --version read and seed the injected probe, alongside simulator boot counts and observed capabilities. Capability state stays “not probed” until a real command runs; the downgrade guard is out of scope per the owner decision on 2026-10-02 and is not reported. The CoreDevice doctor result includes the summary in its optional detail string, which the console prints on an indented line below the version for every check status. JSON retains the existing fields and adds detail. See the iOS simctl and devicectl boundary design.
| Tool | What it does |
|---|---|
📋 listDevices |
Lists booted devices using the shared device description: identity, runtime, form factor, lifecycle, and session summary; a note points to MCP resources for image detail. |
🖼️ listDeviceImages |
Lists configured images using the same canonical identity, runtime, display, lifecycle, provenance, and capability inventory shape as the images resource. |
🤖 getAndroid / 🍎 getApple |
Finds or recovers an Android AVD or iOS Simulator for automation; Android identity includes API level and OS version when known. |
🧱 provisionDevice |
Provisions an exact virtual-device identity, with optional resource configuration before automation readiness. |
⚙️ setDeviceResources |
Configures selected device resources and returns verified, unsupported, or unknown state; omitted settings stay unchanged. Omitted from discovery by default: select it with setToolEnabled (case-sensitive setDeviceResources) or --enable-tool setDeviceResources; direct calls by name remain available. |
🧭 reconcileDeviceResources |
Compares an iOS Simulator with a requested resource map (workload profile) and reports typed drift: missingRequested, ownedExtra, unsupported, commandFailure. Report-only by default; repair: true applies only the drifted delta, re-reads, and fails closed. Omitted from discovery by default: select it with setToolEnabled (case-sensitive reconcileDeviceResources) or --enable-tool reconcileDeviceResources; direct calls by name remain available. |
🔧 setActiveDevice |
Sets the active device. Optional session display pins a panel key/role; null clears, omission preserves. Explicit display beats pin, then focus/posture. Pins clear on release/rebind; direct mode unsupported. |
❌ killDevice / 🧹 deleteDevice |
Stops a device, or stops and permanently deletes it. Both accept force: true, which drops every AVD-name comparison for a wedged Android emulator — the emulator-console confirmation and the platform kill’s own re-discovery check — and acts on whatever occupies the serial; it does not bypass serial selection, nor the refusals raised when the pooled entry was retired and replaced mid-action, or when no booted target can be identified at all. Both also refuse a device another session holds (device_owned_by_other_session) unless the caller is the holder; force: true overrides that refusal on any platform. See Device ownership. |
📸 deviceSnapshot |
Captures or restores a device snapshot. Android VM restores in daemon mode return the new deviceSessionUuid and supersede the previous device-session epoch. |
🔄 rotate |
Changes device orientation. |
📖 setPosture |
Sets a supported posture or a BEST EFFORT hingeAngle (0-180 degrees, emulator/simulator only). Exactly one selector is required; angle requests cannot use displayPreset. Unsupported angles return status: “unsupported” with a reason and no state change. |
🌐 openLink |
Opens web URLs or routes app and universal deep links. |
🧰 homeScreen / recentApps / systemTray |
Controls core system surfaces and notifications. |
🔓 wakeAndUnlock |
Wakes and unlocks the keyguard. |
🌍 changeLocalization |
Changes locale, time zone, text direction, time format, and calendar. |
⚙️ getDeviceState / ⚙️ setDeviceState |
Reads or changes Do Not Disturb, simulator biometric enrollment, network condition, and static geolocation. Set a point with location: { mode: "static", latitude: 37.7749, longitude: -122.4194 } on an Android emulator or iOS Simulator. Latitude must be within −90..90 and longitude within −180..180. The result echoes the applied coordinate; location read-back is unavailable, and physical devices return an unsupported error. On release or rebind of a session that set a location, iOS Simulator clears it with simctl location clear; failed clears are retried and quarantine the device until success or removal. Android emulator fixes persist after the session because the emulator console has no unset command; reset the fix explicitly if needed. Direct-mode (sessionless) calls are unchanged. A sessionless fix written while a session marker remains on that device is cleared by that session’s release; markers are per device and session, not per write. getDeviceState also reads back the Android connectivity toggles — airplaneMode, wifiEnabled, bluetoothEnabled, locationEnabled — in a single adb round-trip, so a toggle can be checked before it is flipped; a bare call returns doNotDisturb + connectivity, and include selects any subset. A connectivity field is true/false, or omitted when the device could not answer it (key absent on this API level, or an unparsable value) — omitted never means off. Connectivity is unsupported on iOS: Airplane mode, Wi-Fi, Bluetooth and Location have no simctl/devicectl read verb, and a simulator shares the host’s network stack. Degraded profiles — including offline — are best-effort cellular shaping on an Android emulator (adb emu network …/gsm data off plus a best-effort Wi-Fi disable), reported partial: they may not affect Wi-Fi or app traffic. Only reset to none is fully verified. A session restores the network to a clean none state on release. Network shaping is unsupported on physical Android and all iOS. |
🔠 displayConfig |
Reads or sets font/text scale, effective display density, and light/dark theme for adaptive-layout and large-font accessibility testing. Android supports all three fields (density overrides are best-effort on physical devices); the iOS Simulator supports theme only, via simctl ui appearance; physical iOS is unsupported. Android reset restores font scale and density to device defaults and restores night mode only to the value displayConfig replaced earlier in this process; otherwise night mode is left unchanged. Android reset never forces light. Omitted from discovery by default — select it with setToolEnabled (case-sensitive displayConfig) or --enable-tool displayConfig; direct calls by name remain available. |
🧬 getIosSimulatorCapabilities |
Discovers biometrics for a selected iOS Simulator device type and runtime. |
biometricAuth |
Simulates biometric authentication. |
📳 shake |
Shakes an Android emulator or iOS Simulator; duration must be an integer from 1 to 1,798,000 ms, and Android intensity must be from 1 to 1,000. |
📞 phoneCall / 💬 sendSms |
Simulates an incoming call or SMS: Android emulator console, or CallKit and a notification through the app’s AutoMobile iOS SDK. |
🔔 postNotification |
Posts a notification through Android SDK hooks or iOS Simulator push. |
🔔 getNotificationPolicy / 🔔 setNotificationPolicy |
Reads or changes app notification and Do Not Disturb policy. |
🛂 getAppPermissions / 🛂 setAppPermissions |
Reads or changes app permissions. |
Clock control uses setDeviceState with clock: { mode: "set", instant: "2026-10-01T00:00:00Z" }, clock: { mode: "advance", byMs: 60000 }, or clock: { mode: "reset" }. Set requires ISO-8601 with Z or an explicit offset within 2000-01-01T00:00:00Z .. 2100-01-01T00:00:00Z, inclusive (MIN_DEVICE_CLOCK_INSTANT_MS / MAX_DEVICE_CLOCK_INSTANT_MS). Advance requires an integer byMs >= 1000, at most MAX_DEVICE_CLOCK_ADVANCE_MS (315360000000, ten 365-day years). An early device read rejects cumulative targets outside that window before any clock or other field mutation. Immediately before the clock write, after other requested fields, root acquisition and disabling automatic time, advance reads the device again and range-checks the fresh target. A target that leaves the window during those operations returns a typed clock-field failure; other fields retain their results. Read-only root-capability probes and date +%s may run during validation. All inputs are validated before applying any field. Only rootable Android emulators support writes; Play Store images, physical Android devices, and all iOS targets return unsupported. Changing the clock affects TLS/certificate validation, token expiry, and the daemon’s own freshness checks for that device.
Set/advance disable automatic time. Results report instant, automaticTime, requestedInstant, appliedInstant, readBack, verified, and toleranceMs (2000). Commands have second-level precision, truncating milliseconds. Advance verifies at least the requested whole seconds minus tolerance, with a minimum one-second movement; a no-op is never verified. Set within tolerance of current device time reports distinct outcome: "unchanged", without writing clock state. Every clock change (set/advance/reset and lifecycle restore, including potentially partially applied failures) invalidates hierarchy/observe caches and freshness baselines so backward jumps cannot reuse stale hierarchies.
At the first mutation, a session records original auto_time, ownership of the clock change, and whether AutoMobile rooted adbd. On session release/rebind/device teardown/reset, AutoMobile explicitly steps the clock to HOST-derived real time (injected host Timer), restores original auto_time, and verifies both by read-back, including when original auto_time=1 because emulators may never automatically re-sync. A failed restore retains pending ownership and retries; the device stays quarantined until restoration succeeds or the device is removed. Each command and the initial teardown wait are bounded; retries use the existing network restore delay (250ms) and pending-device-cleanup pool quarantine, continuing past its bounded retry batch. Reset clears the slot only after verification.
Clock control can restart adbd on the emulator; connections such as port forwards may be re-established. It probes shell id before rooting and unroots on restore only if AutoMobile rooted it, using bounded unroot / wait-for-device commands. Unroot failure is logged and best-effort, and does not fail verified clock/auto-time restoration. The existing legacy locale root path likewise waits for ADB and verifies root, with no explicit port-forward/CtrlProxy reconnect step.
The restore slot is in memory only: daemon restart loses it, potentially leaving a wrong clock and auto_time=0. reset is the recovery: with no recorded slot on a rootable emulator, it writes HOST time and auto_time=1. Reset checks target support and recorded ownership before root capability. Physical Android and iOS get no root/clock commands; without a slot, a root-refusing Play Store image reports unsupported/”nothing to reset” with no clock mutations. With a slot, refused root reports a typed failure and keeps restoration pending. Session-bound and sessionless clock mutations serialize through one device queue and inherit one original auto_time and root-ownership baseline, in either calling order. Session release still restores host time and original auto_time; a sessionless claim retains the original baseline until explicit reset or device removal. Removal aborts queued and in-flight clock commands for that device incarnation. Direct/sessionless mode retains original auto_time and root ownership per device until explicit reset or device removal; it has no automatic lifecycle restore, so callers must reset explicitly. Operational clock failures are typed per-field failures (verified:false) and do not lose other fields’ results or prevent a successfully applied network condition’s TTL from being armed.
getDeviceState with include: ["clock"] reads current Android instant and automatic time without root, including physical devices and non-rootable images; iOS reports unsupported. The default read selection remains Do Not Disturb and connectivity.
Camera posters use setDeviceState with cameraPoster: { mode: "image", path: "/abs/poster.png" }, cameraPoster: { mode: "qr", text: "payload" }, or cameraPoster: { mode: "clear" }, plus an optional surface of wall (default) or table. It runs adb -s <serial> emu virtualscene-image <surface> [path] on a running Android emulator, with no gRPC client and no cold restart; QR payloads reuse the startDevice cameraPosterQr poster writer. The path must be a PNG/JPG/JPEG that exists and contains no whitespace (the console splits on spaces). The AVD back camera must be virtualscene: boot it with startDevice cameraPosterPath or cameraPosterQr, otherwise the console refuses and the error is returned with that hint. iOS and physical Android return unsupported. The console answering OK does not confirm the camera shows the poster: the virtual scene’s default camera pose may not face it, and nothing reads the camera frame back, so the result carries a warning instead of verified. The poster persists until cleared or the AVD is cold-booted.
Location route playback uses setDeviceState with location: { mode: "route", waypoints: [{ latitude: 0, longitude: 0 }, { latitude: 0, longitude: 1 }], durationMs: 10000 }. Provide exactly one of positive, finite durationMs or speedMetersPerSecond. A route needs at least two ordered waypoints; latitude is −90..90, longitude is −180..180, and optional altitude is finite. loop defaults to false; updateIntervalMs defaults to 1000 and accepts integers from 200 to 60000. Starting a route returns immediately with its waypoint count, total distance, expected duration, loop, interval, and method. iOS Simulator interpolates on the host and sends repeated simctl location set commands. Three consecutive fix failures stop playback and are logged; the next location write reports the failed route in previousRoute with endedReason: "failed" and lastError.
One route runs per device. A static fix, new route, location: { mode: "stop" }, session release, unbind, or device removal cancels it. On release or rebind of a session that set a location, iOS Simulator clears it with simctl location clear; failed clears are retried and quarantine the device until success or removal. Android emulator fixes persist after the session because the emulator console has no unset command; reset the fix explicitly if needed. Direct-mode (sessionless) calls are unchanged. A sessionless fix written while a session marker remains on that device is cleared by that session’s release; markers are per device and session, not per write. Stop reports stopped: false when idle and previousRoute when a route ended, including its endedReason (completed, failed, replaced, or stopped) and any lastError.
During canonical-shape phase 1, every device surface returns the complete shared description:
static facts are top-level, changing state is under runtime, and the previous nested and flat
aliases remain present as compatibility fields.
setPosture with a named posture checks Android posture support before sending any fold, unfold,
posture, or device-state override/reset command. It uses hydrated supported
postures, falling back to cmd device_state print-states when that inventory is
absent or unreadable. Unsupported requests fail with an actionable error listing
the supported postures/states and confirming that nothing was changed. Empty
state lists and DEFAULT-only states do not establish fold support. If the
device-state service is unavailable, hydrated fold support and an observed
matching posture are required.
After named-posture dispatch, Android must reach the requested committed state or, for postures
without a committed-state mapping, report the requested posture in a fresh
observation. If the posture remains old or unknown after the polling timeout,
the error explains that the command was sent but the posture did not change.
Physical foldables may reset to opened without an OPENED state only when fold
support is confirmed; they must still confirm the resulting posture.
The initial Android final observation also requests a fresh hierarchy, including
when the committed device state changes without a display transition.
On Android and iOS, setPosture checks the final observation’s display revision
and retries once with a fresh capture if it is stale or has no revision stamp.
If the retry is also stale, the result preserves that observation’s own
display.generation and includes warnings: [“The posture changed, but the returned
observation predates it. Re-observe before acting.”]. Coordinate actions remain
subject to the display-transition fence. On iOS, the settle notification still
follows the final observation; freshness is checked before that notification.
Only a fresh final observation can remember the requested iOS posture for later
display.posture reads, including half_opened on the inner panel.
On Android, folding a device such as a Pixel Fold can raise the “swipe up to
continue” keyguard even though no lock credential is set. When the device was
unlocked before the posture change and the keyguard now showing is definitely not
secure, setPosture dismisses it and returns keyguardDismissed: true. A secure
or unreadable lock state is left alone (call wakeAndUnlock), and if dismissing
fails the result carries a warnings entry saying so.
Both rotate and setPosture declare output schemas and return the same JSON
payload in text content and structuredContent. Ordinary clients receive
structuredContent unless --tool-results-no-structured-content is enabled.
setPosture accepts exactly one of posture or hingeAngle. hingeAngle is a
finite number of degrees from 0 through 180 inclusive and is BEST EFFORT,
for Android emulators and multi-panel iPhone Duo simulators only. displayPreset
requires a named posture and cannot be combined with an angle.
Android tries adb emu sensor set hinge-angle0 <deg> once without retries or a
fold/unfold/posture fallback. Support is detected from the command’s own output at
call time using the existing console failure check. A rejected console command
returns
{ "status": "unsupported", "message": "..." }, including the raw first
non-empty output line as the reason; no state changed. Physical Android angles
are likewise unsupported because they require the emulator console. An unreachable
ADB or timeout while setting, or an abort at any stage, throws an actionable
operational error. After acceptance, adb emu sensor get hinge-angle0 reads the
angle back, strictly parsing one non-empty line of hinge-angle0 = <n>. A mismatch
of more than 1 degree warns with the requested and actual angles; an unreadable
or failed read-back warns that the angle could not be verified. These warnings
remain best effort and do not fail the request.
After an accepted Android angle, committed cmd device_state state is mapped
through cmd device_state print-states, polling for up to 3000 ms. A known mapped
posture is cross-checked against the fresh final observation: two known postures
that disagree fail with an actionable error naming both and the console’s OK
reply. Unavailable service, unreadable/unmapped committed state, or stale final
observation produces posture: "unknown" and a human-readable postureReason.
A known committed posture can be reported when the final observation’s posture
is unknown. No angle-to-posture thresholds are inferred.
Arbitrary iOS angles require a connected runner advertising set_hinge_angle,
shipped in release 0.0.82. The stale-runner gate requires this command only on
simulators. A runner without that advertisement (including a missing handshake)
returns unsupported and must be updated to a runner that advertises it; no hinge
request is sent and no state changed. Physical iOS and non-foldable simulators
retain their existing unsupported results. Named iOS postures retain the existing
0/130/180 mapping and are not gated by this new capability check. An angle’s resulting posture
comes from the observed active panel: cover reports closed, inner reports its
observed posture (or opened if the inner panel is known but posture is unknown),
and an indeterminate panel reports unknown with a reason. The runner’s reported
angle is compared with the request: a difference of more than 1 degree warns
with both angles, and a missing angle warns that it is not verifiable. Neither
warning fails the request.
Named-posture results retain message, the requested posture, and display
(key, role, posture, generation), with optional locked and warnings. Angle
results also echo the requested hingeAngle; posture is the device read-back
or "unknown" with postureReason. Android also reports observedHingeAngle
when the console read-back is valid; iOS reports it when the runner returns an
angle. Mismatched or unverified angle messages describe the angle as requested.
Both request kinds share the same device lock, supersession checks, cache invalidation, and
final observation/generation fence. Unsupported angles return status:
"unsupported" with a reason and no state change; operational failures throw
actionable errors.
rotate returns success, orientation, value, and message. Optional fields
include currentOrientation, previousOrientation, rotationPerformed,
orientationLockHandled, orientationLockState, warning, warnings, error,
staleDisplay, effect, and a finalized observation/observationDiff.
Successful no-ops report rotationPerformed: false. Returned failures retain
the structured result with success: false and set the MCP isError flag.
Localization, display, and event options¶
changeLocalization.appId selects the Android app package for locale changes.
On Android, displayConfig.reset restores font scale and density to the device
baseline (settings delete system font_scale, wm density reset). Font reset
accepts an absent override or the effective AOSP default scale of 1.0. Night mode
is restored only to the value displayConfig itself replaced on that device
before its first theme write in this process; later theme writes preserve that
original value. Without a recorded value, night mode is left unchanged. Android
reset never forces light mode. The in-memory record survives feature instances,
is cleared after a confirmed restore, and is lost when the process restarts.
Failed restores keep the record for retry. The iOS Simulator reset continues to
restore light appearance. Failed displayConfig results set MCP isError: true.
shake.duration is an integer from 1 to 1,798,000 ms (default 1000); the maximum leaves 2 seconds for action-timeout overhead under the 30-minute MCP request limit. Invalid values are rejected before shaking. shake.intensity is an Android acceleration value from 1 to 1,000 (default 100); iOS ignores it. The maximum is a conservative bound because the repository does not define an emulator sensor limit. Android shake restores the acceleration vector read before the shake; when read-back fails, it uses the issue-reported emulator resting vector 0:9.77622:0 and includes restoreWarning in the result.
biometricAuth.errorCode supplies the BiometricPrompt error code for action: "error".
On iOS, match, fail, cancel and error first arm an AutoMobileBiometrics
override through the app’s AutoMobile iOS SDK (DEBUG build, app in the foreground),
which the app reads with consumeOverride(); ttlMs and errorCode apply as on
Android. On the Simulator, match and fail also post the BiometricKit event so a
pending system prompt completes. Without the SDK, the Simulator falls back to
BiometricKit events (match and fail only) and a physical device is unsupported.
On iOS, cancel and error only arm the SDK override; the app must read it via
consumeOverride(), as no system prompt is completed for them.
enroll and unenroll always use the Simulator.
postNotification takes title, body, and appId (target Android package or iOS
bundle ID; required on iOS, while Android defaults to the foreground app if omitted). actions supplies
Android buttons, each with label and actionId.
sendSms.message is the SMS body (at most 1024 characters, without newlines or NUL).
wakeAndUnlock.pin supplies a secure Android unlock credential; it may be omitted
if one is already remembered for the session and is ignored on iOS.
changeLocalization.timeZone accepts a zone ID such as America/Los_Angeles. A malformed
ID, a wrong-case spelling of a known zone (the device looks IDs up case-sensitively), or a
bare UTC offset such as +05:00 is refused before anything is written. Android also accepts
Java custom IDs such as GMT+5 or GMT-08:00. An ID shaped like Area/Location that the
host does not know is still sent, with a note that the host could not validate it, and the
device read-back decides whether it took effect. A successful change reports
timeZoneWarning: the stored value read back, which does not confirm that running apps
observe the new zone.
timeFormat selects "12" or "24", and textDirection selects ltr or rtl.
calendarSystem accepts calendar identifiers such as gregory, japanese,
buddhist, or islamic-civil. restartApp is the iOS bundle ID to relaunch
after a locale change.
displayConfig.fontScale changes Android system text scale; "default" removes
the explicit override and restores the inherited default. Omission leaves it
unchanged. shake.intensity sets Android shake intensity (default 100).
phoneCall.phoneNumber is required except for the hold action.
sendSms.phoneNumber specifies the sender’s number.
On iOS both tools need the app under test to embed the AutoMobile iOS SDK in a
DEBUG build and be in the foreground: phoneCall reports the call through
CallKit, and sendSms posts an SMS-style local notification. Without the SDK
they return an error that says so.
postNotification.channelId supplies the Android channel ID or iOS APNs category.
imageType selects normal (default) or bigPicture; imagePath is the host
image path for bigPicture. The host reads only the file’s first bytes and
rejects files over 16 MiB. It accepts PNG, JPEG, WebP, GIF and BMP everywhere,
and HEIF/HEIC (decoded on Android 8.0+) and AVIF (Android 14+) with a warning,
because the app falls back to showing the notification without the image on a
device that cannot decode them.
postNotification SDK compatibility¶
The app’s SDK receiver reports 0 (failed), 1 (posted) or 2 (posted, but the
requested big picture could not be loaded, so it was shown without it). The host
maps 2 to success: true with a warning and fails closed on any code it does
not know, naming the code. A host older than the app’s SDK predates code 2, so
an image-less bigPicture post that was actually posted is reported as
success: false and a retry would post a duplicate. Update the host (AutoMobile)
before, or together with, the SDK in the app.
biometricAuth.modality selects any (default), fingerprint, or face.
fingerprintId defaults to 1 for match/error and 2 for fail/cancel.
ttlMs sets the SDK override lifetime in milliseconds (default 5000).
Acquisition, deletion, and snapshot options¶
deleteDevice.mode is "destroy": stop and permanently delete the platform device
representation. timeoutMs is the total positive integer teardown timeout in
milliseconds, at most 890000.
getIosSimulatorCapabilities.deviceType selects a CoreSimulator device-type
identifier from automobile:devices/images.
getAndroid and getApple accept bootTimeoutMs for finding, recovering, or
booting the OS and automationReadyTimeoutMs for installing, updating, starting,
and verifying the automation runner. Each defaults to 180000 ms; their sum,
including defaults for omitted fields, must not exceed 890000 ms.
setDeviceResources and provisionDevice.resources results include requested
and an independent observed full-platform resource snapshot after configuration,
including unrequested groups. No read path yields unsupported with a reason;
failed reads yield unknown. Explicit opposite enabled/disabled states set
success: false and name the resources in observationContradictions, using the
existing MCP error response (Android provisioning retains the device/session). Unknown or
unsupported observations do not add success: false to the result; for provisionDevice on an iOS
Simulator they still fail provisioning with resource_profile_unproven and resourceDrift (no
session is bound). Existing mutation fields retain
their shape and meaning. Observation uses at most half the remaining resource deadline and shares the abort
signal; exhausted reads report unknown, and a repeated provisionDevice call refreshes it.
Identical package and launchctl reads are reused only within one observation.
Cancellation after mutation carries the completed result on the propagated error
as deviceResourceResult (including any restore receipt). Non-abort observation errors are
logged and omit observed while retaining the mutation result.
reconcileDeviceResources targets a booted iOS Simulator and returns its incarnation
identity (UDID, runtime, device type), the profileFingerprint of the requested map,
drift found before any repair, remainingDrift, the final independent observed
snapshot, and, after a repair, the applied delta result. success is true only when
every requested resource is observed in its requested state and no owned extra remains.
releaseOwnedExtras: true (with repair) re-enables services AutoMobile disabled
earlier that the profile omits; services AutoMobile never changed are never touched.
The iOS catalog (automobile:devices/images/ios, provisioningCatalog.deviceTypes[]) adds
runtimeCompatibility per model: knowledge: "known" with inclusive normalized
minRuntimeVersion/maxRuntimeVersion (null is an unbounded maximum) and
compatibleRuntimeIds (installed, available runtimes inside the bounds; empty is an authoritative
“none”), or knowledge: "unknown" with a reason when CoreSimulator evidence is missing or
malformed (it says nothing about which runtimes work). Exact iOS provisionDevice checks the
requested pair against the same evidence before any creation side effect and fails a proven
mismatch (or an unavailable runtime) with non-retryable runtime_incompatible; its
runtimeCompatibility diagnostic carries the requested pair, known bounds, and installed
compatibleRuntimes. Unknown evidence or failed discovery does not block creation. The requested
runtime or model is never substituted.
provisionDevice takes no idempotency key and keeps no per-request record: every call runs its own
lifecycle. A concurrent call for the same exact device waits on that device’s lifecycle lease and then
adopts the device (or is refused by session ownership), so retrying never creates a second device.
provisionDevice error responses for daemon handoff (daemon_handoff_interrupted), caller
cancellation (request_cancelled), readiness failures, and cleanup failures (boundary
readiness_failure / cleanup_failure) carry a top-level recovery snapshot (schemaVersion: 2) next
to the existing error and lifecycle fields. boundary names where the call failed; phaseReached is
the last lifecycle phase the request recorded; device is the exact identity (stableId,
runtimeDeviceId) with ownership (created_by_request, adopted, or unknown when the request never
observed which); a display name alone never authorizes destructive recovery. outcomes reports
independent facts: deviceCreation (created, adopted, not_created, unknown) and settlement
(cancellation: settling or settled within the bounded 5 s wait). cleanup.status is unnecessary,
pending, failed_device_retained, reported_complete_unverified (a successful destroy is not verified
absence), or unknown. originalError preserves the provisioning cause. Select the recovery from
nextAction.action: retry (a fresh call; honor retryAfterMs when the cancelled work is still
settling or cleanup is pending, and automaticRetrySafe: false while cleanup is pending),
reacquire_retained_device (the created device still exists; retrying with the same device adopts it
without creating another), perform_cleanup (cleanup failed and the device is retained; use the exact
identity), or obtain_further_evidence (inventory first, also used when a readiness or cleanup failure is
not marked retryable, and when an iOS simulator create was still unsettled at rollback so the outcome is
retained with no device identity and deviceCreation: unknown; automaticRetrySafe: false). Missing
evidence stays unknown; the snapshot is stamped with freshness.observedAtMs and daemon build, and
a response that is lost in transit leaves the caller without it. Gathering it reads only in-memory
state and does not extend the request deadline.
deleteDevice has no caller-supplied operation ID and stores no replayable
result: each call runs its own teardown under the device lifecycle lease, and
a repeated call on an already-removed device reports already_absent. verifyAbsence requires a complete inventory observation proving
durable absence. cancellationPolicy: "cancel-on-request-abort" cancels accepted teardown when
the MCP request is aborted; use it for deadline-critical, caller-owned cleanup
that must stop when its caller stops waiting.
startDevice.screenSize requires positive, finite width and height in pixels.
Matching allows a 10% difference in each dimension.
deviceSnapshot.vmSnapshotTimeoutMs accepts a positive integer up to 1800000 ms
(30 minutes) for capture and restore. When omitted, the configured timeout applies
(default 30000 ms).
deviceSnapshot.useVmSnapshot uses an emulator VM snapshot;
vmSnapshotTimeoutMs sets the VM snapshot timeout in milliseconds.
strictBackupMode is iOS-only and fails the whole snapshot unless every
requested bundle is backed up (all-or-nothing).
Keeping an Android orientation locked¶
With an Android device session (sessionUuid and an available session manager),
rotate holds the requested orientation by default: auto-rotate stays off until
lockOrientation: false, session release, or rebind away from the device. The
session records the original accelerometer_rotation and user_rotation before
its first settings write and restores both on release or rebind. An already
locked no-op records nothing. If the initial auto-rotate setting is unreadable,
omission leaves it unchanged rather than forcing a lock.
In direct mode (no session or session manager), omission temporarily disables
auto-rotate when necessary and restores it at the end of the call. To keep the
orientation locked past a direct call, pass lockOrientation: true:
{ "orientation": "landscape", "lockOrientation": true }
true differs from the default only in direct mode, where it is the sole way to
hold the lock past the call. In session mode it explicitly requests the same
lock, also recording the restore slot so release restores the original settings;
unlike omission, an explicit lock can force an unreadable initial setting off.
The existing orientationLockState reports locked, unlocked, or unknown.
A confirmed final orientation mismatch returns success: false with the achieved
currentOrientation. rotationPerformed is false when the display ended in its
previous orientation. Confirmed failures roll user_rotation back to its value
before the call; cancellation and unknown final orientation do not. An unreadable
final orientation retains success with a warning when no mismatch was confirmed.
A persistent lock requires confirmation of the lock and live orientation.
To restore automatic rotation, pass lockOrientation: false, for example
{ "orientation": "landscape", "lockOrientation": false }. This rotates to the
requested orientation and enables auto-rotate, deliberately overriding an
originally locked accelerometer_rotation=0. In session mode, once auto-rotate
is confirmed on, it additionally restores the original user_rotation and clears
the slot. A failed user-setting restore adds a warning and retains the slot for
release to retry. These options are supported only on Android.
Acquiring a device: avdName, udid, and the deviceId alias¶
startDevice, getAndroid, and listDevices accept requires: { panels?: number; posture?:
Posture }. panels is a minimum; posture must appear in the device’s supported
postures. A foldable form factor alone does not imply two panels: some foldable
AVDs only change posture on one panel. Booted devices use their display inventory.
Booted device entries in listDevices and the booted-devices resource optionally carry
unhealthy: { reason, since }. Reasons are biometric-enrollment, network-condition, clock,
keep-screen-awake (a session’s keep-awake setting could not be restored), or
app-cleanup (an executePlan app cleanup did not complete); since is the daemon’s timestamp in milliseconds. Unresolved restore failures
exclude devices from available/idle counts and new session allocation. Biometric,
network, keep-awake, and app-cleanup failures get three background recovery opportunities with 1s/2s/4s backoff;
a live owner is never restored by this recovery. Clock failures retain the existing
busy quarantine and retry until success or removal. No automatic erase/reboot occurs;
use killDevice/startDevice for replacement if recovery is exhausted (an app-cleanup marker that
exhausts its three attempts is held until then, and the allocation error spells out the killDevice call). Health markers
are in memory only: a daemon restart loses them and does not re-detect dirty state.
listDevices filters its booted results. For an unbooted AVD without known display profile metadata, panel and posture
support is unknown until booted, so capability matching will not select it.
When no device qualifies, acquisition reports the requested capabilities and
the candidates’ known support.
getAndroid and getApple each accept two ways to name a target; pass one.
getAndroid—avdNamenames a configured Android Virtual Device (thenamefield ofautomobile:devices/images/android). It is the identity AutoMobile uses to boot and coordinate a named AVD: theavdNamepath passesmatchExactName,androidAvdName, and astableTargetfor exact AVD-identity and lifecycle coordination.deviceIdis the copy-paste-from-discovery convenience: it accepts either an already-booted serial such asemulator-5554(theruntime.deviceIdfield ofautomobile:devices/booted/android) or an AVD image name — if it names a defined-but-unbooted AVD,getAndroidcold-boots that image by name. The difference is the coordination hints theavdNamepath passes up front —matchExactName, anandroidAvdNamestartup-lease hint, and an eagerstableTarget— so preferavdNamewhen you specifically want to boot or coordinate a named AVD; usedeviceIdto attach to a running device or to boot straight from a discovered identifier.getApple—udidis the iOS Simulator UDID.deviceIdis an accepted alias forudid: a booted simulator’sruntime.deviceId(fromautomobile:devices/booted/ios) is itsudid, so both fields resolve to the same value.
The deviceId fields exist so the value at runtime.deviceId in listDevices and the
automobile:devices/booted/* resources can be copied straight into
getAndroid/getApple — the discovery→acquire path (#5870). See the
FAQ for the CLI equivalents.
Read-only Android inventory shares successful name/display enrichment and AVD listings for
2.5 seconds, keyed by the device-list observation to avoid reusing a replaced emulator’s
identity. The booted resource has an 8-second compute budget; the images resource has a
9-second response budget; listDevices configured-image fallback has a 2-second budget.
Known devices/images remain available on expiry with additive retryable: true and
retryAfterMs: 1000 hints: booted enrichment names pending work, tool enrichment.missing
names configuredImages, and image observation errors name catalog and, when unfinished,
configuredInventory. Retry hints suggest a delay, not a completion deadline. One background
catalog fetch continues for up to 30 seconds and retains its completed stage for 2.5 seconds,
so a short retry normally gets the full inventory. The device-free five-emulator mixed-load
fixture bounds the three sequential reads at 25 seconds and each response below its
15-second client deadline. See device resources for the
constants, cancellation behavior, and completeness shapes.
Persistent Android provenance failure returns enrichment: { complete: false,
missing: ["provenance"], retryable: false, reason }; the reason names the cmdline-tools
failure once and clients should address it instead of polling. Pending provenance is
retryable; success omits the enrichment field. Catalog lifecycle invalidation returns
code: "superseded" with retry hints, hard-cap expiry returns code: "timeout", and a
genuine failure retains code: "failed" with its cause. ADB inventory wait expiry reports
incomplete discovery with code: "timeout", retryable: true and retryAfterMs: 1000
while the shared 10,000 ms read continues; coalesced AVD reads use a 30,000 ms shared cap.
Network, plans & recording¶
| Tool | What it does |
|---|---|
🌐 network |
Controls network capture and error simulation. |
🎭 mockNetwork / 🧹 clearMockNetwork |
Adds or clears mock network response rules. |
🕸️ getNetworkGraph |
Returns the aggregate captured network graph. |
🧪 executePlan |
Executes YAML plan steps and stops at the first failure. |
🔒 criticalSection |
Synchronizes devices, then runs steps serially. |
🚧 barrier |
Synchronizes devices, then lets them proceed concurrently. |
📝 recordToolCalls |
Records MCP tool calls to YAML; begin and end require –tool-call-recording. |
⏺️ recordDeviceGestures |
Records on-device user input (not tool calls) for exportPlan. |
📤 exportPlan |
Stops the active recording and exports a YAML plan. |
🎥 videoRecording |
Starts or stops device video recording. |
Network options¶
getNetworkGraph.method filters by HTTP method.
mockNetwork uses regex host and path patterns and an HTTP method (default
"*"). A positive integer limit caps mock responses. Configure statusCode
(default 200, range 100–599), responseHeaders (string values), responseBody
(the mock body), and contentType (default application/json).
network.simulateErrors configures error simulation with errorType (http500
by default, or timeout, connectionRefused, dnsFailure, tlsFailure),
optional positive limit, and positive durationSeconds. Set cancel: true
to cancel active simulation; otherwise durationSeconds is required.
notifFilter selects all, errors, or slow notifications.
notifDebounceMs sets the notification batching delay in milliseconds (zero is
allowed); slowThresholdMs is the positive duration threshold in milliseconds
at or above which a request counts as slow.
clearMockNetwork.mockId selects one mock to clear; omit it to clear all.
Mock rules and error simulation are kept per device. When a session is released
or leaves a device, only the rules and simulation that session installed are
removed, and the rest of the device’s set is pushed to it. Rules and a
simulation installed without a session (direct mode), or by another session, are
never removed by a session release, even on the same device; sessionless state
has no automatic lifetime and stays until clearMockNetwork, a cancelled or
expired simulation, or removal of the device. A replaced simulation belongs to
the session that installed the replacement.
getNetworkGraph.sinceSeconds sets the lookback in seconds, and minRequests
sets the minimum request count.
Plan and recording options¶
barrier.lock names the shared barrier that devices synchronize on.
criticalSection.lock names its shared barrier lock.
executePlan.planContent contains YAML plan content (also accepts a base64:
prefix). startStep is the start step index (default 0).
Nested executePlan calls run on the enclosing plan’s session/device. Remove
devices/device labels from the nested call and device declarations from its
YAML; otherwise execution fails before label allocation with: “Nested executePlan
cannot use devices/device labels. Remove the labels; nested plans run on the
enclosing plan’s session/device.”
deviceAllocationTimeoutMs is the device allocation timeout in milliseconds
(default 300000). For multi-device failures, abortStrategy selects immediate
(default) or finish-current-step. testMetadata supplies test identity
(testClass, testMethod) and optional build metadata for execution records.
cleanupAppId selects the app for cleanup and cleanupClearAppData requests
clearing its data. captureObserveSteps attaches summary or full observe
snapshots to the step debug trace; multi-device plans ignore this capture option.
holdSessionOnFailure keeps the session and its device after a failed run instead
of auto-releasing them, so the caller can recover and resume on the same device;
the caller then releases the session (plans with device labels are always released).
barrier.deviceCount specifies how many devices must arrive before the barrier
lifts. criticalSection.deviceCount specifies the devices required at its
barrier before serial execution.
recordToolCalls.planName names the plan for action: "end". The end response
carries a warnings list when a call was skipped (a file-staging call with a
host sourcePath, or a param over 64 KiB) or recorded in a weakened form:
resetKeychain is recorded with confirm: false, so a replay stops at that
step until you set confirm: true in the plan by hand. A recording whose calls
were all skipped fails with an error that lists them.
exportPlan.recordingId identifies the recording to export, and planName
names the exported plan.
Device gesture recordings capture input made on the device itself: touches on its touch
screen or emulator window, and text changes in input fields. A text change is recorded whatever produced it,
so text typed by a tool becomes a sendKeys step. Taps, swipes and
other gestures sent through AutoMobile tools (tapAt, tapOn, swipeOn, etc.)
or adb shell input are injected and are not recorded by design; use
recordToolCalls to record tool calls. Recording status always reports
captureScope: "user-input" and includes a notice while eventCount is zero.
A stop or exportPlan with no captured input fails with
reason: "no_user_input_captured" and the same explanation; touch-track or
recorder-stop failures retain their own errors.
Video recording quality and limits¶
videoRecording.resolution sets positive integer width and height;
format accepts "mp4". highlights is an array of circle shape entries with
bounds, optional description, and optional timing.startTimeMs (nonnegative
integer milliseconds).
videoRecording.recordingId identifies a recording to stop.
Start options include qualityPreset (low, medium, or high),
targetBitrateKbps (positive integer bitrate in Kbps), maxThroughputMbps
(positive throughput limit in Mbps), and fps (positive integer frames per
second). The throughput limit caps the target bitrate at
maxThroughputMbps * 1000 Kbps. Built-in defaults are low, 1000 Kbps,
5 Mbps, and 15 fps; configured recording defaults can override them.
qualityPreset is a configuration label; it does not automatically replace the
explicit bitrate or frame-rate settings.
maxDuration sets a positive integer duration in seconds (default 30), capped
at 300 seconds on Android and 3600 seconds on iOS. outputName supplies a
recording label.
The stop result reports two durations. durationMs is the wall-clock time
between start and stop. videoDurationMs is the playable duration read from the
finished file’s MP4 header (mvhd); it is omitted when the header cannot be
read. The two can differ: Android screenrecord writes frames only when the
screen changes, so an idle screen can yield a file shorter than the wall-clock
span (for example 18.2 s of video for a 25.5 s recording). Use
videoDurationMs for assertions about the file and durationMs for how long
the recording ran.
On Android, videoRecording({ action: "start", display }) accepts a physical
panel key, the role inner, cover, rear, or external, or "active".
Omitting display selects the active panel when supported. Multi-panel recordings pin
screenrecord to that physical panel for the full capture; a panel switch
does not retarget it. The stop result’s metadata.recordedPanel gives its
key and role, and metadata.transitions contains timestamped
{ atMs, from, to } panel changes in milliseconds since capture start. For
segmented recordings, atMs includes preceding segments’ time offsets and
each segment may begin with a boundary transition when the active panel
differs from the recorded panel (atMs: 0 for the first segment, or the
segment offset for later segments).
If no multi-panel inventory is available, Android keeps the existing
screenrecord command and omits recordedPanel.
A one-panel inventory reports that panel without changing the command. On a
known API below 34, default multi-panel recording uses the flagless command
and returns a warning; explicit panel selection requires API 34 or newer.
When the API is unknown and screenrecord quickly rejects --display-id,
capture retries once without the flag and returns a warning. iOS does not
accept the recording display argument.
Accessibility & session tools¶
| Tool | What it does |
|---|---|
♿ accessibility |
Reads or controls Android TalkBack and iOS VoiceOver, returning fresh device state regardless of feature flags; when force-accessibility-mode or accessibility-auto-detect: off changes what the action tools assume, detectionOverride names it. After enabling TalkBack, reports a detected blocking system runtime permission prompt via warning and blockingPrompt; AutoMobile does not dismiss it. Use observe, then tapOn to answer it. |
🎯 accessibilityFocus |
Sets or clears Android TalkBack focus by resource ID, text, or content description. |
🔀 setToolEnabled |
Controls which AutoMobile tools appear in tools/list for the current MCP session; an omitted tool remains callable directly by name through tools/call — one exact name via toolName, or a batch via toolNames; unknown or hidden names reject the batch, while always-on names are returned in skipped. |
Accessibility focus selectors¶
accessibilityFocus.resourceId targets a resource ID. contentDesc matches the
exact content description or accessible label, distinct from visible text.
Setting focus on a node that already holds accessibility focus, or clearing focus
on one that does not, succeeds without sending an action and reports
alreadySatisfied: true.
Before acquiring a device, read automobile:tools for every tool’s default discovery state. Startup enable/disable settings also affect discovery only, not direct tools/call by name.
On Android and iOS, compact observations fold soft-keyboard keys into a single
keyboard: { visible: true, package: "…" } summary plus at most one skeleton row:
<ime> | Keyboard (com.google.android.inputmethod.latin) | input
On iOS, the row is labelled Keyboard (com.apple.keyboard).
That row appears only when the IME exposed at least one bounded accessible
descendant. A keyboard whose window carries no accessible keys — some IMEs
expose none — is still announced by the keyboard summary, with no <ime> row,
because a synthetic row must never claim a box it cannot measure. Treat the
summary as the presence signal and the row as optional.
<ime> is a marker, not a selector — use sendKeys for text input and semantic
keys, or keyboard to open or close it. Everything the IME itself owns folds
into that row, including its toolbar, emoji and clipboard affordances — Gboard
gives those the same key_pos_* ids it gives letter keys
(key_pos_header_access_points_menu, key_pos_switch_to_symbol), and many keys
carry no resource-id at all, so nothing distinguishes them from a keycap. What
stays individually actionable is framework chrome sharing the window: a control
whose resource-id belongs to a DIFFERENT package than the IME, such as
android:id/input_method_nav_back. observe with project: "full" or raw: true retains
the individual keys. Keys are identified from the IME window identity a re-cut
control proxy supplies, and otherwise from the …:id/key_pos_* keycap
resource-id family, so the fold also applies on older proxy builds and on the
uiautomator dump path. An absent summary means no IME identity was captured,
not a confirmed hidden keyboard.
A skeleton row omits label entirely when it has none — the key is never
present with a placeholder value. A state-carrying container with no text of its
own (the switchWidget of a Settings row, a scrollable fragment root) takes the
label of its nearest labelled enclosing row, so a checked state is attributable
to the setting it belongs to (com.android.settings:id/switchWidget | Airplane
mode | toggle checked=false).
Action-observation diffs under the default skeleton projection use those same
compact row fields for added and removed nodes; rows with no affordances are
omitted. Their fields.layoutWarnings value is an { added, removed } pair
rather than a whole { from, to } envelope, and excludes Android system-status
bar notification chrome. An artifact-spilled diff keeps its skeleton,
context, and capture metadata inline while the bulky node and field deltas are
available from its artifact pointer.
A changed entry’s selector.index uses the same duplicate candidate set and
ranking as the next observation’s skeleton. A duplicate selector without a safe
index carries selector.ambiguous: true, including inert matches and groups
with a child that can promote to a tap or toggle ancestor. Unique selectors
carry neither index nor ambiguous; selectors with an index omit ambiguous.
observe({ project: "full", scope: { focus: ... } }) also accepts the action
selector vocabulary: exactly one elementId or text, optional container
(recursive), index, and selectionStrategy. The shared resolver resolves
outermost-first through strict descendants, including anonymous wrappers;
noninteractive containers are valid. unique is recommended for intentional
queries and applies at every unindexed level. Explicit indices override
uniqueness within the resolver’s existing ranked candidate set at that level.
Scope cannot expose descendants missing from the automation hierarchy.
For example, on Android (resource IDs) or iOS (accessibility identifiers):
{
"project": "full",
"scope": {
"focus": {
"elementId": "item_42",
"container": { "elementId": "cart_A" },
"selectionStrategy": "unique"
}
}
}
The returned observeScope.focus.chain lists outermost-to-target selectors and
matchCount at each level, before index selection. Selectors use elementId /
text and retain their enclosing container, indices, and effective strategy,
so the final selector can become an action’s container (for example, tap
remove within that item). Use qualified Android resource IDs when needed.
No scope metadata is added to an observation that did not request a scope.
Legacy {resourceId} / flat {text} anchors retain their exact-ID / substring
matching, first-node behavior, and unchanged metadata without chain. An object
with elementId or container is a nested selector; every other object keeps
the flat resourceId/text anchor, ignoring extra fields. Boolean foreground-app
focus keeps its existing metadata.
On the default skeleton projection, focus and region run on the full tree and
the skeleton and context keep only the rows that survive them; overview has no
skeleton form and is reported in observeScope.gatedOff.
A failed new selector returns an empty subtree and observeScope.focus.matched:
false, with the resolver’s unchanged error. A missing leaf reports
Target not found within container; missing or ambiguous ancestors additionally
carry containerFailure: { level, reason: "not-found" | "ambiguous", selector }.
Levels are one-based from the outermost container. Successful ancestor levels
remain in chain; target counts are included when its ancestors resolved.
observe.waitFor container timeouts expose the same optional containerFailure
at the top level, alongside the existing timeoutReason and candidates.
observe.waitFor element conditions (appear, disappear, clickable,
textEquals, countStable, and legacy element predicates) accept a nested
container chain and leaf selectionStrategy: "first" | "random" | "unique".
The timeout / timeoutMs wait budget is capped at 1,770,000 ms so the wait
and its 30-second dispatch/report allowance fit within the caller’s 30-minute
request limit.
Each container names exactly one elementId or text and may carry its own
zero-based index, selectionStrategy, and enclosing container. The outermost
container resolves first; later levels and the leaf search only strict descendants
in the automation hierarchy, including through anonymous wrappers. Rectangles do
not establish scope. The complete chain is resolved again on every fresh poll.
unique requires one eligible match at every unindexed scope level and at the
leaf. An explicit container index overrides uniqueness at that level; an
out-of-range index keeps polling. Missing or ambiguous scopes and ambiguous unique
leaves keep polling within the timeout. Scoped timeouts include a failing-level
timeoutReason and at most five diagnostic candidates. Default and explicit
first choose the first eligible match; random chooses an eligible match.
Compound legacy element fields are combined before the leaf strategy is applied.
Scoped absence (for: "disappear" or absent) is satisfied only after the scope
resolves and the leaf is absent inside it. A missing or ambiguous scope is not
proof of absence, even if the same leaf exists elsewhere. absent uses the
wait’s container and can override the leaf strategy inside its predicate.
Scoped behavior is opt-in through a nested container, any container index or
strategy, or an explicit leaf strategy. For compatibility, a plain flat one-level
{elementId} or {text} container without an index or strategy retains the legacy
behavior: a missing container satisfies disappear/absent. Unscoped calls also
retain their behavior. To wait for a container itself to disappear, target that
container at its enclosing scope. for: "stable" rejects both container and leaf
strategy; textAny-only and posture-only waits reject leaf strategies.
Inspect the hierarchy with observe first:
{ "project": "full" }
Then use the captured identifiers for a scoped wait. After confirming the
cart_A > item_42 > remove ancestry, call observe again to wait for that
removal control to disappear:
{
"waitFor": {
"for": "disappear",
"elementId": "remove",
"selectionStrategy": "unique",
"container": {
"elementId": "item_42",
"container": { "elementId": "cart_A" }
},
"timeoutMs": 5000
}
}
observe can wait for a display stamp with waitFor: { posture: "closed" } or
waitFor: { activeDisplay: "cover" }. activeDisplay accepts either a physical
panel key or a role. These conditions compare the returned observation’s
display stamp. Posture waits accept only known postures; unknown remains a
possible observation stamp but cannot be requested as a wait condition. Posture
waits need a known posture stamp: supported Android foldables report device
posture, while iPhone Duo simulators report inferred or remembered posture.
Posture waits fail immediately with an actionable error only when hydrated display inventory confirms a single panel.
Multi-panel or unavailable inventory keeps polling, including a first capture
with no hierarchy and an unknown display stamp. Posture waits also tolerate
transient observation failures after the first capture during a fold;
cancellation still propagates. activeDisplay fails immediately only when the
known inventory’s panel list has no matching key or role. A single-panel device
asking for its own panel’s key or role keeps waiting normally and resolves on a
match. An unavailable inventory or an unknown panel list keeps polling, as does
a present panel whose first display stamp is unknown.
Posture-only waits use the independently read display stamp without requiring a
new hierarchy timestamp. Combined text/element predicates and settling retain
the hierarchy freshness requirement, and all specified predicates must match.
On timeout, the last observation is returned with timedOut: true and
awaitTimeout: true, plus timeoutReason, for example:
Timed out after 5000 ms waiting for posture "closed"; last observed posture "opened".
If posture was never known on a multi-panel device and no hierarchy was captured,
the reason ends with posture was never observable because no hierarchy was captured.
If inventory was unavailable and posture was never known, it ends with
display inventory was unavailable so posture support was never confirmed.
Active-display timeouts use the parallel reasons the active display was never observable because no hierarchy was captured when no known stamp appeared and no hierarchy was captured, or display inventory was unavailable so the active display was never confirmed when inventory was unavailable and no known stamp appeared. Otherwise the reason names the last observed active display key and role.
display.generation is the host tracker’s identityRevision.
Generation advances on notifyTransition calls for panel key, role, or posture changes, Android non-swap size changes and accepted pushed display_transition events (changed with a different panel key or non-swap size, added, removed, or device_state changes), iOS multi-panel rotation, and iOS setPosture hinge, observed identity, and settled notifications (potentially several increments per request), but not on captures, Android pure width/height swaps, or iOS same-observation geometry corrections.
staleDisplay.observedGeneration is the display.generation stamp from the
caller’s last rendered observation, persisted even when a transition clears the
cached hierarchy. A capture that straddles a transition retains its capture-start
generation, and refusals report that stamp rather than its full internal revision.
currentGeneration is the identity generation at the fence; currentDisplayKey
is included only when a panel has been accepted after the latest transition.
Without a stored stamp, in-flight fences use the action-start identity generation.
Without a prior rendered revision, entry fences do not reject the action; in-flight
transitions still do. Explicit display targeting still requires a prior observation
of that panel and keeps its existing missing-observation error.
Generation is comparable only within one session: it restarts at 0 when the device is released or the session ends, and on daemon restart.
An action-observation diff includes displayChanged: { from, to } when the
panel key, role, or posture changes. Generation alone does not create a
display change entry.
For the observe → act → observe behavior behind interaction tools, see the interaction loop. For per-session public tool selection, see Dynamic Tools.
Android SharedPreferences user targeting¶
For scope: "sharedPreferences", getPreference and setPreference accept an optional
userId (for example a work profile). It is passed to adb shell run-as <pkg> --user <id>
for nonzero users; user 0 keeps the unscoped run-as <pkg> command. When omitted, user 0 is
used whenever the package is installed for it. Another user is used only when the package is
not installed for user 0 and is installed for exactly one other running user; if several such
users have it, the call fails and asks for userId. If the device’s users cannot be listed,
user 0 is used (with a warning). The result always reports the userId that was read or
written. userId is rejected for other scopes.
setKeyValue, removeKeyValue and clearKeyValueFile accept the same optional userId on
Android. It applies to their direct-file fallback (the run-as XML edit used when the SDK route
is disabled by inspection or mutation policy) and resolve the default identically: an explicit
userId wins, otherwise user 0 when the package is installed for it, else the one other running
user that has it (several such users: the call fails and asks for userId). The SDK route itself is not user-scoped.
The mutation queue is keyed per user, so the same file in two users never serializes together.
userId is rejected on iOS devices. The automobile:devices/{deviceId}/storage/... entries
resource has no input, so its run-as fallback reads the same default user.
iOS UserDefaults preferences¶
For a simulator check with an app that does not embed the SDK, run
bash scripts/ios/userdefaults-no-sdk-smoke.sh <booted-simulator-udid>.
It installs and removes a disposable probe app, exercises the real container route
for standard and custom suites, and checks the app’s own UserDefaults after cold
relaunch. It covers an absent runner and the old/new SDK-refusal messages injected
at the transport seam; it does not build or exercise a live CtrlProxy runner.
Run it on a dedicated simulator. Xcode and the repository’s Bun dependencies are
required.
getPreference and setPreference use scope: "userDefaults", appId (bundle ID),
key, and optional suite. Unlike setKeyValue, these tools reject name and
fileName; use suite to select a custom UserDefaults suite. Omit it, pass an
empty string, or use "Standard" for the app’s standard store. For example:
{ "scope": "userDefaults", "appId": "com.example.app", "suite": "mt8327Suite", "key": "kv8327" }
On simulators, reads and writes use the embedded AutoMobile SDK only when an existing runner connection is open, with storage inspection enabled. Storage access itself never starts a runner; tool target resolution may run the readiness described in Reading a device you do not own. SDK requests are bounded to 2.5 seconds. An SDK missing key is authoritative. Reads may fall back after a recognized SDK timeout, transport loss, missing route, or inspection-disabled response. Unknown faults, app mismatch, and mutation refusals surface as errors.
Writes fall back when the client is absent or closed, or the runner refuses the
SDK route before dispatch because the target app lacks the SDK. The runner marks
these refusals with sdk_unavailable_not_dispatched; exact legacy capability
refusals are also recognized. Reads fall back on these refusals too. Dispatched
or ambiguous SDK write failures never cause a container write and report that the
write may or may not have been applied. Inspection-disabled
and mutation-refused writes surface errors, preserving the app’s opt-in policy.
Read-back verification uses the successful write route and reports the type read
back, while comparing according to the requested input type. SDK-redacted reads
return found: true, redacted: true, value: null, and the original canonical
type. The host honors the SDK’s explicit redacted: true flag regardless of value,
and falls back to the [REDACTED] sentinel for older SDKs when the flag is absent.
The storage entries resource keeps each redacted list entry’s SDK type vocabulary
(for example, STRING) while returning value: null and redacted: true; single
reads use the canonical type (for example, string). Unredacted list entries
retain their existing wire shape.
New SDKs also omit the flag for ordinary values, so a literal "[REDACTED]" string
remains indistinguishable from older SDK redaction and is treated as redacted.
A successful write with redacted read-back returns success: true, redacted: true,
verified: false, and a warning that the value was written but not compared;
verified: false means equality was not established, not that the write failed.
Container-plist and defaults reads treat the sentinel as an ordinary string.
The container route reads Library/Preferences/<suite>.plist for custom suites
and <appId>.plist for the standard store, preserving the bundle ID’s casing.
Omitted, empty/whitespace, any case of standard, and a suite equal to the bundle
ID (case-insensitive) all select the standard store; the SDK receives Standard.
App-group suites (group.*) live in a separate group container and require the
connected SDK. Without it, reads return not-found with a warning, and writes fail.
Physical-device access remains unsupported.
Plist reads warn that the on-disk value may lag a running app’s cfprefsd state.
Container writes via defaults write <absolute path> bypass the preferences daemon:
a running app may not see the change, and cfprefsd may overwrite the file with
cached state until the app restarts. verified: true proves file content only.
Canonical types are string, bool, int, float (SDK FLOAT/DOUBLE and plist
real), date, data, array, dictionary, and unknown for unrecognized SDK
types. Unsafe integers retain their exact decimal string with type int. Date
and data values are ISO and base64 strings. Plist non-finite reals retain type
float and values "nan", "inf", or "-inf" so JSON does not turn them into null;
these strings are also preserved recursively inside collections.
Arrays/dictionaries are JSON strings. SDK collections that parse as their declared
JSON shape carry valueFormat: "canonical-json"; current SDKs encode nested dates
as ISO strings, data as base64, and non-finite numbers as "nan", "inf", or "-inf".
If an older SDK falls back to Swift interpolation (for example, nested Date/Data),
the raw description is retained,
type is unknown, and valueFormat is "sdk-description", with a warning about
lossy encoding. Reading via the container-plist route when the SDK is disconnected
returns recursive JSON with ISO/base64 leaves. valueFormat is omitted for other
values and routes.
Android additionally retains long and stringSet.
The optional iOS resolvedStore is a plain name: standard or the custom suite.
The separate optional storeRoute is sdk, container-plist, or defaults.
Simulator-global defaults domains remain available to direct internal calls
without appId; their resolvedStore is the domain name, which cannot start with
-. The MCP input schema requires appId.
Device lifecycle structured results¶
wakeAndUnlock, pressButton, launchApp, terminateApp, getDeviceState, and
setDeviceState declare output schemas and return the same JSON payload in text
content and structuredContent. Ordinary clients receive structuredContent
unless --tool-results-no-structured-content is enabled. Each schema requires
message; variant fields are optional, and additional fields are accepted.
startDevice also declares an output schema. Its readiness evidence contains
level: "automationReady", checks: ["bootCompleted", "runnerReady", "sessionBound"],
elapsedMs measured with the injected clock from the start of boot preparation
through session binding, and recovered indicating System UI ANR recovery.
The older acquisition field remains for compatibility: "already-booted" means
adopted and "cold-boot" means launched or restarted by this call. Evidence uses
the existing readiness steps without extra device probes. The shared getAndroid
and getApple result builder also emits this evidence.
wakeAndUnlock reports success, platform, wasAsleep, wasLocked, and
unlocked, with optional secure, usedRecordedCredential, error, and
warning. Returned unlock failures do not set MCP isError; operational errors
may throw.
pressButton reports success, button, and keyCode, with optional error,
warnings, staleDisplay, effect, and finalized observation/observationDiff.
Returned failures retain their structured payload and set MCP isError.
launchApp reports success and packageName, with optional activityName,
userId, pid, alreadyForeground, foregroundActivityPackage, verifiedBy,
verified, verifyFailureReason, observedAppId, and action observation metadata.
A stale launch observation may be replaced by observationOmitted containing
reason, expectedPackage, and reportedPackages. Failed launches throw
actionable errors; a successful launch with unverified foreground still returns
its verification fields.
terminateApp reports success and packageName, with optional wasForeground
(omitted when the pre-terminate foreground app could not be determined),
wasInstalled, wasRunning, userId, and action observation metadata. Already
absent or stopped apps are successful no-ops. Failed terminations throw actionable
errors.
getDeviceState and setDeviceState report success, deviceId, and platform,
with optional error and requested field results: doNotDisturb, connectivity,
biometrics, networkCondition, clock, and (writes only) location. Reads may
also include displays and unsupported field names. Field results report
supported and optional verification, capability, method, values, warning, or
error metadata. Clock writes can report outcome as changed, unchanged, or
restored; degraded network writes report capability partial. On an iOS
Simulator, networkCondition is per-app only: offline or none with appId,
within a session, through the opt-in network filter. It reports scope: "app",
the acknowledged rule (revision, owner generation, lease), coverage: "partial"
and limitations; reads list the provider’s active rules for that simulator.
Setter TTL
rejection and biometric capture failures also return structured failure payloads
without MCP isError.
Compact action metadata¶
Action responses compact unchanged metadata by default within a session and device:
observation.insets, systemInsets, backStack, gfxMetrics,
displayedTimeMetrics, deviceLock, accessibilityState, and freshness, plus
raw viewHierarchy.insets and viewHierarchy.systemInsets. First delivery,
a new session, and every device switch send available blocks in full; changed
blocks reappear. Stale freshness, unstable gfx metrics, and partial back stacks
remain inline. backStack.capturedAt alone does not count as a change.
A duplicate top-level element is omitted when identical to
selectedElement.matchedElement and not required by the output schema.
Use AUTOMOBILE_ACTIONS_COMPACT_METADATA=0, --no-actions-compact-metadata,
or feature flag actions-compact-metadata=false to restore full metadata.
--actions-compact-metadata or exact env 1 explicitly enables it. Negative CLI
wins over positive CLI, then exact env 0/1, then persisted state, then on.
An explicit CLI/env choice applies to that connection only: proxies relay it on
the connection profile, never restart the shared daemon for it, and never persist it.
Unset or other env values express no preference and use the daemon’s effective setting.
observe responses remain full. See interaction loop.