# MAS SDK Reference: Python Android Automation API

> The mas SDK reference: every function, class, type and exception in the Python package with exact signatures and defaults for emulator and cloud device macros.

Source: https://automationmacro.com/docs/api-reference (Python SDK, updated 2026-09-04)

This page is the mas SDK reference: every public name in the `mas` package that ships with Macro Automation Studio (MAS), with the signature as written in the source, each parameter with its default, what comes back, what can raise, and a short example. Read the [SDK overview](/docs/sdk) first if you have not written a macro yet. The namespace pages explain when to use each call; this page is the lookup table. If you describe the task to [MAS Agent](/agent), it writes this code for you.

Every signature below is copied from the SDK source. `mas.__version__` is `"0.1.0"`.

> [!NOTE]
> The device connection is handled for you. MAS starts your script with the device already bound, so there is no connect or disconnect call. The first `mas.*` call opens the JSON-RPC connection to the app.

## Namespace map of the mas SDK reference

| Module | What it holds | Section |
|---|---|---|
| `types.py` | Dataclasses, enums and aliases shared by every call: `Region`, `ObjectMatch`, `KeyCode`, `ColorConversion` and more | [Types](#types) |
| `interaction.py` | Touch and keyboard input: `click`, `swipe`, `input_text`, `key_press`, `zoom_in`, `zoom_out` | [Interaction](#interaction) |
| `vision.py` | Screenshots, template matching and OCR: `take_screenshot`, `find_object`, `find_objects`, `find_any_object`, `find_object_retry`, `find_any_object_retry`, `read_text`, `wait_for_object` | [Vision](#vision) |
| `registry.py` | Readable names for Image Library IDs: `images`, `ImageRef`, `ImageMap` | [Image registry](#image-registry) |
| `elements.py` | The accessibility tree: `find_element`, `find_elements`, `find_element_retry`, `read_element`, `scroll_to_element`, `read_page`, `dump_hierarchy`, `host_capabilities`, `ElementMatch` | [Elements](#elements) |
| `app.py` | Launch and inspect apps: `open_app`, `close_app`, `get_current_app`, `get_app_state`, `is_app_focused` and the app state constants | [App](#app) |
| `device.py` | Device facts: `get_screen_size`, `get_device_info`, `get_host_machine_id` | [Device](#device) |
| `storage.py` | State that survives a run: `save`, `retrieve`, `retrieve_all`, `clear`, `get_current_device_port` | [Storage](#storage) |
| `utils.py`, `webhook.py`, `client.py` | `log`, `get_clipboard`, `webhook`, `RPCClient`, `get_client` | [Utilities and webhooks](#utilities-and-webhooks) |
| `ui.py` as `mas.ui` | Runtime dashboard updates and events: `set_text`, `set_progress`, charts, text areas, tables, `wait_for_event`, `on_click`, `batch` | [Runtime UI](#runtime-ui-masui) |
| `exceptions.py` | `MASError`, `RPCError` and its coded subclasses, `ElementsUnavailable` | [Exceptions](#exceptions) |

Everything is importable from `mas` directly, for example `from mas import Region, KeyCode`. Only the runtime UI lives in its own namespace, `mas.ui`.

## Types

The types are plain dataclasses, enums and type aliases. Import them from `mas`.

### Coordinates

```python
Coordinates = tuple[int, int]
```

An `(x, y)` pair in pixels, measured from the top-left corner of the screen. `swipe`, `zoom_in` and `zoom_out` take one; `ObjectMatch.center` and `ElementMatch.center` return one.

```python
import mas

start: mas.Coordinates = (540, 1600)
end: mas.Coordinates = (540, 600)
mas.swipe(start, end, duration_ms=400)
```

### Region

```python
class Region
```

A rectangle on screen given by two corners. `find_object`, `find_objects` and `find_any_object` take one as `search_region`; `read_text` takes one as `region`.

| Field | Type | Default | Meaning |
|---|---|---|---|
| `x1` | `int` | required | Left edge, pixels from the left |
| `y1` | `int` | required | Top edge, pixels from the top |
| `x2` | `int` | required | Right edge, pixels from the left |
| `y2` | `int` | required | Bottom edge, pixels from the top |

```python
import mas
from mas import Region

score_box = Region(x1=800, y1=10, x2=1050, y2=60)
result = mas.read_text(region=score_box)
```

### ColorConversion

```python
class ColorConversion(Enum)
```

Preprocessing applied before OCR in `read_text`. The values match OpenCV's color conversion codes; two are MAS additions. Members that share a value are aliases of each other.

| Member | Value | Meaning |
|---|---|---|
| `NONE` | `-1` | No conversion (the default) |
| `BLACK_WHITE` | `-2` | Grayscale plus an automatic threshold, so the image is pure black and white; the best choice for high-contrast text on noisy or colored backgrounds |
| `BGR_TO_BGRA`, `RGB_TO_RGBA` | `0` | Add an alpha channel |
| `BGRA_TO_BGR`, `RGBA_TO_RGB` | `1` | Drop the alpha channel |
| `BGR_TO_RGBA`, `RGB_TO_BGRA` | `2` | Swap the channel order and add alpha |
| `RGBA_TO_BGR`, `BGRA_TO_RGB` | `3` | Swap the channel order and drop alpha |
| `BGR_TO_RGB`, `RGB_TO_BGR` | `4` | Swap the channel order |
| `BGRA_TO_RGBA`, `RGBA_TO_BGRA` | `5` | Swap the channel order, keep alpha |
| `BGR_TO_GRAY` | `6` | Grayscale from BGR |
| `RGB_TO_GRAY` | `7` | Grayscale from RGB |
| `GRAY_TO_BGR`, `GRAY_TO_RGB` | `8` | Expand grayscale to three channels |
| `GRAY_TO_BGRA`, `GRAY_TO_RGBA` | `9` | Expand grayscale to four channels |
| `BGRA_TO_GRAY` | `10` | Grayscale from BGRA |
| `RGBA_TO_GRAY` | `11` | Grayscale from RGBA |
| `BGR_TO_HSV` | `40` | HSV from BGR |
| `RGB_TO_HSV` | `41` | HSV from RGB |

There is no `GRAYSCALE` or `BINARY` member. Use `BGR_TO_GRAY` for grayscale and `BLACK_WHITE` for a binary image.

```python
import mas
from mas import ColorConversion, Region

word = mas.read_text(region=Region(100, 200, 400, 260), psm=8,
                     color_conversion=ColorConversion.BLACK_WHITE)
```

### SearchStrategy

```python
SearchStrategy = Literal["first_match", "best_match", "priority_order"]
```

How `find_any_object` chooses among several templates.

| Value | Behavior |
|---|---|
| `"first_match"` | Return the first template that matches (the default and the fastest) |
| `"best_match"` | Try every template and return the highest-confidence hit |
| `"priority_order"` | Prefer templates that appear earlier in the list |

### DeviceType

```python
DeviceType = Literal["android", "desktop"]
```

The kind of device bound to the run, as reported in `DeviceInfo.type`.

### DeviceInfo

```python
class DeviceInfo
```

Returned by `get_device_info`.

| Field | Type | Default | Meaning |
|---|---|---|---|
| `id` | `str` | required | Device identifier, for example `emulator-5556` |
| `name` | `str` | required | The display name of the device |
| `type` | `DeviceType` | required | `"android"` or `"desktop"` |
| `screen_width` | `int` | required | Screen width in pixels |
| `screen_height` | `int` | required | Screen height in pixels |
| `connected` | `bool` | required | Whether the device is reachable |

### ScreenSize

```python
class ScreenSize
```

Returned by `get_screen_size`.

| Field | Type | Default | Meaning |
|---|---|---|---|
| `width` | `int` | required | Screen width in pixels |
| `height` | `int` | required | Screen height in pixels |

### ObjectMatch

```python
class ObjectMatch
```

The result of a template match. `x` and `y` are the center of the matched area. There is no confidence, width or height field: the host applies `threshold` before it answers, so a returned match already passed it.

| Field | Type | Default | Meaning |
|---|---|---|---|
| `x` | `int` | required | Center x of the match |
| `y` | `int` | required | Center y of the match |
| `matched_template_id` | `int \| None` | `None` | The image ID that matched; set by `find_any_object` |

Properties: `center` returns `(x, y)` as `Coordinates`; `template_id` is an alias for `matched_template_id`.

```python
import mas

match = mas.find_object(42)
if match:
    mas.click(*match.center)
    print(match.x, match.y, match.template_id)
```

### Screenshot

```python
class Screenshot
```

Returned by `take_screenshot`. The image stays in memory as base64-encoded PNG.

| Field | Type | Default | Meaning |
|---|---|---|---|
| `base64` | `str` | required | Base64-encoded PNG data |
| `width` | `int` | required | Width in pixels |
| `height` | `int` | required | Height in pixels |
| `timestamp` | `str` | required | Capture time in ISO 8601 format |

### TextRecognitionResult

```python
class TextRecognitionResult
```

Returned by `read_text`.

| Field | Type | Default | Meaning |
|---|---|---|---|
| `text` | `str` | required | The recognized text |
| `confidence` | `float` | required | Recognition confidence from 0.0 to 1.0 |
| `region` | `Region` | required | The area the text was read from |

### KeyCode

```python
class KeyCode(Enum)
```

Android hardware key codes for `key_press`. Each member's value is the Android key code string. There are 33 members; there is no `ESCAPE`.

| Member | Value |
|---|---|
| `BACK`, `HOME`, `MENU`, `APP_SWITCH` | `KEYCODE_BACK`, `KEYCODE_HOME`, `KEYCODE_MENU`, `KEYCODE_APP_SWITCH` |
| `ENTER`, `DELETE`, `SPACE`, `TAB` | `KEYCODE_ENTER`, `KEYCODE_DEL`, `KEYCODE_SPACE`, `KEYCODE_TAB` |
| `POWER`, `CAMERA` | `KEYCODE_POWER`, `KEYCODE_CAMERA` |
| `VOLUME_UP`, `VOLUME_DOWN` | `KEYCODE_VOLUME_UP`, `KEYCODE_VOLUME_DOWN` |
| `BRIGHTNESS_UP`, `BRIGHTNESS_DOWN` | `KEYCODE_BRIGHTNESS_UP`, `KEYCODE_BRIGHTNESS_DOWN` |
| `DPAD_UP`, `DPAD_DOWN`, `DPAD_LEFT`, `DPAD_RIGHT`, `DPAD_CENTER` | `KEYCODE_DPAD_UP`, `KEYCODE_DPAD_DOWN`, `KEYCODE_DPAD_LEFT`, `KEYCODE_DPAD_RIGHT`, `KEYCODE_DPAD_CENTER` |
| `MEDIA_PLAY`, `MEDIA_PAUSE`, `MEDIA_NEXT`, `MEDIA_PREVIOUS` | `KEYCODE_MEDIA_PLAY`, `KEYCODE_MEDIA_PAUSE`, `KEYCODE_MEDIA_NEXT`, `KEYCODE_MEDIA_PREVIOUS` |
| `NUM_0` to `NUM_9` | `KEYCODE_0` to `KEYCODE_9` |

```python
import mas
from mas import KeyCode

mas.key_press(KeyCode.BACK)
mas.key_press(KeyCode.DELETE, repeat=10)
```

### Modifier

```python
class Modifier(Enum)
```

Modifier keys for `key_press`: `SHIFT` (`"shift"`), `CTRL` (`"ctrl"`), `ALT` (`"alt"`), `META` (`"meta"`).

### LogLevel

```python
class LogLevel(Enum)
```

Severity for `log`: `DEBUG` (`"debug"`), `INFO` (`"info"`), `WARNING` (`"warning"`), `ERROR` (`"error"`). `log` also accepts the plain strings.

### ExecutionResult

```python
class ExecutionResult
```

A generic command result. It is exported for typing; no function on this page returns it.

| Field | Type | Default | Meaning |
|---|---|---|---|
| `success` | `bool` | required | Whether the command succeeded |
| `message` | `str \| None` | required | A human-readable note |
| `data` | `dict \| None` | required | Extra data from the host |
| `duration_ms` | `int` | required | How long the command took |

## Interaction

Touch and keyboard input. Coordinates are pixels on the device screen. Each call blocks until the host confirms the command and then returns `None`. A failure on the device surfaces as an `RPCError` subclass (see [Exceptions](#exceptions)): `DeviceNotConnectedError` when no device is bound to the run, `CommandFailedError` when the device rejects the command.

### click

```python
def click(x: int, y: int, delay_ms: int = 1000) -> None
```

Tap once at `(x, y)`, then pause for `delay_ms` so the app can react.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `x` | `int` | required | Horizontal position in pixels from the left edge |
| `y` | `int` | required | Vertical position in pixels from the top edge |
| `delay_ms` | `int` | `1000` | Pause after the tap, in milliseconds |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas

mas.click(540, 1200)                # tap, then wait 1000 ms
mas.click(540, 1200, delay_ms=0)    # no pause, for tight loops
match = mas.find_object_retry(42)
if match:
    mas.click(match.x, match.y, delay_ms=1500)
```

### swipe

```python
def swipe(from_coords: Coordinates, to_coords: Coordinates, duration_ms: int = 1000) -> None
```

Drag from one point to another. A short duration is a flick; a long one is a controlled drag.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `from_coords` | `Coordinates` | required | Starting `(x, y)` |
| `to_coords` | `Coordinates` | required | Ending `(x, y)` |
| `duration_ms` | `int` | `1000` | Gesture duration in milliseconds; shorter is faster |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas

mas.swipe((500, 1500), (500, 500), duration_ms=400)    # swipe up to scroll down
mas.swipe((100, 500), (900, 500), duration_ms=300)     # swipe right
mas.swipe((200, 300), (800, 300), duration_ms=1000)    # slow drag
```

### input_text

```python
def input_text(text: str, delay_ms: int = 0, clear: bool = False) -> None
```

Type into the field that currently has focus. Tap the field first. With `clear=True` the cursor moves to the end of the field and the existing characters are deleted before typing, so the field does not need to be empty.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `text` | `str` | required | The text to type |
| `delay_ms` | `int` | `0` | Pause after typing, in milliseconds |
| `clear` | `bool` | `False` | Delete the field's current contents first |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas

mas.click(300, 450)                       # focus the field
mas.input_text("user@example.com")
mas.input_text("new value", clear=True)   # replace whatever is there
mas.input_text("done", delay_ms=500)
```

### key_press

```python
def key_press(key_code: KeyCode, modifiers: list[Modifier] | None = None, duration_ms: int = 100, repeat: int = 1) -> None
```

Press a hardware key, optionally with modifiers. A `duration_ms` of 500 or more sends a long press; smaller values send a normal press. The Android input bridge has no arbitrary hold, so the exact number of milliseconds is not honored beyond that distinction. All `repeat` presses go to the device in one command, which is much faster than a Python loop. Holding `DELETE` does not clear a field; use `repeat` to delete several characters, or `input_text(..., clear=True)`.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `key_code` | `KeyCode` | required | The key to press |
| `modifiers` | `list[Modifier] \| None` | `None` | Modifier keys held during the press |
| `duration_ms` | `int` | `100` | Hold time; 500 or more means a long press |
| `repeat` | `int` | `1` | Number of presses, 1 to 100, sent as one command |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas
from mas import KeyCode, Modifier

mas.key_press(KeyCode.BACK)
mas.key_press(KeyCode.ENTER)
mas.key_press(KeyCode.POWER, duration_ms=3000)        # long press
mas.key_press(KeyCode.DELETE, repeat=10)              # delete 10 characters
mas.key_press(KeyCode.TAB, modifiers=[Modifier.SHIFT])
```

### zoom_in

```python
def zoom_in(center: Coordinates | None = None, percent: int = 50, duration_ms: int = 0, steps: int = 10) -> None
```

A two-finger pinch that spreads the fingers apart. The gesture is synthesized through the emulator's touch input device, which is detected automatically.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `center` | `Coordinates \| None` | `None` | Center of the gesture; `None` means the center of the screen |
| `percent` | `int` | `50` | Intensity from 1 to 100: how far the fingers spread, as a share of the largest pinch that fits around the center |
| `duration_ms` | `int` | `0` | Total gesture time; `0` runs it as fast as the device executes it |
| `steps` | `int` | `10` | Finger movement steps from 2 to 100; more steps make a smoother gesture |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas

mas.zoom_in()                                   # at the screen center
mas.zoom_in(center=(540, 960), percent=80)      # strong zoom at a point
```

### zoom_out

```python
def zoom_out(center: Coordinates | None = None, percent: int = 50, duration_ms: int = 0, steps: int = 10) -> None
```

A two-finger pinch that brings the fingers together. Same mechanics and parameters as `zoom_in`; `percent` is how far apart the fingers start.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `center` | `Coordinates \| None` | `None` | Center of the gesture; `None` means the center of the screen |
| `percent` | `int` | `50` | Intensity from 1 to 100: how far apart the fingers start |
| `duration_ms` | `int` | `0` | Total gesture time; `0` runs it as fast as the device executes it |
| `steps` | `int` | `10` | Finger movement steps from 2 to 100 |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas

mas.zoom_out()
mas.zoom_out(center=(540, 960), percent=30)     # gentle zoom out at a point
```

## Vision

A macro decides what to do by looking at the screen. Template images live in your Image Library in the cloud, each with an integer ID; you crop them in Asset Lab. Vision calls take that ID, or an `ImageRef` from `images`. Every `find_*` call returns `None` (or an empty list) on a miss and never raises for a miss. Take one screenshot and pass it to several searches when you need to check more than one thing on the same frame.

### take_screenshot

```python
def take_screenshot() -> Screenshot
```

Capture the device screen as an in-memory base64 PNG.

**Returns:** a `Screenshot` with `base64`, `width`, `height` and `timestamp`.

**Raises:** an `RPCError` subclass when the capture fails.

```python
import mas
import base64

shot = mas.take_screenshot()
play = mas.find_object(42, screenshot=shot)
coins = mas.find_object(43, screenshot=shot)
with open("screen.png", "wb") as f:
    f.write(base64.b64decode(shot.base64))
```

### find_object

```python
def find_object(image_id: int | ImageRef, *, threshold: float = 0.8, screenshot: Screenshot | None = None, continuous_mode: bool = False, timeout_ms: int = 1000, capture_interval_ms: int = 500, max_matches: int = 1, search_region: Region | None = None) -> ObjectMatch | None
```

Find one template on screen by template matching. All options after `image_id` are keyword-only.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `image_id` | `int \| ImageRef` | required | Image Library ID, or an `ImageRef` from `images` |
| `threshold` | `float` | `0.8` | Match confidence from 0.0 to 1.0; higher is stricter |
| `screenshot` | `Screenshot \| None` | `None` | Reuse a frame from `take_screenshot` instead of capturing |
| `continuous_mode` | `bool` | `False` | Keep capturing and searching until found or `timeout_ms` elapses |
| `timeout_ms` | `int` | `1000` | Time budget, used only in continuous mode |
| `capture_interval_ms` | `int` | `500` | Pause between captures in continuous mode |
| `max_matches` | `int` | `1` | Candidates the host looks for; `find_object` returns one match whatever the value |
| `search_region` | `Region \| None` | `None` | Restrict the search to a rectangle; faster and fewer false hits |

**Returns:** an `ObjectMatch` when a match reaches `threshold`, otherwise `None`.

**Raises:** `ImageNotFoundError` when the ID is not in your Image Library or cannot be loaded; `TypeError` when `image_id` is neither an `int` nor an `ImageRef`; other `RPCError` subclasses on host failures.

```python
import mas
from mas import Region

images = mas.images({"login_btn": 123, "badge": 124})
match = mas.find_object(images.login_btn)
if match:
    mas.click(*match.center)
top_left = mas.find_object(images.badge, search_region=Region(0, 0, 500, 400))
```

### find_objects

```python
def find_objects(image_id: int | ImageRef, *, threshold: float = 0.8, screenshot: Screenshot | None = None, continuous_mode: bool = False, timeout_ms: int = 1000, capture_interval_ms: int = 500, max_matches: int = 10, search_region: Region | None = None) -> list[ObjectMatch]
```

Find every occurrence of one template. The parameters match `find_object`; only the `max_matches` default differs.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `image_id` | `int \| ImageRef` | required | Image Library ID, or an `ImageRef` |
| `threshold` | `float` | `0.8` | Match confidence from 0.0 to 1.0 |
| `screenshot` | `Screenshot \| None` | `None` | Reuse a captured frame |
| `continuous_mode` | `bool` | `False` | Keep searching until found or timeout |
| `timeout_ms` | `int` | `1000` | Time budget in continuous mode |
| `capture_interval_ms` | `int` | `500` | Pause between captures in continuous mode |
| `max_matches` | `int` | `10` | Maximum number of matches to return |
| `search_region` | `Region \| None` | `None` | Restrict the search to a rectangle |

**Returns:** a list of `ObjectMatch` sorted by confidence, best first; an empty list when nothing matches.

**Raises:** `ImageNotFoundError`, `TypeError`, or another `RPCError` subclass, as for `find_object`.

```python
import mas

images = mas.images({"collectible": 88})
for star in mas.find_objects(images.collectible, max_matches=20):
    mas.click(*star.center, delay_ms=300)
```

### find_any_object

```python
def find_any_object(image_ids: list[int | ImageRef], *, threshold: float = 0.8, screenshot: Screenshot | None = None, continuous_mode: bool = False, timeout_ms: int = 1000, capture_interval_ms: int = 500, max_matches: int = 1, search_strategy: SearchStrategy = "first_match", search_region: Region | None = None) -> ObjectMatch | None
```

Search for several templates at once. Use it when one control has variants: languages, themes or states. The returned match's `matched_template_id` tells you which template hit.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `image_ids` | `list[int \| ImageRef]` | required | Templates to look for |
| `threshold` | `float` | `0.8` | Match confidence from 0.0 to 1.0 |
| `screenshot` | `Screenshot \| None` | `None` | Reuse a captured frame |
| `continuous_mode` | `bool` | `False` | Keep searching until found or timeout |
| `timeout_ms` | `int` | `1000` | Time budget in continuous mode |
| `capture_interval_ms` | `int` | `500` | Pause between captures in continuous mode |
| `max_matches` | `int` | `1` | Candidates per template |
| `search_strategy` | `SearchStrategy` | `"first_match"` | `"first_match"`, `"best_match"` or `"priority_order"` |
| `search_region` | `Region \| None` | `None` | Restrict the search to a rectangle |

**Returns:** an `ObjectMatch` with `x`, `y` and `matched_template_id`, or `None`.

**Raises:** `ImageNotFoundError`, `TypeError`, or another `RPCError` subclass.

```python
import mas

images = mas.images({"claim_en": 123, "claim_fr": 124, "claim_es": 125})
match = mas.find_any_object([images.claim_en, images.claim_fr, images.claim_es],
                            search_strategy="best_match")
if match:
    print(f"Matched image {match.template_id} at {match.center}")
    mas.click(*match.center)
```

### find_object_retry

```python
def find_object_retry(image_id: int | ImageRef, *, total_tries: int = 3, time_sleep: float = 2.0, **kwargs) -> ObjectMatch | None
```

The house wait primitive. Call `find_object` up to `total_tries` times, sleeping `time_sleep` seconds between failed attempts (never after the last one), and return the first match, or `None` when every attempt fails. Discrete tries with a flat pause read plainly, cost a bounded and predictable amount of time, and behave the same on every host. Every other keyword argument (`threshold`, `screenshot`, `max_matches`, `search_region` and so on) is forwarded to `find_object` by name. A caller-supplied `screenshot` is a frozen frame, so the call collapses to one attempt.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `image_id` | `int \| ImageRef` | required | Image Library ID, or an `ImageRef` |
| `total_tries` | `int` | `3` | Attempts before giving up; values below 1 count as 1 |
| `time_sleep` | `float` | `2.0` | Seconds to sleep between failed attempts |
| `**kwargs` | keyword arguments | none | Forwarded to `find_object` unchanged |

**Returns:** the first `ObjectMatch`, or `None`.

**Raises:** whatever `find_object` raises.

```python
import mas

images = mas.images({"play": 784})
button = mas.find_object_retry(images.play, total_tries=5, time_sleep=1.5, threshold=0.85)
if button is None:
    mas.log("Play button not found, will try again later", level="warning")
else:
    mas.click(button.x, button.y, delay_ms=1000)
```

### find_any_object_retry

```python
def find_any_object_retry(image_ids: list[int | ImageRef], *, total_tries: int = 3, time_sleep: float = 2.0, **kwargs) -> ObjectMatch | None
```

`find_object_retry` over a list of templates. Identical semantics: up to `total_tries` attempts, a flat `time_sleep` pause between failed ones, every other keyword forwarded to `find_any_object` by name, and a caller-supplied `screenshot` collapsing to one attempt. Use it where several variants of one control are acceptable.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `image_ids` | `list[int \| ImageRef]` | required | Templates to look for |
| `total_tries` | `int` | `3` | Attempts before giving up |
| `time_sleep` | `float` | `2.0` | Seconds to sleep between failed attempts |
| `**kwargs` | keyword arguments | none | Forwarded to `find_any_object` unchanged |

**Returns:** the first `ObjectMatch`, or `None`.

**Raises:** whatever `find_any_object` raises.

```python
import mas

images = mas.images({"ok_light": 201, "ok_dark": 202})
ok = mas.find_any_object_retry([images.ok_light, images.ok_dark], total_tries=4)
if ok:
    mas.click(*ok.center)
```

### read_text

```python
def read_text(region: Region | None = None, screenshot: Screenshot | None = None, model: str = "eng_best", psm: int = 7, color_conversion: ColorConversion = ColorConversion.NONE, timeout_ms: int = 30000) -> TextRecognitionResult
```

Read text off the screen with OCR (Tesseract). Always pass a `region` when you can: OCR on a tight box is faster and far more accurate than reading the whole screen. `psm` tells the engine the shape of the text.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `region` | `Region \| None` | `None` | Area to read; `None` reads the whole screen |
| `screenshot` | `Screenshot \| None` | `None` | Reuse a captured frame |
| `model` | `str` | `"eng_best"` | OCR model or language: `"eng_best"`, `"eng_fast"` or `"eng"` |
| `psm` | `int` | `7` | Page segmentation mode 0 to 13: 6 uniform block, 7 single line, 8 single word, 11 sparse text |
| `color_conversion` | `ColorConversion` | `ColorConversion.NONE` | Preprocessing before OCR; `BLACK_WHITE` for text on noisy backgrounds, `BGR_TO_GRAY` for plain grayscale |
| `timeout_ms` | `int` | `30000` | OCR time budget in milliseconds |

**Returns:** a `TextRecognitionResult` with `text`, `confidence` and `region`.

**Raises:** an `RPCError` subclass when OCR fails or the host times out.

```python
import mas
from mas import ColorConversion, Region

result = mas.read_text(region=Region(420, 90, 660, 140))
score = int(result.text) if result.text.strip().isdigit() else 0
word = mas.read_text(region=Region(100, 200, 400, 260), psm=8,
                     color_conversion=ColorConversion.BLACK_WHITE)
print(word.text, word.confidence)
```

### wait_for_object

```python
def wait_for_object(image_id: int | ImageRef, timeout_ms: int = 10000, threshold: float = 0.8) -> ObjectMatch | None
```

Wait on the host until a template appears or `timeout_ms` elapses. This call is kept for existing macros. Prefer [`find_object_retry`](#find_object_retry): discrete tries with a flat pause read plainly and behave identically on every host.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `image_id` | `int \| ImageRef` | required | Image Library ID, or an `ImageRef` |
| `timeout_ms` | `int` | `10000` | Maximum wait in milliseconds |
| `threshold` | `float` | `0.8` | Match confidence from 0.0 to 1.0 |

**Returns:** an `ObjectMatch` when the template appears in time, otherwise `None`.

**Raises:** `ImageNotFoundError`, `TypeError`, or another `RPCError` subclass.

```python
import mas

images = mas.images({"home_screen": 456})
mas.click(150, 300)
home = mas.wait_for_object(images.home_screen, timeout_ms=30000)
if home is None:
    raise RuntimeError("Home screen did not load")
```

## Image registry

Template images do not live in your project folder. They live in your Image Library in the cloud, where each image has a numeric ID. You could pass raw integers to every vision call, but that scatters meaningless numbers through the code. Declare the IDs once with `images` and refer to them by name. The publisher reads that call to know which images to bundle with the macro, so keep every image in one `images` call near the top of `src/app.py`. When a script uses `images` and still passes a raw integer somewhere, the SDK prints a hint on exit listing the IDs to add.

### images

```python
def images(mapping: dict[str, int]) -> ImageMap
```

Declare the image IDs your script uses under readable names.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `mapping` | `dict[str, int]` | required | Name to Image Library ID; names must be valid Python identifiers |

**Returns:** an `ImageMap` with one `ImageRef` per entry, reached by dot access.

**Raises:** `TypeError` when a name is not a string or an ID is not an integer; `ValueError` when a name is not a valid identifier.

```python
import mas

images = mas.images({
    "login_btn": 123,
    "home_screen": 456,
    "daily_reward": 789,
})
match = mas.find_object(images.login_btn)
```

### ImageRef

```python
class ImageRef
```

A reference to one registered image. `images` creates them; you do not construct one by hand. Any vision call accepts an `ImageRef` in place of an integer. It compares equal to another `ImageRef` with the same ID and to the plain integer, and `int(ref)` returns the ID.

| Field | Type | Default | Meaning |
|---|---|---|---|
| `id` | `int` | required | The Image Library ID |
| `name` | `str` | required | The name given in `images` |

```python
import mas

images = mas.images({"login_btn": 123})
ref = images.login_btn
print(ref)            # ImageRef('login_btn', id=123)
print(int(ref) == 123, ref == 123)
```

### ImageMap

```python
class ImageMap
```

The read-only container returned by `images`. Each key of the mapping becomes an attribute holding an `ImageRef`. Assigning a new attribute raises `AttributeError`; define every image in the `images` call instead.

| Method | Type | Default | Meaning |
|---|---|---|---|
| `get_all_ids()` | `list[int]` | none | Every registered ID, in declaration order |

```python
import mas

images = mas.images({"login_btn": 123, "home": 456})
print(images.get_all_ids())    # [123, 456]
print(images)                  # ImageMap(login_btn=123, home=456)
```

## Elements

The accessibility tree is the second way to see the screen. Where vision matches pixels, elements read the widget tree the app exposes to Android: resource IDs, text, content descriptions and bounds. The device side is one wire method, a hierarchy dump; matching, ranking and scrolling run inside the SDK, so every host that can dump the tree serves the whole API.

A selector is a plain dict. Every key is optional, and the same dict shape is what MAS Agent harvests as `<NAME>_SELECTOR` constants.

```python
{"rid": "com.app:id/claim", "text": "Claim", "desc": "Claim reward",
 "cls": "Button", "index": 0, "clickable": True}
```

| Key | Meaning |
|---|---|
| `rid` | Resource ID. Matches the full ID or its trailing `id/...` part. Outranks every other key |
| `desc` | Content description, case-insensitive; an exact match ranks above a contains match |
| `text` | Visible text, case-insensitive; exact above contains |
| `cls` | Class name, exact or suffix match. When `rid` already matched, a differing class does not veto the node |
| `index` | Which of the equally ranked matches to pick, from 0 |
| `clickable` | When `True`, only clickable nodes qualify |

Ranking is `rid` over `desc` over `text` over `cls`. The `match_count` field on a result says how many nodes matched at all: 1 is unambiguous, more means the pick was by rank and `index`.

The tri-state rule: a tree that cannot be read is never the same as an element that is absent. A game drawing into a canvas, a secure screen, a desktop host, a dump that failed or an RPC timeout all raise `ElementsUnavailable`. Catch it and fall back to vision or retry; never treat it as "already done". A readable tree with no match returns `None`.

The host answers from one of two channels. The rich dump carries text and content descriptions but needs the screen to hold still. The structural dump is fast and works while the screen animates, but it carries no text or descriptions. The SDK tells the host whether the selector needs text. When only the structural tree could be read and the selector offers nothing but `text` or `desc`, the call raises `ElementsUnavailable` with a message that says so; anchor such selectors on a resource ID, or use a vision asset for that screen.

### ElementMatch

```python
class ElementMatch
```

One matched element. `x` and `y` are the center, which is the tap target.

| Field | Type | Default | Meaning |
|---|---|---|---|
| `x` | `int` | required | Center x |
| `y` | `int` | required | Center y |
| `text` | `str` | `""` | The element's text |
| `desc` | `str` | `""` | The content description |
| `rid` | `str` | `""` | The resource ID |
| `cls` | `str` | `""` | The class name |
| `bounds` | `tuple[int, int, int, int]` | `(0, 0, 0, 0)` | `(x1, y1, x2, y2)` on screen |
| `checked` | `bool` | `False` | Checked state |
| `enabled` | `bool` | `True` | Enabled state |
| `clickable` | `bool` | `False` | Whether the node is clickable |
| `scrollable` | `bool` | `False` | Whether the node scrolls |
| `match_count` | `int` | `1` | How many nodes matched the selector |

The `center` property returns `(x, y)` as `Coordinates`, mirroring `ObjectMatch`.

```python
import mas

btn = mas.find_element({"text": "Start"})
if btn:
    print(btn.rid, btn.bounds, btn.match_count)
    mas.click(*btn.center)
```

### dump_hierarchy

```python
def dump_hierarchy() -> str
```

Return the current screen's accessibility tree as uiautomator XML. Useful while authoring, to see which resource IDs and texts exist.

**Returns:** the XML as a string; empty when the host returned nothing.

**Raises:** `ElementsUnavailable` when this host cannot provide a dump.

```python
import mas

xml = mas.dump_hierarchy()
print(xml[:500])
```

### host_capabilities

```python
def host_capabilities() -> list[str]
```

The RPC methods the connected host dispatches. Use it to prove a host's surface instead of assuming it: a method one host wires and another lacks fails mid-run with `MethodNotFoundError`.

**Returns:** a list of method names; an empty list on older hosts that do not answer.

**Raises:** nothing; `MethodNotFoundError` is swallowed and yields `[]`.

```python
import mas

methods = mas.host_capabilities()
if "dump_hierarchy" in methods:
    btn = mas.find_element({"rid": "com.app:id/start"})
```

### find_element

```python
def find_element(selector: dict) -> ElementMatch | None
```

Find one element on the current screen by selector and return the best-ranked match.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `selector` | `dict` | required | Keys `rid`, `text`, `desc`, `cls`, `index`, `clickable` as described above |

**Returns:** an `ElementMatch`, or `None` when the tree is readable but nothing matches.

**Raises:** `ElementsUnavailable` when the tree cannot describe this screen.

```python
import mas

try:
    btn = mas.find_element({"rid": "com.app:id/start"})
except mas.ElementsUnavailable:
    btn = None
if btn:
    mas.click(*btn.center)
```

### find_elements

```python
def find_elements(selector: dict, *, max_matches: int = 10) -> list[ElementMatch]
```

Every element matching the selector, best-ranked first.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `selector` | `dict` | required | The selector dict |
| `max_matches` | `int` | `10` | Maximum results; values below 1 count as 1 |

**Returns:** a list of `ElementMatch`; empty when nothing matches.

**Raises:** `ElementsUnavailable` when the tree cannot describe this screen.

```python
import mas

rows = mas.find_elements({"cls": "TextView", "clickable": True}, max_matches=5)
for row in rows:
    print(row.text, row.center)
```

### find_element_retry

```python
def find_element_retry(selector: dict, *, total_tries: int = 3, time_sleep: float = 2.0) -> ElementMatch | None
```

`find_element` with discrete retries, mirroring `find_object_retry`. A transiently unreadable tree counts as a failed attempt; `ElementsUnavailable` propagates only when the final attempt still cannot read the tree. One flaky dump never fails a run, and a treeless screen still refuses to look like "element absent".

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `selector` | `dict` | required | The selector dict |
| `total_tries` | `int` | `3` | Attempts before giving up |
| `time_sleep` | `float` | `2.0` | Seconds between failed attempts |

**Returns:** the first `ElementMatch`, or `None`.

**Raises:** `ElementsUnavailable` when the last attempt could not read the tree.

```python
import mas

claim = mas.find_element_retry({"rid": "com.app:id/claim"}, total_tries=5, time_sleep=1.0)
if claim:
    mas.click(*claim.center)
```

### read_element

```python
def read_element(selector: dict) -> str
```

The text of the best-matching element: `text` first, then the content description. This is an exact string from the UI toolkit, where OCR would approximate.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `selector` | `dict` | required | The selector dict |

**Returns:** the string, or `""` when nothing matches.

**Raises:** `ElementsUnavailable` when the tree cannot describe this screen.

```python
import mas

balance = mas.read_element({"rid": "com.app:id/gold_balance"})
if balance.replace(",", "").isdigit():
    print(int(balance.replace(",", "")))
```

### scroll_to_element

```python
def scroll_to_element(selector: dict, *, container: dict | None = None, max_swipes: int = 8) -> ElementMatch | None
```

Scroll until the selector matches or the swipes run out. Each step swipes inside the container's bounds (or the biggest scrollable element, or the visible extent of the tree) from 75 percent of its height to 25 percent, then waits for the fling to settle before the next dump.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `selector` | `dict` | required | The element to reach |
| `container` | `dict \| None` | `None` | Selector for the scrolling container; `None` picks the biggest scrollable element |
| `max_swipes` | `int` | `8` | Swipes to spend; the screen is checked once more after the last swipe |

**Returns:** the `ElementMatch` once visible, or `None`.

**Raises:** `ElementsUnavailable` when the tree cannot describe this screen.

```python
import mas

row = mas.scroll_to_element({"text": "Advanced settings"}, max_swipes=6)
if row:
    mas.click(*row.center)
```

### read_page

```python
def read_page(container: dict | None = None, *, fields: list[str] | None = None, max_swipes: int = 8, row_cap: int = 200) -> list[dict]
```

Scroll through a list screen and collect its repeated rows. The call finds the container (by selector, or the biggest scrollable element), detects the repeated row structure, scrolls while collecting, and returns the rows deduplicated in reading order. It stops early when a full swipe surfaces nothing new.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `container` | `dict \| None` | `None` | Selector for the list container; `None` picks the biggest scrollable element |
| `fields` | `list[str] \| None` | `None` | Cell keys to keep out of `text`, `desc`, `rid`, `center`; `text` is always kept |
| `max_swipes` | `int` | `8` | Scroll budget, clamped to 0 to 20 |
| `row_cap` | `int` | `200` | Stop after this many rows, clamped to 1 to 500 |

**Returns:** a list of `{"cells": [...]}` dicts. Each cell is `{"text", "desc", "rid", "center"}` for one content-bearing element of the row, in document order.

**Raises:** `ElementsUnavailable` when the tree cannot describe this screen.

```python
import mas

rows = mas.read_page(fields=["text", "center"], max_swipes=10)
for row in rows:
    print([cell["text"] for cell in row["cells"]])
```

## App

Launch, close and inspect apps by package name. Package names look like `com.android.settings`.

### open_app

```python
def open_app(package_name: str, timeout_ms: int = 2000) -> None
```

Launch an app by package name.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `package_name` | `str` | required | The Android package identifier |
| `timeout_ms` | `int` | `2000` | How long to wait for the launch, in milliseconds |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas

mas.open_app("com.android.settings")
mas.open_app("com.android.chrome", timeout_ms=5000)
```

### close_app

```python
def close_app(package_name: str) -> None
```

Force stop an app.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `package_name` | `str` | required | The package to stop |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas

mas.open_app("com.example.game")
mas.log("automation runs here")
mas.close_app("com.example.game")
```

### get_current_app

```python
def get_current_app() -> str
```

The package name of the foreground app.

**Returns:** the package name as a string.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas
import time

mas.open_app("com.android.chrome")
while "chrome" not in mas.get_current_app().lower():
    time.sleep(0.5)
print("Chrome is active")
```

### get_app_state

```python
def get_app_state(package_name: str) -> int
```

The state of an app as an integer. Compare it with the module constants.

| Constant | Value | Meaning |
|---|---|---|
| `mas.RUNNING_IN_FOREGROUND` | `4` | Visible and focused |
| `mas.RUNNING_IN_BACKGROUND` | `3` | Running, not visible |
| `mas.RUNNING_IN_BACKGROUND_SUSPENDED` | `2` | In the background and frozen |
| `mas.NOT_RUNNING` | `1` | Installed but not running |
| `mas.NOT_INSTALLED` | `0` | Not on the device |

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `package_name` | `str` | required | The package to check, or a desktop application name on a desktop host |

**Returns:** one of the five integers above.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas

if mas.get_app_state("com.example.game") == mas.NOT_RUNNING:
    mas.open_app("com.example.game")
elif mas.get_app_state("com.example.game") == mas.NOT_INSTALLED:
    mas.log("Game is not installed", level="error")
```

### is_app_focused

```python
def is_app_focused(package_name: str) -> bool
```

Whether the app currently has input focus, according to the Android window manager. A good guard before a tap or typing.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `package_name` | `str` | required | The package to check |

**Returns:** `True` when the app is focused, otherwise `False`.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas
import time

mas.open_app("com.example.game")
while not mas.is_app_focused("com.example.game"):
    time.sleep(0.5)
```

## Device

Facts about the device and the computer the macro runs on.

### get_screen_size

```python
def get_screen_size() -> ScreenSize
```

The device screen dimensions in pixels. Use it to compute coordinates so a macro survives a resolution change.

**Returns:** a `ScreenSize` with `width` and `height`.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas

size = mas.get_screen_size()
mas.click(size.width // 2, size.height // 2)    # tap the center
```

### get_device_info

```python
def get_device_info() -> DeviceInfo
```

Details about the bound device.

**Returns:** a `DeviceInfo` with `id`, `name`, `type`, `screen_width`, `screen_height` and `connected`.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas

info = mas.get_device_info()
print(f"{info.name} ({info.type}) {info.screen_width}x{info.screen_height}")
```

### get_host_machine_id

```python
def get_host_machine_id() -> str
```

A stable UUID for the computer the macro runs on. It is generated once, survives restarts and is shared by every macro on that machine. It is the default `machine_id` for storage, so you rarely call it yourself.

**Returns:** the UUID as a string.

**Raises:** `RPCError` when the host returns no usable ID. The SDK refuses to fall back to an empty string, because storage is keyed by `(machine_id, port, task_name)` and an empty ID would corrupt every later save.

```python
import mas

machine_id = mas.get_host_machine_id()
print(machine_id)    # 550e8400-e29b-41d4-a716-446655440000
```

## Storage

`save` and `retrieve` give a macro memory across runs: a streak counter, a checkpoint, a "last processed" marker. Data is any JSON-serializable dict. Every entry is keyed by the triple `(machine_id, port, task_name)`, so two emulators running the same macro keep separate state. Leave `machine_id` and `port` at their defaults; they resolve to this computer and the current device. Passing a `port` that is not the current device's port raises `ValueError`.

### save

```python
def save(task_name: str, data: Dict[str, Any], machine_id: Optional[str] = None, port: int = 0) -> bool
```

Persist a dict for a task. Saving the same key again updates the entry rather than duplicating it.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `task_name` | `str` | required | Identifier for the task or script |
| `data` | `Dict[str, Any]` | required | JSON-serializable data |
| `machine_id` | `Optional[str]` | `None` | Defaults to `get_host_machine_id()` |
| `port` | `int` | `0` | `0` means the current device port |

**Returns:** `True` when the save succeeded.

**Raises:** `ValueError` when `data` is not JSON-serializable or `port` does not match the current device; `RPCError` when the machine ID cannot be read.

```python
import mas

state = mas.retrieve("daily_login")
streak = state.get("streak", 0) + 1
mas.save("daily_login", {"streak": streak, "last_run": "2026-09-04"})
```

### retrieve

```python
def retrieve(task_name: str, machine_id: Optional[str] = None, port: int = 0) -> Dict[str, Any]
```

Read an entry back.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `task_name` | `str` | required | Task identifier |
| `machine_id` | `Optional[str]` | `None` | Defaults to `get_host_machine_id()` |
| `port` | `int` | `0` | `0` means the current device port |

**Returns:** the stored dict, or an empty dict when nothing was stored.

**Raises:** `ValueError` when `port` does not match the current device; `RPCError` when the machine ID cannot be read.

```python
import mas

state = mas.retrieve("harvest")    # {} on the first run
last_field = state.get("last_field", 0)
```

### retrieve_all

```python
def retrieve_all(task_name: str) -> List[Dict[str, Any]]
```

Every entry stored under a task name, across all machines and ports. Use it to roll up results from many devices running the same macro.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `task_name` | `str` | required | Task identifier |

**Returns:** a list of dicts, each with `machine_id`, `port`, `data` and `updated_at`.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas

entries = mas.retrieve_all("harvest")
total = sum(e["data"].get("collected", 0) for e in entries)
mas.log(f"Collected {total} across {len(entries)} devices")
```

### clear

```python
def clear(task_name: str, machine_id: Optional[str] = None, port: int = 0) -> bool
```

Delete a stored entry.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `task_name` | `str` | required | Task identifier |
| `machine_id` | `Optional[str]` | `None` | Defaults to `get_host_machine_id()` |
| `port` | `int` | `0` | `0` means the current device port |

**Returns:** `True` when the entry was cleared.

**Raises:** `ValueError` when `port` does not match the current device; `RPCError` when the machine ID cannot be read.

```python
import mas

if mas.retrieve("daily_login").get("streak", 0) > 30:
    mas.clear("daily_login")
```

### get_current_device_port

```python
def get_current_device_port() -> int
```

The port of the device this run is bound to, for example `5556` for `emulator-5556`. The value is fetched once and cached. Useful for labelling per-device output.

**Returns:** the port as an integer.

**Raises:** `RPCError` when no device is connected.

```python
import mas

port = mas.get_current_device_port()
mas.log(f"Running on port {port}")
```

## Utilities and webhooks

Logging, the clipboard, custom webhook events, and the JSON-RPC client that every call uses underneath.

### log

```python
def log(message: str, level: Union[LogLevel, str] = LogLevel.INFO) -> None
```

Send a leveled line to the run console in Studio. Unlike `print`, the line carries a severity and is surfaced in the app's log view. No device is required.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `message` | `str` | required | The text to log |
| `level` | `Union[LogLevel, str]` | `LogLevel.INFO` | A `LogLevel`, or `"debug"`, `"info"`, `"warning"`, `"error"` |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

mas.log("Run started")
mas.log("retries running low", level="warning")
mas.log("step failed", level=mas.LogLevel.ERROR)
```

### get_clipboard

```python
def get_clipboard() -> str
```

Read the device clipboard as text.

**Returns:** the clipboard contents.

**Raises:** an `RPCError` subclass when the host reports a failure.

```python
import mas

mas.click(300, 500, delay_ms=1500)    # select text in the app
mas.click(400, 300)                   # tap the app's Copy control
copied = mas.get_clipboard()
print(copied)
```

### webhook

```python
def webhook(event: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]
```

Send a custom webhook event to your configured endpoints. Use it to signal things only the script knows: a level reached, a captcha hit, an inventory full. MAS's servers deliver the event, so it is signed, retried on failure and recorded in the delivery log next to `macro.started` and `macro.failed`. Your event name is namespaced under `custom.`, so `"level.reached"` arrives as `custom.level.reached`; a script can never impersonate a lifecycle event. The run and device are attached automatically. Set up an endpoint first on the **Webhooks** page of the app, subscribed to `custom.*` or `*`; see [Webhooks](/docs/webhooks).

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `event` | `str` | required | Letters, numbers, dots, dashes and underscores; 64 characters or fewer |
| `data` | `Optional[Dict[str, Any]]` | `None` | JSON-serializable details, up to 50 keys |

**Returns:** a dict with `event_id` (the receiver's dedupe key), `event_type` (the namespaced name) and `queued` (how many endpoints it went to). `queued` of `0` is not an error; no endpoint is subscribed to this event.

**Raises:** `RuntimeError` when the event could not be queued.

```python
import mas

result = mas.webhook("level.reached", {"level": 40, "account": "alt-3"})
print(result["event_type"], result["queued"])
```

### RPCClient

```python
class RPCClient
```

The JSON-RPC 2.0 client that every `mas.*` call uses. You rarely construct one yourself; `get_client` returns the shared instance. The constructor discovers the transport from the environment: `MAS_RPC_SOCKET` (a unix socket, used inside the cloud sandbox) takes precedence over TCP; otherwise `MAS_RPC_HOST` (default `localhost`) and `MAS_RPC_PORT`, then the port file the app writes, and finally `ConnectionError` when no port can be found. On connect it registers the session from `MAS_SESSION_TOKEN`, or binds a device from `MAS_DEVICE_ID` when a script was started outside Studio.

```python
def __init__(self, host: Optional[str] = None, port: Optional[int] = None, connect_timeout: float = DEFAULT_CONNECT_TIMEOUT, read_timeout: float = DEFAULT_READ_TIMEOUT)
```

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `host` | `Optional[str]` | `None` | RPC host; defaults to `MAS_RPC_HOST` or `localhost` |
| `port` | `Optional[int]` | `None` | RPC port; defaults to `MAS_RPC_PORT`, then the port file |
| `connect_timeout` | `float` | `DEFAULT_CONNECT_TIMEOUT` (`5.0`) | Seconds to wait for the connection |
| `read_timeout` | `float` | `DEFAULT_READ_TIMEOUT` (`30.0`) | Seconds to wait for a response |

Methods:

| Method | Returns | Meaning |
|---|---|---|
| `connect()` | `None` | Open the socket and register the session or device; a no-op when already connected |
| `disconnect()` | `None` | Close the socket |
| `call(method: str, params: Dict[str, Any])` | `Any` | Send one request and return its `result`; raises the mapped `RPCError` subclass on an error response |

The client is a context manager: `with RPCClient() as client:` connects on entry and disconnects on exit. Calls are serialized with a lock, so one client is safe to share across threads. A read that exceeds `read_timeout` raises `RPCError` and marks the client disconnected; the next call reconnects.

```python
import mas
from mas import RPCClient

client = RPCClient(read_timeout=60.0)
try:
    methods = client.call("host_capabilities", {})
    print(methods.get("methods", []))
finally:
    client.disconnect()
```

### get_client

```python
def get_client() -> RPCClient
```

Get or create the global `RPCClient` that the SDK functions share.

**Returns:** the shared `RPCClient`.

**Raises:** `ConnectionError` on first use when no RPC port can be discovered.

```python
import mas

client = mas.get_client()
size = client.call("get_screen_size", {})
print(size["width"], size["height"])
```

## Runtime UI (mas.ui)

`mas.ui` drives the runtime dashboard, the live panel shown next to a running macro. You design the dashboard in the UI Builder as a `.uibrt` file, give every widget a name in the Inspector, and address it by that name from the script. A name that does not exist in the dashboard updates nothing and raises nothing, so a typo is the most common dashboard bug. Each call is one round trip to the app; wrap several updates in `batch` to send them together. See [Runtime UI](/docs/sdk/ui) for the widget-by-widget guide and [UI Builder](/docs/ui-builder) for the designer.

| Widget | Call |
|---|---|
| Label, or the caption of a progress bar or button | `set_text` |
| Progress bar | `set_progress` |
| Chart | `add_data_point`, `set_chart_data`, `clear_chart` |
| Text area | `append_text`, `set_textarea`, `clear_text` |
| Table | `set_table_data`, `append_table_row`, `clear_table`, `cell_button`, `set_cell_button_enabled` |
| Button (read) | `wait_for_event`, `on_click` |
| Text input (read) | `get_input_value`, `on_change`, `wait_for_event` |

### UIEvent

```python
class UIEvent
```

A user interaction reported by `wait_for_event` or dispatched to a handler. It is a frozen dataclass.

| Field | Type | Default | Meaning |
|---|---|---|---|
| `widget_id` | `str` | required | The widget's name from the `.uibrt`, or a `cell_button` id |
| `event_type` | `str` | required | `"click"` or `"change"` |
| `value` | `Any` | required | The current value for a change; `None` for a click |

```python
import mas

event = mas.ui.wait_for_event(timeout=60)
if event and event.widget_id == "start_btn" and event.event_type == "click":
    print("Start pressed")
```

### set_text

```python
def set_text(name: str, text: str) -> None
```

Set the text of a label, a text area, a button, or the caption of a progress bar. The value is converted with `str`.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `name` | `str` | required | The widget name |
| `text` | `str` | required | The new text |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

mas.ui.set_text("status", "Logging in...")
mas.ui.set_text("status", f"Step {3}/{10}")
```

### set_progress

```python
def set_progress(name: str, value: Union[int, float]) -> None
```

Set the value of a progress bar. The app clamps it to the widget's `[min, max]`.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `name` | `str` | required | The widget name |
| `value` | `Union[int, float]` | required | The new value |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

for i in range(101):
    mas.ui.set_progress("main_bar", i)
```

### add_data_point

```python
def add_data_point(name: str, value: Union[int, float], label: str, series: Optional[str] = None) -> None
```

Append one point to a streaming chart. The app drops points beyond the widget's `maxDataPoints`.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `name` | `str` | required | The chart name |
| `value` | `Union[int, float]` | required | The y value |
| `label` | `str` | required | The x label |
| `series` | `Optional[str]` | `None` | Series name for grouped charts |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

mas.ui.add_data_point("cpu_chart", value=42.5, label="t=12", series="CPU")
mas.ui.add_data_point("cpu_chart", value=61.0, label="t=13", series="CPU")
```

### set_chart_data

```python
def set_chart_data(name: str, data: List[Dict[str, Any]]) -> None
```

Replace a chart's whole dataset. Each entry needs `label` (a string) and `value` (a number); `series` is optional.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `name` | `str` | required | The chart name |
| `data` | `List[Dict[str, Any]]` | required | The points |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

mas.ui.set_chart_data("scores", [
    {"label": "Gold", "value": 1250},
    {"label": "XP", "value": 45000},
])
```

### clear_chart

```python
def clear_chart(name: str) -> None
```

Remove every point from a chart.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `name` | `str` | required | The chart name |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

mas.ui.clear_chart("scores")
```

### append_text

```python
def append_text(name: str, line: str) -> None
```

Append a line to a text area. The panel auto-scrolls, and lines beyond the widget's `maxLines` drop off the top. The SDK keeps a local copy of the text so it can compose the next append without a round trip.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `name` | `str` | required | The text area name |
| `line` | `str` | required | The line to add |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

mas.ui.append_text("log", "Run started")
mas.ui.append_text("log", "Step 1 complete")
```

### set_textarea

```python
def set_textarea(name: str, text: str) -> None
```

Replace the whole content of a text area.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `name` | `str` | required | The text area name |
| `text` | `str` | required | The new content |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

mas.ui.set_textarea("log", "fresh contents")
```

### clear_text

```python
def clear_text(name: str) -> None
```

Clear a text area.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `name` | `str` | required | The text area name |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

mas.ui.clear_text("log")
```

### set_table_data

```python
def set_table_data(name: str, rows: List[Dict[str, Any]]) -> None
```

Replace every row of a table. Each row is a dict whose keys match the widget's `columns`.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `name` | `str` | required | The table name |
| `rows` | `List[Dict[str, Any]]` | required | The rows |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

mas.ui.set_table_data("results", [
    {"Name": "Gold", "Value": "1,250", "Status": "OK"},
    {"Name": "Stone", "Value": "12", "Status": "low"},
])
```

### append_table_row

```python
def append_table_row(name: str, row: Dict[str, Any]) -> None
```

Append one row to a table. The SDK keeps the rows locally and re-sends the full table.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `name` | `str` | required | The table name |
| `row` | `Dict[str, Any]` | required | Keys matching the widget's columns |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

mas.ui.append_table_row("results", {"Name": "Wood", "Value": "300", "Status": "OK"})
```

### clear_table

```python
def clear_table(name: str) -> None
```

Remove every row from a table.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `name` | `str` | required | The table name |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

mas.ui.clear_table("results")
```

### cell_button

```python
def cell_button(*, id: str, label: str, variant: str = "default", disabled: bool = False) -> Dict[str, Any]
```

Build a button to place inside a table cell. Put the returned dict in a row passed to `set_table_data` or `append_table_row`. A click emits a UI event whose `widget_id` equals `id`, so the script handles it with the same `on_click` or `wait_for_event` channel as a standalone button. All parameters are keyword-only.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `id` | `str` | required | The event id for this button |
| `label` | `str` | required | The button text |
| `variant` | `str` | `"default"` | `"default"`, `"outline"` or `"destructive"` |
| `disabled` | `bool` | `False` | Start disabled |

**Returns:** a dict describing the button.

**Raises:** `ValueError` when `variant` is not one of the three values.

```python
import mas

mas.ui.append_table_row("orders", {
    "Name": "Gold",
    "Action": mas.ui.cell_button(id="refund_gold", label="Refund", variant="destructive"),
})
mas.ui.on_click("refund_gold", lambda: mas.log("Refund requested"))
mas.ui.start_listener()
```

### set_cell_button_enabled

```python
def set_cell_button_enabled(table_name: str, button_id: str, enabled: bool) -> None
```

Enable or disable a button cell in a table that was already rendered. The SDK finds the cell by `button_id` in its local copy of the table, flips the flag and re-sends the table. Nothing happens when no cell has that id or the flag already matches.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `table_name` | `str` | required | The table name |
| `button_id` | `str` | required | The `id` given to `cell_button` |
| `enabled` | `bool` | required | `True` to enable, `False` to disable |

**Returns:** `None`.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

mas.ui.set_cell_button_enabled("orders", "refund_gold", False)
```

### wait_for_event

```python
def wait_for_event(timeout: float = 30.0) -> Optional[UIEvent]
```

Block until the user clicks a button or changes an input, or until `timeout` seconds pass. This is the blocking model: the script stops and waits.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `timeout` | `float` | `30.0` | Seconds to wait |

**Returns:** a `UIEvent`, or `None` when the timeout elapsed with no event.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

mas.ui.set_text("status", "Press Start when the game is on the home screen")
event = mas.ui.wait_for_event(timeout=120)
if event is None or event.widget_id != "start_btn":
    raise SystemExit(1)
```

### get_input_value

```python
def get_input_value(name: str) -> Optional[str]
```

The most recent value typed into an input widget. No listener is needed.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `name` | `str` | required | The input widget name |

**Returns:** the value, or `None` when the widget has none.

**Raises:** an `RPCError` subclass when the app cannot be reached.

```python
import mas

query = mas.ui.get_input_value("search_box")
if query:
    mas.input_text(query)
```

### on_click

```python
def on_click(name: str, handler: Callable[[], None]) -> None
```

Register a callback for clicks on a button or a `cell_button`. The callback fires only while the listener thread runs; call `start_listener` once after registering every handler. Registering a name again replaces the previous handler.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `name` | `str` | required | The button name or cell button id |
| `handler` | `Callable[[], None]` | required | Called with no arguments on the listener thread |

**Returns:** `None`.

**Raises:** nothing.

```python
import mas

stop = False

def on_stop():
    global stop
    stop = True

mas.ui.on_click("stop_btn", on_stop)
mas.ui.start_listener()
```

### on_change

```python
def on_change(name: str, handler: Callable[[Any], None]) -> None
```

Register a callback for value changes on an input widget. The callback receives the new value as its only argument and fires only while the listener thread runs.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `name` | `str` | required | The input widget name |
| `handler` | `Callable[[Any], None]` | required | Called with the new value on the listener thread |

**Returns:** `None`.

**Raises:** nothing.

```python
import mas

def on_delay_change(value):
    mas.log(f"Delay set to {value}")

mas.ui.on_change("delay_input", on_delay_change)
mas.ui.start_listener()
```

### start_listener

```python
def start_listener() -> None
```

Start the daemon thread that dispatches UI events to `on_click` and `on_change` handlers. It is idempotent: a second call while the thread is alive does nothing. The thread opens its own RPC connection so its long waits never block `mas.*` calls on the main thread, and it is a daemon, so it does not keep the interpreter alive after the script returns. A handler that raises is caught and its traceback printed; the listener keeps running.

**Returns:** `None`, immediately.

**Raises:** `ConnectionError` when the listener's connection cannot be opened.

```python
import mas

paused = False

def toggle():
    global paused
    paused = not paused

mas.ui.on_click("pause_btn", toggle)
mas.ui.start_listener()
```

### stop_listener

```python
def stop_listener() -> None
```

Signal the listener thread to exit and close its connection. Mostly useful in tests; a finished script does not need it.

**Returns:** `None`.

**Raises:** nothing.

```python
import mas

mas.ui.start_listener()
mas.ui.set_text("status", "Running")
mas.ui.stop_listener()
```

### batch

```python
def batch() -> Iterator[None]
```

A context manager that coalesces every update inside the `with` block into one RPC call. This is the difference between a smooth dashboard and a flickering one. Nesting is not supported. The buffer is process-global, not thread-local: updates from other threads while a batch is open are pulled into it, so finish the batch before letting listener handlers fire their own updates.

**Returns:** a context manager; the block yields `None`.

**Raises:** `RuntimeError` when a batch is opened inside another batch.

```python
import mas

with mas.ui.batch():
    mas.ui.set_text("status", "Step 3/10")
    mas.ui.set_progress("main_bar", 30)
    mas.ui.add_data_point("cpu_chart", value=42, label="t=3", series="CPU")
```

## Exceptions

Rule of thumb: `None` means "not found, and that is fine", so handle it with `if`. An exception means something is wrong, so let it stop the macro unless you have a recovery. Every SDK error descends from `MASError`. Errors that come back from the app carry a JSON-RPC code and are raised as the matching `RPCError` subclass; an unknown code raises plain `RPCError`. `ElementsUnavailable` is the one exception that is not an RPC error. The [Errors](/docs/sdk/errors) page shows handling patterns.

```text
MASError
  RPCError                    .code, .message, .data
    ConnectionError           could not reach the app (code -1)
    ParseError                -32700
    InvalidRequestError       -32600
    MethodNotFoundError       -32601
    InvalidParamsError        -32602
    InternalError             -32603
    DeviceNotConnectedError   -32001
    DeviceNotFoundError       -32002
    CommandFailedError        -32003
    ImageNotFoundError        -32004
    TimeoutError              -32005
    AuthenticationError       -32010
    SubscriptionError         -32011
  ElementsUnavailable         the accessibility tree could not be read
```

`ConnectionError` and `TimeoutError` shadow the Python built-ins of the same name inside the `mas` namespace. Refer to them as `mas.ConnectionError` and `mas.TimeoutError` to keep the two apart.

### MASError

```python
class MASError(Exception)
```

The base class for every SDK error. Catch it to handle any SDK failure in one place.

```python
import mas

try:
    mas.open_app("com.example.game")
except mas.MASError as e:
    mas.log(f"SDK error: {e}", level="error")
    raise
```

### RPCError

```python
class RPCError(MASError)
```

Raised when an RPC call fails. It is the base class of every coded error below, and the class you get for a code that has no subclass, an invalid response, a socket error, or a response that took longer than the client's read timeout.

```python
def __init__(self, message: str, code: int = -1, data: Optional[Any] = None)
```

| Attribute | Type | Default | Meaning |
|---|---|---|---|
| `code` | `int` | `-1` | The JSON-RPC error code |
| `message` | `str` | required | The human-readable message |
| `data` | `Optional[Any]` | `None` | Extra data from the app |

`str(error)` renders as `RPC Error (<code>): <message>`.

```python
import mas

try:
    mas.click(100, 100)
except mas.RPCError as e:
    print(e.code, e.message, e.data)
```

### ConnectionError

```python
class ConnectionError(RPCError)
```

Raised when the SDK cannot reach the app: no RPC port could be discovered, the connection was refused, or connecting timed out. The message says to make sure MAS is running. Its `code` is `-1`.

```python
import mas

try:
    size = mas.get_screen_size()
except mas.ConnectionError:
    print("Start Macro Automation Studio, then run the script again")
```

### ParseError

```python
class ParseError(RPCError)
```

Raised when the app received invalid JSON (code `-32700`). The SDK builds every request itself, so this indicates a transport problem, not a script bug.

```python
import mas

try:
    mas.log("hello")
except mas.ParseError as e:
    print(e.message)
```

### InvalidRequestError

```python
class InvalidRequestError(RPCError)
```

Raised when the JSON-RPC request is malformed (code `-32600`).

```python
import mas

try:
    mas.get_current_app()
except mas.InvalidRequestError as e:
    print(e.message)
```

### MethodNotFoundError

```python
class MethodNotFoundError(RPCError)
```

Raised when the requested method does not exist on this host (code `-32601`). Check `host_capabilities` before relying on a method that not every host provides.

```python
import mas

try:
    xml = mas.dump_hierarchy()
except (mas.MethodNotFoundError, mas.ElementsUnavailable):
    xml = ""
```

### InvalidParamsError

```python
class InvalidParamsError(RPCError)
```

Raised when the method parameters are invalid (code `-32602`), for example a value outside the range the host accepts.

```python
import mas

try:
    mas.key_press(mas.KeyCode.DELETE, repeat=500)
except mas.InvalidParamsError as e:
    print(e.message)
```

### InternalError

```python
class InternalError(RPCError)
```

Raised when the app hits an internal error (code `-32603`).

```python
import mas

try:
    shot = mas.take_screenshot()
except mas.InternalError as e:
    mas.log(f"Host error: {e.message}", level="error")
    raise
```

### DeviceNotConnectedError

```python
class DeviceNotConnectedError(RPCError)
```

Raised when a device operation runs with no device bound to this connection (code `-32001`). The emulator stopped, the cloud device went away, or the script was started without a device.

```python
import mas

try:
    mas.click(540, 960)
except mas.DeviceNotConnectedError:
    mas.log("No device connected", level="error")
    raise SystemExit(1)
```

### DeviceNotFoundError

```python
class DeviceNotFoundError(RPCError)
```

Raised when the requested device does not exist or is not available (code `-32002`), for example when `MAS_DEVICE_ID` names a device the app cannot see.

```python
import mas

try:
    info = mas.get_device_info()
except mas.DeviceNotFoundError as e:
    print(e.message)
```

### CommandFailedError

```python
class CommandFailedError(RPCError)
```

Raised when a device command such as a click, swipe or screenshot fails to complete on the device (code `-32003`).

```python
import mas

try:
    mas.swipe((500, 1500), (500, 500))
except mas.CommandFailedError as e:
    mas.log(f"Swipe failed: {e.message}", level="warning")
```

### ImageNotFoundError

```python
class ImageNotFoundError(RPCError)
```

Raised when a template image cannot be used (code `-32004`): the ID does not exist in your Image Library, or the file cannot be loaded. A template that exists but does not appear on screen is not an error; `find_object` returns `None` for that.

```python
import mas

try:
    match = mas.find_object(999999)
except mas.ImageNotFoundError:
    mas.log("Image 999999 is not in your Image Library", level="error")
    raise
```

### TimeoutError

```python
class TimeoutError(RPCError)
```

Raised when an operation on the host exceeds its time budget (code `-32005`). Note that `wait_for_object` returns `None` on a timeout instead of raising; this error comes from the host when a command itself times out.

```python
import mas

try:
    text = mas.read_text(timeout_ms=5000)
except mas.TimeoutError:
    mas.log("OCR took longer than 5 seconds", level="warning")
```

### AuthenticationError

```python
class AuthenticationError(RPCError)
```

Raised when you are not logged in to MAS (code `-32010`). Log in and run the script again.

```python
import mas

try:
    mas.log("start")
except mas.AuthenticationError:
    print("Log in to Macro Automation Studio, then run the script again")
    raise SystemExit(1)
```

### SubscriptionError

```python
class SubscriptionError(RPCError)
```

Raised when an active subscription is required (code `-32011`). The subscription is verified once when a script starts, so this should not appear mid-run. If it does, the app may have been unable to confirm your subscription with the server, for example after a network problem; it does not necessarily mean the subscription has lapsed. See [Billing](/docs/billing).

```python
import mas

try:
    mas.get_screen_size()
except mas.SubscriptionError as e:
    print(e.message)
    raise SystemExit(1)
```

### ElementsUnavailable

```python
class ElementsUnavailable(MASError)
```

Raised when the accessibility tree cannot be read for this screen: a game or canvas surface, a secure screen, a host without dump support, a device-side dump failure, or a dump that did not arrive in time. It means the tree is unavailable, not that the element you looked for is absent. Catch it to fall back to vision or to retry later; never treat it as "already done". It descends from `MASError` directly, not from `RPCError`, and carries no code.

```python
import mas

images = mas.images({"claim": 321})
try:
    target = mas.find_element({"rid": "com.app:id/claim"})
except mas.ElementsUnavailable:
    target = mas.find_object_retry(images.claim)
if target:
    mas.click(*target.center)
```
