Quick Start

Install AppClaw globally and start automating in seconds.

Terminal
# Install
npm install -g @appclaw/cli

# Run with a natural language goal
appclaw "Open Settings and turn on Wi-Fi"

# Run a YAML flow
appclaw --flow my-test.yaml

# Interactive playground
appclaw --playground

For a full test project — config file, spec files, npm scripts — scaffold it with appclaw init or set it up manually below.

Scaffold a project with appclaw init

The fastest way to start. It asks a few questions (platform, LLM provider, agent mode — use the ↑/↓ arrow keys to choose), then writes a ready-to-run project. It only creates files and prints next steps: it never installs packages, never touches the network, and never prompts for your API key.

Terminal
# Scaffold into the current folder (or pass a directory name)
npx appclaw init
npx appclaw init my-app

# Skip the prompts with flags / defaults
npx appclaw init my-app --platform ios --provider anthropic --yes

It generates a complete Test Runner project:

Generated
my-app/
  appclaw.config.ts      # all 5 lifecycle hooks, wired to your answers
  tests/
    example.spec.ts      # a starter spec to run immediately
  .env.example           # full, commented reference of every setting
  .env                   # empty — paste your LLM_API_KEY here (gitignored)
  package.json           # "test" + "test:parallel" scripts, devDeps
  tsconfig.json
  .gitignore             # node_modules/, .appclaw/, .env
Non-destructive

Re-running init in an existing project skips files that already exist and merges package.json / .gitignore rather than overwriting them. Pass --force to overwrite.

Then finish setup and run:

Terminal
cd my-app
npm install
# paste your key into .env → LLM_API_KEY=...
npm test

Or set it up manually

Terminal
# 1. Install (project-local) — tsx loads the TypeScript specs/config
npm install -D appclaw tsx

# 2. Connect an emulator / device, then run all tests
npx appclaw test

# Run a single file, or filter by path substring
npx appclaw test tests/login.spec.ts
npx appclaw test login

Trigger it from package.json

The normal entry point is an npm script. appclaw test auto-discovers appclaw.config.ts in the working directory and runs the specs in its testDir — so the script is just the command plus any flags.

package.json
{
  "scripts": {
    "test": "appclaw test",                  // runs everything in testDir
    "test:parallel": "appclaw test --workers 2",
    "test:login": "appclaw test tests/login.spec.ts",
    "test:grep": "appclaw test --grep login"
  },
  "devDependencies": { "@appclaw/cli": "^1.9.3", "@appclaw/core": "^1.9.3", "@appclaw/runner": "^1.9.3", "tsx": "^4.21.0" }
}
Terminal
npm test                  # → appclaw test (reads appclaw.config.ts)
npm run test:parallel     # 2 devices at once
npm run test:login        # a single file

A minimal project layout:

Project
my-app/
  package.json          # "test": "appclaw test"
  appclaw.config.ts     # infra: nodes, concurrency, retries, llm, hooks (write once)
  tests/
    login.spec.ts       # your tests — no UDIDs, no ports
    checkout.spec.ts
TypeScript specs just work

Spec and config files are TypeScript. appclaw test registers a TS loader (tsx) automatically before importing them, so a plain "test": "appclaw test" script works — just keep tsx in your devDependencies. Pre-compiled .js specs run without it.

CLI Options

All the flags you can pass to appclaw.

Flag Description
Platform & Device
--platform <os> Target platform: android or ios
--device-type <type> iOS only: simulator or real
--device <name> Device name (partial match, e.g. "iPhone 17 Pro")
--udid <udid> Device UDID (skips the device picker)
Execution
--flow <file> Run a declarative YAML flow file
--env <name> Environment for variable/secret resolution
--env-path <path> Load a dotenv (.env) file into the environment for this run
--caps <path> JSON file of extra Appium capabilities merged into the session. Flat or platform-scoped (android/ios/common). Overrides CAPABILITIES_FILE. See Custom Capabilities.
--playground Launch the interactive REPL for building flows
--export [path] Write a replayable SDK vitest spec after a goal completes
--export-dir <dir> Directory for bare-filename exports (overrides EXPORT_DIR)
--record Record a goal execution for later replay
--replay <file> Replay a previously recorded session
--plan Decompose a complex goal into sub-goals
--json JSON output mode (for IDE extensions)
Explorer (Test Generation)
--explore <prd> Generate test flows from a PRD document
--num-flows <N> Number of flows to generate (default: 5)
--no-crawl Skip device crawling, use PRD only
--output-dir <dir> Output directory (default: generated-flows)
--max-screens <N> Max screens to crawl (default: 10)
--max-depth <N> Max navigation depth (default: 3)

Execution Modes

AppClaw has three distinct ways to automate mobile apps.

Agent Mode

Give AppClaw a goal in plain English. The AI agent takes a screenshot, reasons about what it sees, and decides what to tap, type, or swipe — step by step until the goal is complete.

Agent Mode
appclaw "Search for 'Appium 3.0' on YouTube and find the TestMu AI video"

YAML Flows

Define repeatable, version-controlled test flows in YAML. Each step is a natural language instruction — no element selectors, no brittle locators.

YAML Flow
appclaw --flow tests/youtube-search.yaml --env dev

Playground

An interactive REPL where you type one instruction at a time and see it execute immediately. Great for exploring an app and building flows interactively.

Playground
appclaw --playground --platform ios --device-type simulator

When you're happy with the recorded steps, type /export to save them. By default this writes an SDK vitest spec (.test.ts); pass a .yaml filename to export a YAML flow instead.

Exporting from the REPL
# In the REPL prompt:
/export                  # → SDK vitest spec (flow-<ts>.test.ts)
/export login.test.ts    # → SDK vitest spec
/export login.yaml       # → YAML flow

Bare-filename exports land in EXPORT_DIR (default .appclaw/exports). Override it per run with --export-dir, and load a custom .env with --env-path:

Playground with overrides
appclaw --playground --env-path path/to/.env --export-dir tests/generated

Designing YAML Flows

YAML flows are the heart of AppClaw's repeatable automation. Write your test steps in plain English — AppClaw figures out how to execute them on the device. No XPath, no accessibility IDs, no brittle selectors.

Key Idea

Each step is a natural language instruction like tap Login or wait for the home screen to be visible. AppClaw uses AI to find the right elements on screen.

Flat Format

The simplest YAML structure — a metadata header separated by --- from a flat list of steps.

settings-wifi.yaml
name: Turn on Wi-Fi
platform: android
---
- open Settings app
- tap Connections
- wait 1s
- tap Wi-Fi
- verify Wi-Fi is visible
- done

Metadata Fields

Field Description
name Display name for the flow
description Optional description of what the flow does
platform android or ios — fallback if no --platform CLI flag
appId App bundle/package ID for launchApp steps
env Environment name — resolves variables from .appclaw/env/<name>.yaml

Phased Format

For structured tests, organize your steps into three phases: setup, steps, and assertions. This gives clearer reporting and separates initialization from the actual test logic.

youtube-search.yaml
name: YouTube Search
description: Searches YouTube and verifies video results
platform: android
env: dev
---
setup:
  - open ${variables.app_name} app
  - wait until search icon is visible

steps:
  - click on search icon
  - type '${secrets.search_query}'
  - wait 3s
  - click on the first result from the list
  - wait for the search results to be visible
  - scroll down

assertions:
  - verify ${variables.expected_channel} is visible

Phases Explained

Phase Purpose
setup Initialization — launch the app, navigate to starting screen, dismiss popups. Failures here skip the test.
steps The main test actions — the interactions you're actually testing.
assertions Verification checks — confirm the expected outcome. You can also mix in actions here if needed.

Variables & Secrets

Keep your flows flexible and secure with variable interpolation.

Variables ${variables.X}

Loaded from environment files. Values appear in logs.

Secrets ${secrets.X}

Resolved from shell environment variables at runtime. Always shown as *** in logs. Use --env-path path/to/.env to load these from a dotenv file outside the working directory (values override the process environment).

Environment File

Create .appclaw/env/<name>.yaml in your project root:

.appclaw/env/dev.yaml
variables:
  app_name: youtube
  expected_channel: TestMu AI
  timeout: 30
  locale: en-US

Then reference it in your YAML header with env: dev, or pass --env dev on the CLI.

Inline Variables

For self-contained flows, embed variables directly in the YAML header:

Inline env block
name: Self-contained flow
env:
  variables:
    app_name: youtube
    search_term: appium 3.0
---
- open ${variables.app_name} app
- type ${variables.search_term}
Resolution Order

--env CLI flag wins over the YAML env: field, which wins over inline env: blocks. Secrets always come from shell environment variables.

Open / Close App

Launch an app by name, or close (terminate) it when you’re done. Closing uses appium-mcp’s terminate under the hood.

Natural language
# Open
- open YouTube
- launch Settings app

# Close — by name
- close YouTube
- close the YouTube app
- terminate Settings
- quit Chrome

# Close — the current foreground app
- close app
- close the app

App names resolve against the device’s installed apps (well-known names like YouTube work out of the box). Omitting the name closes whatever app is in the foreground.

Not an app close

close the dialog, close the keyboard, and similar UI dismissals are treated as taps, not app terminations — only close <app>/close the app (or terminate/quit/kill) end an app.

Tap / Click

Tap on an element by describing its label. AppClaw matches it against visible text and elements on screen.

Natural language
- tap Login
- click on the search icon
- press Submit
- select the first item
- choose English
- pick the blue option
- navigate to Settings
- toggle Dark Mode
- enable Notifications
- close the popup
- dismiss the dialog

All of these are equivalent — they find the element and tap it. Use whichever reads most naturally.

Structured YAML
- tap: "Login Button"

Proximity Selectors

When several elements share the same label — e.g. a “Login” tab, a “Login” nav item, and the actual LOGIN button — add a spatial qualifier to pick the right one by its position relative to another element. Works on both tap and type.

Natural language
- tap the login button below the password field
- tap the icon to the right of the title
- tap the arrow to the left of the heading
- tap the star above the rating
- click the checkbox next to Terms
- tap the submit button within the form
- type "secret" in the field below the email

The phrase after the target names the relation and the anchor element. Modeled on Taiko’s proximity selectors:

Phrase Relation Meaning
above / over above Target sits above the anchor
below / under / underneath below Target sits below the anchor
left of / to the left of toLeftOf Target is to the left of the anchor
right of / to the right of toRightOf Target is to the right of the anchor
near / next to / beside near Target is closest to the anchor (any direction)
within / inside within Target is contained inside the anchor (e.g. a form/card)
How it resolves

Among elements matching the target label, AppClaw keeps the interactive (clickable) candidates, then picks the one best satisfying the relation — ranked by distance for ties. If the anchor isn’t found or nothing satisfies the relation, the step fails loudly rather than tapping the wrong element.

Type Text

Type text into the currently focused field, or specify a target field.

Natural language
# Type into focused field
- type "hello world"
- enter text "user@example.com"

# Type into a specific field
- type "john@example.com" in email field
- enter "password123" into password field

# Search (types the text)
- search for "Appium 3.0"
- look for "restaurants nearby"
Structured YAML
- type: "hello world"

Wait / Pause

Pause execution for a fixed duration.

Natural language
- wait 3s
- wait 1.5 seconds
- sleep 500ms
- pause 2 sec
- wait               # defaults to 2 seconds
- wait a moment      # defaults to 2 seconds
Structured YAML
- wait: 3          # seconds

Wait Until

Wait dynamically until a condition is met. Polls the screen every 500ms up to a timeout (default 10s). Uses AI vision to understand the screen — you can describe what you expect to see in plain English.

Wait for something to appear
- wait until search icon is visible
- wait for the search results to be visible
- wait for the home screen to be loaded
- wait until "Welcome back" appears
- wait 15s until login button is visible  # custom timeout
Wait for something to disappear
- wait until loading spinner is gone
- wait for the popup to be hidden
- wait until progress bar disappeared
Wait for screen to stabilize
- wait until screen is loaded
- wait until screen is stable
- wait 5s until screen is ready
Structured YAML
# With custom timeout
- waitUntil: "Login button"
  timeout: 15

# Wait for element to disappear
- waitUntilGone: "Loading spinner"
  timeout: 20

# Screen loaded (DOM stability check)
- waitUntil: "screen loaded"
Smart Vision

When you write something descriptive like wait for the search results to be visible, AppClaw uses AI vision to understand the screen holistically — it checks whether results are actually shown, not just whether the literal words "search results" appear. You can describe what you expect to see naturally.

Scroll / Swipe

Scroll or swipe in any direction, optionally repeating multiple times or scrolling until an element is found.

Basic scroll / swipe
- scroll down
- scroll up 3 times
- swipe left
- swipe right 2 times
Scroll until found
# Scroll until an element appears
- scroll down until "Terms & Conditions" is visible
- scroll down 5 times to find "Accept"
- scroll down to see "Load More"
Structured YAML
- scrollAssert: "Terms & Conditions"
  direction: down
  maxScrolls: 5

Drag / Slider

Drag one element to another — sliders, carousels, reorderable lists, and any drag-and-drop interaction. Requires vision mode (AGENT_MODE=vision).

Natural language
- drag the green circle slider to the +100 mark
- slide the price handle to +80
- move the volume knob to maximum
Structured YAML — shorthand
# "drag: from to to"
- drag: "green circle slider to +100 mark"
Structured YAML — explicit from/to
- drag:
    from: green circle slider
    to:   +100 mark
Vision required

Drag uses AI vision to locate both the source and target by visual description. Set AGENT_MODE=vision and VISION_LOCATE_PROVIDER=stark with a valid LLM_API_KEY.

Assert / Verify

Verify that something is visible on screen. Works with both literal text and visual/semantic descriptions via AI vision.

Natural language
- verify "Welcome back" is visible
- assert Dashboard is visible
- check that the login button is on the screen
- verify TestMu AI is visible
Structured YAML
- assert: "Welcome back"
- verify: "Dashboard"    # alias for assert
- check:  "Login button"  # alias for assert

Full Command Reference

Every supported step kind at a glance.

Kind Parameters Description
openApp query Open an app by name
launchApp Launch app defined in appId metadata
closeApp query? Close/terminate an app by name, or the current app when omitted
tap label Tap element by visible text/label
type text, target? Type text, optionally into a named field
enter Press Enter / Return key
back Press the Back button
home Press the Home button
wait seconds Pause for a fixed duration
waitUntil condition, text?, timeout Poll until visible/gone/screenLoaded
swipe direction, repeat? Swipe up/down/left/right
drag from, to Drag from one element to another (vision mode)
assert text Verify text or description is visible
scrollAssert text, direction, maxScrolls Scroll until text found
getInfo query Ask the AI a question about the screen
done message? Signal flow completion

Vision Modes

Control how AppClaw locates elements on screen.

Agent Mode AGENT_MODE

Value Behavior
dom Default. Uses the app's DOM/accessibility tree to find elements.
vision Uses AI vision (screenshots + LLM) as the primary strategy for all interactions.

Vision Mode VISION_MODE

Value Behavior
fallback Default. Try DOM first, fall back to vision if no match found.
always Skip DOM entirely, use vision for every interaction.
never DOM only. No vision fallback.

Environment Variables

All environment variables recognized by AppClaw. These are especially useful for CI/CD pipelines.

LLM Configuration

Variable Description
LLM_PROVIDER LLM provider: anthropic, openai, gemini, groq, ollama
LLM_API_KEY API key for the chosen provider
LLM_MODEL Specific model name to use
LLM_THINKING Extended thinking: on or off (default: on)
LLM_THINKING_BUDGET Max thinking tokens: 1–10000 (default: 128)
LLM_SCREENSHOT_MAX_EDGE_PX Downscale screenshots to this max edge (0 = disabled)

Device & Platform

Variable Description
PLATFORM Same as --platform flag
DEVICE_TYPE Same as --device-type flag
DEVICE_UDID Same as --udid flag
DEVICE_NAME Same as --device flag
APP_PATH Path or URL to an APK/IPA installed via appium:app at session start. Override per-flow via the app: YAML header.
CAPABILITIES_FILE JSON file of extra Appium capabilities merged into create_session. Same as --caps flag and the SDK capabilitiesFile option. See Custom Capabilities.

Vision

Variable Description
VISION_MODE always, fallback, or never
AGENT_MODE dom or vision
GEMINI_API_KEY Gemini API key for Stark vision — only needed when LLM_PROVIDER is not gemini and AGENT_MODE=vision. If provider is already Gemini, LLM_API_KEY is reused automatically.

Execution Tuning

Variable Description
MAX_STEPS Max steps per goal (default: 30)
STEP_DELAY Delay between steps in ms (default: 500)
WAIT_TIMEOUT Implicit wait (ms) for an element to be ready before each SDK action (default: 10000; 0 disables / fail-fast)
WAIT_INTERVAL Poll cadence (ms) for WAIT_TIMEOUT (default: 300)
MAX_ELEMENTS Max DOM elements to parse (default: 40)
MAX_HISTORY_STEPS Max action history retained (default: 10)
EXPORT_DIR Default directory for bare-filename exports / --export writes (default: .appclaw/exports). Overridden by --export-dir.
LOCATOR_CACHE_ENABLED SDK locator cache toggle: on or off (default: off). DOM mode only.
LOCATOR_CACHE_PATH Optional path for the SDK locator cache JSON file. Empty uses ~/.appclaw/locator-cache.json.
APPCLAW_MEMORY_NAMESPACE Namespace for memory-backed stores, including locator cache. Useful for isolating branches, CI lanes, or test suites.

MCP Connection

Variable Description
MCP_TRANSPORT stdio or sse (default: stdio)
MCP_HOST MCP server host (default: localhost)
MCP_PORT MCP server port (default: 8080)

Cloud Providers

Set CLOUD_PROVIDER=browserstack | saucelabs | lambdatest | custom to route sessions through a remote hub instead of a local device. Full variable reference: Cloud Providers → Environment variables.

Custom Capabilities

Sometimes the built-in capabilities aren't enough — you want to set appium:autoGrantPermissions, force a specific appium:automationName, or pre-install an APK. AppClaw accepts a JSON file of extra capabilities merged into create_session on every run.

Where to set it

  • CLI: --caps path/to/caps.json (interactive, YAML flow, --playground, --record — every mode).
  • Env var: CAPABILITIES_FILE=path/to/caps.json in .env or your shell.
  • SDK: new AppClaw({ capabilitiesFile: 'path/to/caps.json' }).

--caps wins over the env var when both are set.

Flat format

The simplest shape — a flat object of Appium capabilities. Applied to every session regardless of platform.

caps.json
{
  "appium:autoGrantPermissions": true,
  "appium:noReset": true,
  "appium:newCommandTimeout": 300
}

Platform-scoped format

When Android and iOS need different capabilities, split them under top-level android and ios keys. Optionally use common (alias: default, shared) for capabilities that apply to both.

caps.json
{
  "common": {
    "appium:newCommandTimeout": 300,
    "appium:noReset": true
  },
  "android": {
    "appium:app": "/path/to/MyApp.apk",
    "appium:automationName": "UiAutomator2",
    "appium:autoGrantPermissions": true
  },
  "ios": {
    "appium:app": "/path/to/MyApp.ipa",
    "appium:automationName": "XCUITest",
    "appium:autoAcceptAlerts": true
  }
}

The wrapper keys themselves (android, ios, common) are stripped before sending to Appium — they're config metadata, not W3C capabilities.

Precedence

When multiple sources set the same capability, later wins:

  1. Built-in defaults (MJPEG port, app from APP_PATH, etc.)
  2. Your capabilities file (top-level / common / platform-specific, in that order)
  3. Framework-managed caps (per-parallel-worker ports, pinned UDID from --udid)

So your file can override defaults, but framework values (parallel ports, device pinning) still take final precedence — concurrent workers won't collide even if your file sets the same key.

Strict platform check

If your file declares android and/or ios sections but doesn't cover the platform you're running on, AppClaw refuses to start with a clear error. This catches the common misconfig where you split caps by platform and forgot one. To opt out, drop the platform wrapper and use the flat format instead.

Notes

  • The file is loaded once per session. Editing it during a long run has no effect until the next session.
  • appium:app in the file takes precedence over the APP_PATH env var, which itself is overridden by a per-flow app: YAML header.
  • All values are sent through verbatim — AppClaw doesn't validate that appium:* keys are recognised by your driver. Typos will surface as Appium errors at session creation.
  • Cloud runs: capabilities from this file are merged into the remote session too, including provider-specific namespaces like bstack:options, sauce:options, or lt:options.

Cloud Providers

Run AppClaw on real iOS and Android devices in the cloud — no local device or emulator required. AppClaw ships built-in support for TestMuAI (formerly LambdaTest), Sauce Labs, and BrowserStack, plus a custom mode that points at any Appium-compatible hub (self-hosted grids, device farms, Selenium Grid).

Configuration is generic across providers: pick one via CLOUD_PROVIDER, then supply credentials, target device, and app through the same CLOUD_* variables. Provider-specific dashboard options (bstack:options, sauce:options, lt:options) go through CAPABILITIES_FILE — AppClaw doesn't wrap them individually.

Quick reference

Provider CLOUD_PROVIDER value Default hub Options namespace
TestMuAI (formerly LambdaTest) lambdatest mobile-hub.lambdatest.com/wd/hub lt:options
Sauce Labs saucelabs ondemand.{CLOUD_REGION}.saucelabs.com/wd/hub sauce:options
BrowserStack browserstack hub-cloud.browserstack.com/wd/hub bstack:options
Custom / self-hosted custom Supplied via CLOUD_SERVER_URL Whatever your grid reads

TestMuAI (formerly LambdaTest)

Example .env for the TestMuAI hub. Sauce Labs and BrowserStack use the same CLOUD_* variables — swap CLOUD_PROVIDER to saucelabs or browserstack, plug in that provider's credentials and app identifier, and add CLOUD_REGION for Sauce if you're not on us-west-1.

.env
# Enable TestMuAI (formerly LambdaTest)
CLOUD_PROVIDER=lambdatest

# Credentials (app.lambdatest.com → Profile → Access Key)
CLOUD_USERNAME=your_username
CLOUD_ACCESS_KEY=your_access_key

# Target device
PLATFORM=ios
CLOUD_DEVICE_NAME=iPhone 14
CLOUD_OS_VERSION=16

# Your app (upload via portal, copy the lt:// ID)
CLOUD_APP=lt://APP10xxxxxxxxxxxxxxxx

# Optional dashboard labels (mapped into lt:options.build/project)
CLOUD_BUILD_NAME=nightly-1234
CLOUD_PROJECT_NAME=Checkout Suite

Custom / self-hosted grid

Use CLOUD_PROVIDER=custom to point at any Appium-compatible hub — a self-hosted Selenium Grid node, an internal device farm, an Appium server running on another machine, or a third-party provider not listed above. AppClaw sends W3C caps verbatim; it doesn't inject a provider-specific options namespace.

.env
# Custom grid — you supply the whole hub URL
CLOUD_PROVIDER=custom
CLOUD_SERVER_URL=https://appium.example.com/wd/hub

# Auth: either embed in the URL (https://user:key@host/wd/hub) OR set separately
CLOUD_USERNAME=your_username
CLOUD_ACCESS_KEY=your_access_key

PLATFORM=android
CLOUD_DEVICE_NAME=Pixel 8
CLOUD_OS_VERSION=14
CLOUD_APP=https://storage.example.com/builds/app.apk
Overriding a known provider's hub

For BrowserStack / Sauce / LambdaTest you can override the built-in hub URL by setting CLOUD_SERVER_URL — useful for private endpoints, regional overrides, or Sauce datacenters not covered by CLOUD_REGION. Credentials are still injected from CLOUD_USERNAME and CLOUD_ACCESS_KEY unless the URL already carries basic auth.

Provider-specific options (bstack / sauce / lt)

AppClaw maps CLOUD_BUILD_NAME and CLOUD_PROJECT_NAME into the active provider's options namespace as a convenience (for BrowserStack it aliases both buildName/projectName and build/project). Everything else — video recording, network logs, tunneling, geo-location, project capacity — goes through a capabilities file:

caps.json
{
  "lt:options": {
    "video": true,
    "network": true,
    "deviceOrientation": "PORTRAIT",
    "idleTimeout": 300
  }
}

Load it via CLI or SDK the same way you do for local runs:

Terminal
appclaw --caps ./caps.json --flow flows/checkout.yaml

CLI usage

Once .env is configured, run AppClaw exactly as you would locally:

Terminal
# Run a natural-language goal on a cloud device
appclaw "Open the app and navigate to the checkout screen"

# Run a YAML flow on a cloud device
appclaw --flow flows/checkout.yaml

SDK usage

No SDK changes needed — the same AppClaw instance routes through the configured cloud provider automatically:

TypeScript
import { AppClaw } from '@appclaw/core';

// CLOUD_PROVIDER is read from .env automatically
const app = new AppClaw({
  provider:    'gemini',
  apiKey:      process.env.LLM_API_KEY,
  reportName:  'Checkout — Cloud',
});

await app.run('open the app');
await app.run('tap Add to Cart');
await app.run('tap Checkout');

await app.teardown();  // report saved to .appclaw/runs/

Environment variables

Variable Required Description
CLOUD_PROVIDER Yes browserstack, saucelabs, lambdatest, or custom. Empty (default) = local execution.
CLOUD_USERNAME Yes1 Cloud account username / user ID.
CLOUD_ACCESS_KEY Yes1 Cloud access key / token from the provider dashboard.
CLOUD_DEVICE_NAME Yes Cloud device to target, e.g. iPhone 15 Pro or Samsung Galaxy S24. Sent as appium:deviceName.
CLOUD_OS_VERSION Yes OS version, e.g. 17 (iOS) or 14 (Android). Sent as appium:platformVersion.
CLOUD_APP No App identifier — bs://… (BrowserStack), storage:filename=… or https://… (Sauce), lt://APP… (LambdaTest), or any URL the custom grid understands. Sent as appium:app.
CLOUD_SERVER_URL Yes2 Full Appium hub URL, e.g. https://appium.example.com/wd/hub. Required for custom; optional for known providers (overrides the built-in hub).
CLOUD_REGION No Sauce Labs datacenter region for the built-in hub URL. Default: us-west-1. Ignored by other providers.
CLOUD_BUILD_NAME No Build label shown in the provider dashboard.
CLOUD_PROJECT_NAME No Project label shown in the provider dashboard.

1 Not strictly required for custom when the credentials are embedded in CLOUD_SERVER_URL (https://user:key@host/wd/hub).
2 Required for CLOUD_PROVIDER=custom; optional otherwise.

No code changes required

Switching between local and cloud execution is purely config — set CLOUD_PROVIDER in your CI environment and unset it (or leave it empty) for local runs. Your YAML flows and SDK tests stay identical.

Node.js / TypeScript SDK

AppClaw ships a first-class programmatic API so you can drive mobile automation directly from Node.js or TypeScript — no CLI required. The SDK is the natural fit for QA automation inside test runners (Vitest, Jest, Mocha), CI pipelines, and any script that needs to control a device programmatically.

When to use the SDK vs the CLI

Use the CLI for one-off tasks and interactive exploration. Use the SDK when you want to run flows inside a test suite, assert on results, share a device connection across multiple flows, or integrate AppClaw into a larger automation pipeline.

Architecture

The SDK exposes a single AppClaw class that manages the full lifecycle:

  • Lazy MCP connect — the Appium connection is opened on the first runFlow() or runGoal() call, not on construction.
  • Connection reuse — subsequent calls share the same underlying connection, so you pay the startup cost once per test suite.
  • Explicit teardown — call teardown() in your afterAll hook to close the connection cleanly.
  • Silent by default — spinners and terminal colours are suppressed automatically, keeping CI logs clean.

Installation

AppClaw is a single package — the SDK is built in, nothing extra to install.

Terminal
npm install appclaw

Create a .env file in your project root (or pass options directly to the constructor — see Options Reference).

.env
LLM_PROVIDER=anthropic
LLM_API_KEY=sk-ant-...
PLATFORM=android

runFlow()

Parse and execute a YAML flow file against a connected device. Returns a FlowResult you can assert on.

TypeScript
import { AppClaw } from '@appclaw/core';

const app = new AppClaw({
  provider: 'anthropic',
  apiKey:   process.env.ANTHROPIC_API_KEY,
  platform: 'android',
});

const result = await app.runFlow('./flows/checkout.yaml');

console.log(result.success);    // true
console.log(result.stepsUsed);  // 6
console.log(result.stepsTotal); // 6

await app.teardown();

FlowResult shape

Field Type Description
success boolean Whether all steps completed successfully
stepsUsed number Steps executed before completion or failure
stepsTotal number Total steps in the flow (including unexecuted)
failedStep number? 1-based index of the step that failed
failedPhase string? setup | test | assertion
error string? Human-readable failure reason

runGoal()

Execute a plain-English goal using the agent loop — same as passing a goal string to the CLI. Returns an AgentResult.

TypeScript
import { AppClaw } from '@appclaw/core';

const app = new AppClaw({ provider: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY });

const result = await app.runGoal(
  'Log in with email qa@company.com and password Test1234'
);

console.log(result.success);   // true
console.log(result.stepsUsed); // 4
console.log(result.reason);    // "Logged in successfully"

await app.teardown();
Flow vs Goal

Use runFlow() for repeatable QA scenarios — structured, deterministic, zero LLM cost. Use runGoal() for exploratory tasks or when you need the agent to adapt to dynamic screen states.

run()

Execute a single natural-language instruction directly on the device — the programmatic equivalent of typing a command in the playground REPL. Each call is one atomic action: parse the instruction, execute it, return the result.

TypeScript
import { AppClaw } from '@appclaw/core';

const app = new AppClaw({ provider: 'gemini', apiKey: process.env.GEMINI_API_KEY, platform: 'android' });

await app.run('open YouTube app');
await app.run('tap Search');
await app.run('type Appium 3.0');
await app.run('tap the search button');
await app.run('wait 2 seconds');
await app.run('scroll down');

// Assertion — throws AppClawAssertionError if the claim is false
await app.verify('the TestMu AI video is visible');

await app.teardown();  // report written to .appclaw/runs/

Per-command options

run() takes an optional second argument (RunOptions) that overrides the instance defaults for that one call — handy for a slow screen that needs a longer wait, or a tight list that needs a shorter scroll. Every field is optional and falls back to the constructor value, then to the engine default.

TypeScript
// Wait up to 20s for this specific (slow-loading) screen
await app.run('click on Dashboard', { waitTimeout: 20000 });

// Scroll a short distance, up to 5 times, to find an item
await app.run('scroll down until Karma is visible', { scrollMode: 'short', scrollTimes: 5 });

// A single full-screen swipe
await app.run('swipe up', { scrollMode: 'full' });
RunOptions field Type Description
waitTimeout number Implicit-wait timeout (ms) for this command's target element
waitInterval number Poll cadence (ms) for this command's implicit wait
scrollMode ScrollDistance Scroll/swipe distance: 'short' | 'medium' | 'full'
scrollTimes number Repeat count (plain swipe) or max scroll attempts (scroll … until …)

RunResult shape

Field Type Description
success boolean Whether the action completed successfully
action string Resolved step kind: tap | type | openApp | wait | swipe | …
message string Human-readable description of what happened
run() vs runGoal() vs runFlow()

Use run() when you want full control — one deterministic step at a time, easy to integrate with any test framework. Use runGoal() when you want the agent to figure out the steps itself. Use runFlow() for declarative YAML test cases you want to version-control.

verify()

Assert that something is true on the current screen. verify() takes a natural-language claim, captures the screen, and checks whether it holds — the programmatic counterpart to a YAML assertion step. Unlike run(), it throws when the claim is false, so it drops straight into any test runner.

TypeScript
import { AppClaw } from '@appclaw/core';

const app = new AppClaw();

await app.run('open YouTube app');
await app.run('search for Appium 3.0');

// Throws AppClawAssertionError if the claim is false — no try/catch needed,
// the throw fails the test and the error carries the on-screen context.
await app.verify('the TestMu AI video is visible');

await app.teardown();

On failure it raises AppClawAssertionError, which carries the original claim, the underlying result, and — in DOM mode — the screenContents captured from the page source, so the failure message shows what was actually on screen. In vision mode the model's reasoning is already included in result.message.

When a claim fails, the thrown error reads like this:

Assertion failure
AppClawAssertionError: Verify failed: "the TestMu AI video is visible"
  Reason: "TestMu AI" not found on screen
    Screen contains: Search, Appium 3.0 | What's new in Appium 3 | Appium 3.0 Tutorial | Mobile Testing 101 | Selenium vs Appium
  On screen now:
    • Search, Appium 3.0
    • What's new in Appium 3
    • Appium 3.0 Tutorial
    • Mobile Testing 101
    • Selenium vs Appium
run() vs verify()

run() performs an action and returns a result without throwing. verify() makes an assertion and throws on failure. Use run() to drive the app, verify() to assert the outcome.

Locator Cache

The SDK locator cache is an opt-in DOM-mode speed path for repeated app.run() calls. When the same app, screen, action, and label are seen again, AppClaw can skip the page-source parse, DOM scoring, and multi-strategy probing and go straight to the Appium locator that worked last time.

DOM mode only

Locator cache is not used in AGENT_MODE=vision. Vision mode taps by screenshot coordinates or vision-derived boxes, which are tied to the current screenshot and should not be reused across sessions.

Enable it

Enable per SDK instance:

TypeScript
const app = new AppClaw({
  platform: 'android',
  agentMode: 'dom',
  locatorCache: true
});

Or isolate the cache file and namespace for CI:

TypeScript
const app = new AppClaw({
  locatorCache: {
    path: '.appclaw/cache/locator-cache.json',
    namespace: process.env.GITHUB_REF_NAME ?? 'local'
  }
});

You can also enable it through environment variables:

.env
AGENT_MODE=dom
LOCATOR_CACHE_ENABLED=on
LOCATOR_CACHE_PATH=.appclaw/cache/locator-cache.json
APPCLAW_MEMORY_NAMESPACE=main

What gets stored

AppClaw stores the winning Appium locator, not the Appium element UUID. UUIDs are session-scoped and change every run; locators such as accessibility IDs and resource IDs can be re-resolved to a fresh UUID on the next run.

~/.appclaw/locator-cache.json
{
  "version": 1,
  "entries": [
    {
      "namespace": "default",
      "platform": "android",
      "appId": "com.example.app",
      "screenFingerprint": "68eda714f104",
      "actionKind": "tap",
      "label": "login button",
      "locator": {
        "strategy": "accessibility id",
        "selector": "login"
      },
      "successCount": 4,
      "failCount": 0,
      "confidence": 1
    }
  ]
}

How a cache hit works

  1. AppClaw reads page source and builds a cache key from namespace, platform, app ID, semantic screen fingerprint, action kind, and label.
  2. If an entry exists, AppClaw calls one Appium lookup: findElement(strategy, selector).
  3. If Appium returns a fresh element UUID, AppClaw performs the action with that UUID.
  4. On success, the report manifest records "cacheHit": true for that step.
Report manifest
{
  "verbatim": "click on login button",
  "message": "Tapped \"login button\"",
  "cacheHit": true
}

Loading screens and implicit waits

Cache misses do not bypass AppClaw's implicit wait. For DOM actions such as tap, longPress, and type, AppClaw still polls according to waitTimeout and waitInterval.

Each poll attempt does this:

  1. Try the cached locator, if a cache entry exists.
  2. If the cached findElement returns no element, do not fail and do not mark the cache stale. The page may still be loading.
  3. Fall back to normal DOM parsing, scoring, and locator probing.
  4. If a matching element is found, perform the action and update the cache.
  5. If no match is found, wait waitInterval and try again.
Slow API response

If the app is waiting on an API and the target is not in the DOM yet, AppClaw keeps polling until the wait budget is exhausted. A temporary absence does not reduce cache confidence.

Locator changes and recovery

If the app changes and the cached selector no longer works, AppClaw recovers through the normal DOM path. For example, if the cache contains { strategy: "accessibility id", selector: "login1" } but the real element is now login:

  1. The cached lookup for login1 returns no element.
  2. AppClaw falls back to DOM scoring in the same poll attempt.
  3. The matching element is found and tapped.
  4. recordHit() overwrites the stale selector with the new winning locator.

If the cached locator resolves to an element but the action fails against that UUID, AppClaw marks the entry stale, lowers confidence, and falls back to normal DOM resolution. Entries with very low effective confidence are evicted on save.

Screen transitions

During a transition, Appium can briefly return a mixed DOM containing labels from the previous and next screens. To avoid recording cache entries against that transient state, cache-enabled DOM actions wait briefly for two consecutive semantic screen fingerprints to match before reading or writing cache entries.

Inspecting the cache

The default file is:

Terminal
cat ~/.appclaw/locator-cache.json

To see whether a step used the cache, inspect the latest SDK report manifest:

Terminal
find .appclaw/runs -name manifest.json -maxdepth 2 | xargs ls -t | head -1
rg "cacheHit|verbatim|message" .appclaw/runs/*/manifest.json

Reports

Reports are enabled by default when using the SDK. After teardown() is called, AppClaw writes an HTML report to .appclaw/runs/ — one screenshot per step, plus a full execution summary. No extra configuration needed.

TypeScript
const app = new AppClaw({
  provider:   'gemini',
  apiKey:     process.env.GEMINI_API_KEY,
  platform:   'android',
  reportName: 'YouTube Search',  // shown in the report viewer
});

await app.run('open YouTube app');
await app.run('tap Search');
await app.run('type Appium 3.0');
await app.run('tap the search button');

await app.teardown();
// ↑ writes report to .appclaw/runs/<runId>/

Screen recording

Pass video: true to record the screen for the entire run and embed the video in the report. Recording starts automatically on the first run() call and stops in teardown().

TypeScript
const app = new AppClaw({
  provider:   'gemini',
  apiKey:     process.env.GEMINI_API_KEY,
  platform:   'android',
  reportName: 'YouTube Search',
  video:      true,               // record screen for the whole run
});

await app.run('open YouTube app');
await app.run('tap Search');
await app.run('type Appium 3.0');
await app.run('tap the search button');

await app.teardown();
// ↑ report includes recording.mp4 under the Recording tab
Parallel-safe

Each AppClaw instance records its own session independently — parallel tests do not interfere. Port allocation (MJPEG, system port) is also handled automatically per instance.

Viewing the report

Run the built-in report server after your tests complete:

Shell
npx appclaw --report                              # serves ./.appclaw/runs/
npx appclaw --report --report-dir examples/runner # serve a specific location
npx appclaw --report --report-port 5000           # custom port

The server reads .appclaw/runs/ relative to the current directory. Use --report-dir <path> to point it at a project elsewhere (absolute or relative to cwd) without cding there. Every run is listed with its steps, screenshots, pass/fail status, and timing.

Report file layout

File tree
.appclaw/
  runs/
    runs.json              # global run index
    <runId>/
      manifest.json        # full run data (steps, timing, success)
      steps/
        step-000.png       # screenshot after step 1
        step-001.png       # screenshot after step 2
        step-002.png

Disabling reports

Set report: false to skip report generation (e.g. in performance-sensitive CI pipelines):

TypeScript
const app = new AppClaw({
  provider: 'gemini',
  apiKey:   process.env.GEMINI_API_KEY,
  report:   false,   // disable report generation
});

Using with Vitest / Jest

Create one AppClaw instance per test file, connect once in beforeAll, and tear down in afterAll. Individual tests call runFlow() or runGoal() and assert on the result.

tests/checkout.test.ts
import { describe, it, expect, afterAll } from 'vitest';
import { AppClaw } from '@appclaw/core';

const app = new AppClaw({
  provider: 'anthropic',
  apiKey:   process.env.ANTHROPIC_API_KEY,
  platform: 'android',
  maxSteps: 20,
});

afterAll(() => app.teardown());

describe('Checkout flow', () => {
  it('completes purchase as a logged-in user', async () => {
    const result = await app.runFlow('./flows/checkout.yaml');
    expect(result.success).toBe(true);
  });

  it('handles empty cart gracefully', async () => {
    const result = await app.runFlow('./flows/checkout-empty-cart.yaml');
    expect(result.success).toBe(true);
  });

  it('completes in under 15 steps', async () => {
    const result = await app.runFlow('./flows/checkout.yaml');
    expect(result.stepsUsed).toBeLessThan(15);
  });
});

Phased flows & assertion results

For flows that use setup / steps / assertions sections, the failedPhase field tells you exactly where execution broke down:

TypeScript
const result = await app.runFlow('./flows/login-phased.yaml');

if (!result.success) {
  // failedPhase: 'setup' | 'test' | 'assertion'
  console.error(`Failed in ${result.failedPhase} phase`);
  console.error(`Step ${result.failedStep}: ${result.error}`);
}

CI Scripts

For CI pipelines that don't use a test framework, run flows sequentially and exit non-zero on failure. The SDK's silent: true default keeps logs clean.

scripts/smoke-test.ts
import { AppClaw } from '@appclaw/core';

const app = new AppClaw({
  provider: 'google',
  apiKey:   process.env.GEMINI_API_KEY,
  platform: 'android',
  silent:   true,  // no spinners in CI
});

const flows = [
  './flows/login.yaml',
  './flows/checkout.yaml',
  './flows/search.yaml',
];

for (const flow of flows) {
  const result = await app.runFlow(flow);

  if (!result.success) {
    console.error(`FAILED: ${flow} ${result.error}`);
    await app.teardown();
    process.exit(1);
  }

  console.log(`PASSED: ${flow} (${result.stepsUsed} steps)`);
}

await app.teardown();
console.log('All flows passed.');

Run it with tsx (no compilation step needed):

Terminal
npx tsx scripts/smoke-test.ts

Options Reference

All fields passed to new AppClaw(options). Every field is optional — unset fields fall back to .env values or built-in defaults, matching CLI behaviour exactly.

Option Type Default Description
provider string 'gemini' 'anthropic' | 'openai' | 'gemini' | 'groq' | 'ollama'
apiKey string API key for the chosen LLM provider
model string Provider default Model ID override (e.g. 'claude-opus-4-6')
platform string 'android' | 'ios'
capabilitiesFile string Path to a JSON file of extra Appium capabilities merged into the session. Flat or platform-scoped (android/ios/common). Maps to CAPABILITIES_FILE. See Custom Capabilities.
agentMode string 'dom' 'dom' uses accessibility tree; 'vision' uses AI vision
maxSteps number 30 Maximum agent steps before giving up (applies to runGoal)
stepDelay number 500 Delay between steps in milliseconds
waitTimeout number 10000 Implicit wait (ms). Every element-bearing action polls its target until it is on screen before acting, so you don't need wait … steps between calls. 0 = fail-fast (single attempt).
waitInterval number 300 Poll cadence (ms) for waitTimeout
scrollMode ScrollDistance engine (~60%) Default scroll/swipe distance: 'short' (~30%) | 'medium' (~60%) | 'full' (~90%) of the screen. Override per call via run().
scrollTimes number parsed Default scroll/swipe count — repeat count for a plain swipe, or max scroll attempts for scroll … until …. Override per call via run().
silent boolean true Suppress spinners and terminal colour output. Set false to debug locally.
report boolean true Auto-generate an HTML report to .appclaw/runs/ on teardown(). Set false to disable.
reportName string 'AppClaw SDK Run' Name shown in the report viewer.
video boolean false Record the screen for the entire run and embed the video under the Recording tab in the report. Recording starts on the first run() call and stops automatically in teardown(). Requires Appium screen recording support.
locatorCache boolean | object false Enable DOM-mode locator caching. Pass true for defaults, or { path, namespace } to isolate cache files and namespaces.
mcpTransport string 'stdio' 'stdio' (local appium-mcp) | 'sse' (remote server)
mcpHost string 'localhost' appium-mcp host when transport is 'sse'
mcpPort number 8080 appium-mcp port when transport is 'sse'

TypeScript types

All public types are exported from the top-level '@appclaw/core' import:

TypeScript
import {
  AppClaw,
  type AppClawOptions,   // constructor options
  type RunOptions,        // per-command second arg to run()
  type ScrollDistance,    // 'short' | 'medium' | 'full'
  type FlowResult,       // returned by runFlow()
  type RunResult,        // returned by run()
  type AgentResult,      // returned by runGoal()
  type RunYamlFlowOptions // second arg to runFlow()
} from '@appclaw/core';

RunOptions is an interface, so TypeScript flags a misspelled key and the ScrollDistance union rejects an invalid value — the options object is fully type-checked, not any:

TypeScript
await app.run('swipe up', { scrollMode: 'shrt' });  // ✗ not assignable to ScrollDistance
await app.run('swipe up', { waitTimout: 1000 });   // ✗ unknown property (did you mean waitTimeout?)

GitHub Actions

Run AppClaw mobile UI automation flows and AI-driven goals directly in GitHub Actions — Android emulator or iOS simulator included, zero boilerplate.

Available on the GitHub Marketplace as AppClaw Mobile Tests.

workflow.yml
uses: AppiumTestDistribution/AppClaw@v1
with:
  flow: flows/login.yaml
  platform: android
  api-key: ${{ secrets.LLM_API_KEY }}

Quick Start

Android — run a YAML flow

android-flow.yml
name: Mobile Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: AppiumTestDistribution/AppClaw@v1
        with:
          flow: flows/login.yaml
          platform: android
          api-key: ${{ secrets.LLM_API_KEY }}

Android — natural language goal

android-goal.yml
- uses: AppiumTestDistribution/AppClaw@v1
  with:
    goal: 'Open YouTube, search for Appium 3.0, verify the first result is visible'
    platform: android
    api-key: ${{ secrets.LLM_API_KEY }}

iOS — run a YAML flow

ios-flow.yml
jobs:
  test:
    runs-on: macos-14  # iOS requires macOS (Apple Silicon)
    steps:
      - uses: actions/checkout@v4

      - uses: AppiumTestDistribution/AppClaw@v1
        with:
          flow: flows/ios-login.yaml
          platform: ios
          api-key: ${{ secrets.LLM_API_KEY }}

Inputs

All inputs are passed via the with: block in your workflow.

Input Required Default Description
flow one of* Path to a YAML flow file relative to repo root
goal one of* Natural language goal executed by the LLM agent
platform no android Target platform: android or ios
provider no gemini LLM provider: gemini, anthropic, openai, groq
api-key yes LLM API key — stored as LLM_API_KEY
model no provider default LLM model ID to pin (e.g. gemini-2.0-flash)
agent-mode no dom dom (element locators) or vision (screenshot AI)
max-steps no 30 Maximum agent steps before the run fails
step-delay no 500 Milliseconds between steps
android-api-level no 33 Android emulator API level (33 = Android 13)
android-profile no pixel_6 Android AVD hardware profile
android-target no default Emulator target: default or google_apis
ios-device-type no simulator iOS device type: simulator or real
ios-simulator-name no iPhone 16 iOS simulator model to boot (e.g. iPhone 15, iPad Air)
ios-simulator-os no latest iOS version filter for simulator selection (e.g. 18.4)
mcp-debug no false Enable MCP debug logging (MCP_DEBUG=1). Useful for diagnosing CI timeouts.
cloud-provider no local Cloud provider: lambdatest. Leave empty for local.
lambdatest-username no** LambdaTest account username
lambdatest-access-key no** LambdaTest access key
lambdatest-device-name no** Cloud device name (e.g. Pixel 7)
lambdatest-os-version no** Cloud OS version (e.g. 13, 16)
lambdatest-app no LambdaTest app ID (lt://APP...)
report no true Upload HTML report as workflow artifact
report-name no appclaw-report Name of the uploaded artifact
appclaw-version no latest npm package version to pin

* Provide either flow or goal, not both.

** Required when cloud-provider: lambdatest.

Secrets Setup

Go to your repo → Settings → Secrets and variables → Actions → New repository secret:

Secret name Description
LLM_API_KEY Your API key — works for any provider (Gemini, Anthropic, OpenAI, Groq)
LT_USERNAME LambdaTest username (only if using cloud devices)
LT_ACCESS_KEY LambdaTest access key (only if using cloud devices)
LT_APP_ID LambdaTest app ID (only if using cloud devices)

Examples

Parallel matrix — run multiple flows concurrently

matrix-parallel.yml
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        flow:
          - flows/login.yaml
          - flows/search.yaml
          - flows/checkout.yaml
    steps:
      - uses: actions/checkout@v4

      - uses: AppiumTestDistribution/AppClaw@v1
        with:
          flow: ${{ matrix.flow }}
          platform: android
          api-key: ${{ secrets.LLM_API_KEY }}
          report-name: report-${{ strategy.job-index }}

LambdaTest cloud devices

Run iOS tests on Ubuntu — no macOS runner needed.

lambdatest.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: AppiumTestDistribution/AppClaw@v1
        with:
          flow: flows/ios-login.yaml
          platform: ios
          api-key: ${{ secrets.LLM_API_KEY }}
          cloud-provider: lambdatest
          lambdatest-username: ${{ secrets.LT_USERNAME }}
          lambdatest-access-key: ${{ secrets.LT_ACCESS_KEY }}
          lambdatest-device-name: 'iPhone 14'
          lambdatest-os-version: '16'
          lambdatest-app: ${{ secrets.LT_APP_ID }}

Vision mode (screenshot-based AI)

vision-mode.yml
- uses: AppiumTestDistribution/AppClaw@v1
  with:
    flow: flows/onboarding.yaml
    platform: android
    agent-mode: vision
    api-key: ${{ secrets.LLM_API_KEY }}

Pin model for cost control

pin-model.yml
- uses: AppiumTestDistribution/AppClaw@v1
  with:
    flow: flows/smoke.yaml
    platform: android
    api-key: ${{ secrets.LLM_API_KEY }}
    model: 'gemini-2.0-flash'  # cheaper/faster than pro

Nightly regression on a schedule

nightly.yml
on:
  schedule:
    - cron: '0 2 * * *'  # 2 AM UTC every night

jobs:
  nightly:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: AppiumTestDistribution/AppClaw@v1
        with:
          flow: flows/full-regression.yaml
          platform: android
          api-key: ${{ secrets.LLM_API_KEY }}
          report-name: nightly-report-${{ github.run_id }}

Reports

When report: true (default), an HTML report is uploaded as a workflow artifact after each run. Download it from the Actions run summary → Artifacts. The report includes:

  • Step-by-step screenshots with tap overlays
  • Pass/fail status per step
  • Execution timeline
  • Screen recording (if video: true is set in your flow)

Use report path in a downstream step

report-path.yml
- uses: AppiumTestDistribution/AppClaw@v1
  id: appclaw
  with:
    flow: flows/login.yaml
    platform: android
    api-key: ${{ secrets.LLM_API_KEY }}

- name: Print report location
  run: echo "Report at ${{ steps.appclaw.outputs.report-path }}"

Runner Requirements

Platform Runner Notes
android ubuntu-latest Free tier. KVM-enabled. Emulator boots in ~4-6 min.
ios macos-14 Apple Silicon. macOS minutes cost ~10x Linux.

iOS tip: For faster iOS CI, use LambdaTest cloud devices on ubuntu-latest instead of a macOS runner.

App Guides / Overview

What are App Guides?

App Guides (AppGuides) are per-app knowledge snippets injected directly into the agent's context window at the start of every automation run. They encode navigation patterns, gesture shortcuts, and common action paths for a specific app — so the agent never needs to rediscover them by trial and error.

Context Engineering for Mobile

AppGuides are AppClaw's implementation of context engineering — the practice of giving the LLM exactly the right knowledge to act correctly, rather than relying on the model's general training alone. For mobile automation, this means app-specific navigation knowledge baked into the system prompt before the agent ever looks at the screen.

How it works

When AppClaw starts a run against a known app, it automatically loads the matching guide and prepends it to the agent's system prompt with an APP_GUIDE (AppName): prefix. The LLM sees this contextual knowledge before it takes any action — making the first step decisive rather than exploratory.

System prompt injection (simplified)
APP_GUIDE (WhatsApp):

## WhatsApp Navigation
- Bottom tabs: Chats | Updates | Communities | Calls
- New chat: floating pencil/message icon (bottom-right)
- Search: magnifying-glass icon at the top of Chats

## Messaging
- Open a chat → type in the message bar at the bottom → send via arrow icon
- Attach media: paperclip icon next to message bar
- Voice note: long-press the microphone icon

Resolution order

  1. Custom guide.appclaw/guides/<appId>.md (highest priority, overrides built-ins)
  2. Built-in guide — bundled guides for 10 common apps
  3. No guide — agent explores the app from scratch using only what it sees on screen
App Guides / Built-in Guides

Built-in Guides

AppClaw ships with guides for the most commonly automated apps on both Android and iOS. These activate automatically when AppClaw detects the matching package name or bundle ID.

App Platform App ID / Bundle ID
Gmail Android com.google.android.gm
Gmail iOS com.google.gmail
YouTube Android com.google.android.youtube
YouTube iOS com.google.ios.youtube
WhatsApp Android com.whatsapp
WhatsApp iOS net.whatsapp.WhatsApp
Chrome Android com.android.chrome
Chrome iOS com.google.chrome
Settings Android com.android.settings
Settings iOS com.apple.Preferences

Example: WhatsApp Guide

APP_GUIDE (WhatsApp)
## WhatsApp Navigation
- Bottom tabs: Chats | Updates | Communities | Calls
- New chat: floating pencil/message icon (bottom-right)
- Search: magnifying-glass icon at the top of Chats

## Messaging
- Open a chat → type in the message bar at the bottom → send via arrow icon
- Attach media: paperclip icon next to message bar
- Voice note: long-press the microphone icon
- Emoji/stickers: smiley face icon on the left of message bar

## Common Actions
- Star a message: long-press message → star icon
- Forward: long-press message → forward arrow
- Delete: long-press message → trash icon
- Group info: tap the group name at the top of the chat

Example: YouTube Guide

APP_GUIDE (YouTube)
## YouTube Navigation
- Bottom nav: Home | Shorts | + (upload) | Subscriptions | Library
- Search: magnifying-glass icon (top-right)
- Tap a video thumbnail to play; double-tap left/right to seek ±10 s

## Searching
- Tap the search icon → type query → press Enter or tap search icon again
- Filter results: tap "Filters" after searching

## Playback
- Full screen: rotate device or tap the expand icon (bottom-right of player)
- Quality: tap ⋮ inside player → Quality
- Captions: tap CC icon inside player
App Guides / Custom Guides

Custom Guides

Add a guide for any app — or override a built-in — by dropping a Markdown file at .appclaw/guides/<appId>.md in your project directory. Custom guides always take priority over built-ins.

Custom guides always win

If a custom guide exists for an app ID, it replaces the built-in entirely. To extend a built-in guide, copy its contents into your custom file and add your own sections.

Creating a custom guide

  1. Find your app's package name (Android) or bundle ID (iOS). You can get this from the appId field in your YAML flow, or by inspecting the device.
  2. Create the directory .appclaw/guides/ in your project root.
  3. Write a Markdown file named <appId>.md.
.appclaw/guides/com.myapp.android.md
## Main Navigation
- Bottom tabs: Home | Search | Orders | Profile
- Hamburger menu (top-left) → categories and account settings

## Checkout Flow
- Cart icon is always in the top-right corner
- Tap "Proceed to Checkout" → select address → choose payment → Place Order
- Apply coupon: tap "Have a coupon?" on the order summary screen

## Product Search
- Tap the search bar at the top; supports filters: Brand | Price | Rating
- Long-press any product thumbnail to preview without navigating away

Once in place, AppClaw picks it up automatically — no code changes or restarts needed.

Tips for writing good guides

Do Why
Describe where things are, not what they say UI labels change; positions are stable
List gestures explicitly ("swipe right to archive") The agent can't infer non-obvious gestures from a screenshot
Use bullet points over prose Every token counts — bullets are faster for the model to parse
Document multi-step paths (Settings → Account → Privacy) Saves the agent multiple round-trips for deeply nested flows
Keep it short (under 500 tokens) Guides are injected on every step — brevity reduces cost

Agent CLI appclaw-agent

appclaw-agent is a deterministic terminal CLI designed for AI coding agents — Claude Code, Gemini CLI, Codex CLI, and any agent with terminal access. It exposes named device sessions through simple JSON-output commands so the host agent can inspect and operate a real Android or iOS device without writing any automation code.

Who this is for

Use appclaw-agent when an AI coding agent needs to drive a mobile device as part of a larger task — writing a test, verifying a UI, or exploring an app. The agent issues terminal commands; AppClaw handles the device session.

How it compares

appclaw appclaw-agent
Audience Developers & QA engineers AI coding agents
Reasoning Built-in LLM agent loop Handled by the host agent
Output Rich terminal UI Structured JSON
Sessions One goal per invocation Named, persistent across calls
LLM key required Yes No (vision commands only)
Agent CLI / Installation

Installation

Install globally so appclaw-agent is available in any terminal session.

Terminal
npm install -g @appclaw/agent

Verify the install and read the built-in workflow guide — agents should run this before starting any session:

Terminal
appclaw-agent help workflow
For Claude Code users

Drop a use-appclaw-agent-cli skill in your project's .claude/skills/ directory. Claude Code will automatically use appclaw-agent for any mobile automation task — no extra configuration needed.

Agent CLI / Workflow

Workflow

Every session follows the same five-step pattern. Re-snapshot after every state-changing action — refs are invalidated when the screen changes.

Terminal
# 1. Open a named session
appclaw-agent --session login open com.example.app --platform android

# 2. Inspect actionable UI elements
appclaw-agent --session login snapshot -i --json

# 3. Interact using a ref or stable selector
appclaw-agent --session login fill @e1 "test@example.com" --json
appclaw-agent --session login press @e2 --json

# 4. Re-snapshot to get fresh refs
appclaw-agent --session login snapshot -i --json

# 5. Verify visually, then close
appclaw-agent --session login screenshot /tmp/screen.png
appclaw-agent --session login close

Session persistence

A user-local daemon owns active sessions. Separate terminal invocations sharing the same --session name continue operating on the same screen — you don't lose the device connection between commands.

JSON output

All commands accept --json and return a structured response the host agent can parse directly:

JSON response
{
  "ok": true,
  "session": "login",
  "message": "Snapshot contains 12 element(s)",
  "output": "@e1 [EditText] \"Email\" id=\"com.example:id/email\"\n...",
  "data": { "elements": [ ... ] }
}
Agent CLI / Command Reference

Command Reference

Session management

Command Description
open <appId> --platform android|ios Open an app and start a named session
close Close the session and release the device
help workflow Print the recommended agent workflow guide

Inspection

Command Description
snapshot -i --json List all interactive elements with refs, types, and locators
screenshot <path> Save a screenshot to disk for visual verification
is visible <selector> --json DOM check — element exists in hierarchy (not a visibility guarantee)

Interaction

Command Description
press <ref|selector> --json Tap an element
fill <ref|selector> "text" --json Type text into an input field
scroll down|up --json Scroll the screen
scroll <ref> down|up --json Scroll within a specific container element

Hardware keys

Command Description
back --json Press the hardware Back button
home --json Press the hardware Home button
enter --json Press the Enter / Return key
Always re-snapshot after interaction

Any command that changes screen state (press, fill, scroll, hardware keys) invalidates all current @eN refs. Run snapshot -i --json again before referencing elements.

Agent CLI / Selectors & Refs

Selectors & Refs

There are two ways to target an element: snapshot refs (short-lived) and stable selectors (durable across snapshots).

Snapshot refs (@eN)

Every snapshot -i --json response includes short refs like @e1, @e2, etc. Use these for immediate actions on the current screen — they're fast to write but expire as soon as the screen changes.

Terminal
appclaw-agent --session s1 press @e3 --json

Stable selectors

Stable selectors survive re-snapshots and are safe to store in test scripts. Three selector kinds are supported:

Kind Syntax Best for
Resource ID id="com.example:id/login_btn" Most reliable — stable across app versions
Accessibility ID accessibility="Sign in" Good fallback when IDs are absent
Text text="Sign in" Quick targeting when label is unique
Terminal
# Using resource ID
appclaw-agent --session s1 press 'id="com.example:id/login_btn"' --json

# Using accessibility ID
appclaw-agent --session s1 press 'accessibility="Sign in"' --json

# Using visible text
appclaw-agent --session s1 press 'text="Sign in"' --json
Agent CLI / Vision

Vision

When AppClaw's vision API is configured, you can describe elements in plain English instead of providing a selector. Use --vision only for genuinely visual operations where a DOM selector isn't available or reliable.

Terminal
# Tap by visual description
appclaw-agent --session s1 press --vision "cart icon in the top right"

# Assert something is visible on screen
appclaw-agent --session s1 is visible --vision "order confirmed message" --json

# Read a value from the screen
appclaw-agent --session s1 get info --vision "displayed total" --json
Screenshot fallback

When vision is not configured, --vision commands automatically capture a screenshot and return its path in screenshotPath. The host agent can then read the image to answer the question visually — no API key required.

Visual assertions vs DOM checks

DOM checks confirm an element exists in the hierarchy — not that it is rendered on screen. An element can be in the DOM but scrolled off-screen or hidden. Always use a screenshot for pass/fail assertions:

Method What it checks Use when
screenshot + visual read What is actually rendered Pass/fail assertions (preferred)
is visible <selector> Element exists in DOM hierarchy Presence check only, not visibility
is visible --vision Element visible on rendered screen When vision API is configured

Test Runner

The AppClaw Runner owns your entire mobile test run. It discovers connected devices, starts the appium-mcp server, assigns one device per worker, runs your tests in parallel, and writes a consolidated report. You only write the test bodies — device pools, ports, sessions, and cleanup are handled for you.

It builds on the Node.js SDK: every test receives a ready app (an AppClaw instance) bound to a leased device, so the full app.run() / app.verify() surface is available inside a test.

What the Runner handles for you:

  • Device pool — discovers and de-dupes connected devices/emulators.
  • appium-mcp node — spins up a local SSE server and connects to it.
  • Parallel sessions — one device per worker, isolated ports + pinned UDID.
  • Lifecycle hooks — run / device / scope / test setup and teardown.
  • Fixtures — Playwright-style reusable, composable, injected setup.
  • Reporting — per-test reports + a suite summary, with a pass/fail exit code.
  • Cleanup — deletes sessions (frees adb ports) and never orphans the server.

Writing Tests

Import test, describe, and hooks from @appclaw/runner. Each test gets an injected app and a context object.

tests/login.spec.ts
import { test, describe } from '@appclaw/runner';

describe('Login', () => {
  test('user can sign in', async (app, ctx) => {
    // app = an AppClaw on a leased device
    await app.run('tap the Username field');
    await app.run('type admin');
    await app.run('tap Login');
    await app.verify('the home screen is visible');
    // ctx.state · ctx.device · ctx.title · ctx.retry
  });

  // Per-test options: retries, skip, only, platform
  test('flaky path', { retries: 2 }, async (app) => { /* … */ });
  test.skip('not ready', async () => {});
  test.only('focus this', async (app) => { /* … */ });

  // Platform-only tests — skipped (not failed) on a run of the other OS
  test.android('NFC tap-to-pay', async (app) => { /* … */ });
  test.ios('Face ID enrollment', async (app) => { /* … */ });
  test('shared but explicit', { platform: ['android', 'ios'] }, async (app) => { /* … */ });
});
API Description
test(title, fn) Register a test. fn(app, ctx) runs on a leased device.
test(title, opts, fn) With per-test options: retries, skip, only, platform.
test.only / test.skip Focus or skip a test. Any .only filters the run to only those.
test.android / test.ios Restrict a test to one platform. On a run of the other OS it is reported as skipped (not failed) — so a shared spec file can hold both Android-only and iOS-only tests. Equivalent to { platform: 'android' } / { platform: 'ios' }; pass an array ({ platform: ['android', 'ios'] }) for multiple.
describe(label, fn) Group tests (nestable). Scopes beforeAll/afterAll and titles.
ctx.state Value returned by globalSetup, shared across the run.
ctx.device The device this test is running on (name, udid).

Configuration

All run configuration lives in appclaw.config.ts via defineConfig. It is written once by whoever operates the lab/CI — test authors never touch it.

appclaw.config.ts
import { defineConfig } from '@appclaw/runner';

export default defineConfig({
  // ── discovery / specs ──
  testDir: 'tests',
  testMatch: ['**/*.spec.ts', '**/*.test.ts'],

  // ── execution ──
  concurrency: 'auto',        // 'auto' = one worker per device, or a number
  retries: 1,
  timeout: 120000,           // per-test timeout (ms)

  // ── infra ──
  node: { local: true },     // spawn a local appium-mcp SSE server

  // ── AppClaw options (forwarded to every test's session) ──
  platform: 'android',
  provider: process.env.LLM_PROVIDER,
  apiKey: process.env.LLM_API_KEY,
  model: process.env.LLM_MODEL,
  capabilitiesFile: './tests/caps.json',   // resolved next to this config
  // agentMode: 'vision', maxSteps: 40, waitTimeout: 15000, video: true, …
});

Environment variables (.env)

The config reads secrets from process.env (e.g. apiKey: process.env.LLM_API_KEY). AppClaw auto-loads a .env file from the directory you run in — put it next to package.json and the values are available before the config is read. No wiring needed.

.env
LLM_PROVIDER=anthropic
LLM_API_KEY=sk-ant-…
LLM_MODEL=claude-sonnet-4-6

If your .env lives elsewhere (a shared file, a CI secret path), point at it with --env-file — it's loaded before the config, overriding the auto-loaded one:

Terminal
appclaw test --env-file ../../.env
appclaw test --env-file .env.ci --workers 2
Secrets stay out of the config

Keep appclaw.config.ts committed and free of secrets — reference process.env.* for keys and let .env (git-ignored) or --env-file supply the values. dotenv won't overwrite variables already set in the real environment, so CI-injected secrets take precedence.

Resolution precedence

For any setting: CLI flag > config file > built-in default.

Runner-level options

Key Default Description
testDir tests Folder scanned for spec files.
testMatch **/*.spec.ts, **/*.test.ts Filename patterns that count as specs.
testIgnore Patterns to exclude.
concurrency auto auto = one worker per device, or a fixed number.
retries 0 Re-run a failed test up to N times (fresh device each retry).
timeout 120000 Per-test timeout in ms.
node { local: true } Spawn a local SSE node, or connect to { url }.
reporter / reportDir list / .appclaw/runs Reporter and output directory.

AppClaw options (pass-through)

The config extends the full AppClawOptions surface, so any per-session option can be set directly on the config and is forwarded to every test: platform, provider, apiKey, model, agentMode, maxSteps, waitTimeout, scrollMode, video, locatorCache, capabilitiesFile, and more. See the SDK Options Reference. The Runner manages mcpTransport/mcpHost/mcpPort/deviceUdid itself (overridden per session) so you can't accidentally break device pinning.

Running on Android & iOS

A run targets one platform — set by platform in the config, or overridden with --platform android|ios. The whole run (device pool, sessions, report) pins to that platform.

Your specs don't change: AppClaw drives by natural language + DOM/vision, not platform-specific selectors, so the same tests/login.spec.ts runs on both OSes. Only the config differs (platform + capabilities). The clean setup is two config files sharing one tests/ folder, since capabilitiesFile is per-platform:

project layout
appclaw.android.ts   // platform: 'android', capabilitiesFile: './caps/android.json'
appclaw.ios.ts       // platform: 'ios',     capabilitiesFile: './caps/ios.json'
tests/               // shared specs — unchanged across platforms
package.json
{
  "scripts": {
    "test:android": "appclaw test -c appclaw.android.ts",
    "test:ios":     "appclaw test -c appclaw.ios.ts",
    "test:all":     "npm run test:android && npm run test:ios"
  }
}

If both platforms can share a single caps file, skip the second config and just flip the flag: appclaw test --platform android / --platform ios.

One platform per run — by design

Each invocation discovers its own device pool and writes its own report under .appclaw/runs/. In CI this maps cleanly onto separate jobs — an Android build/runner on one machine and an iOS build on a Mac runner — each running npm run test:android or test:ios against its own devices. There is no single-command Android+iOS matrix; run the two scripts (sequentially via test:all, or as parallel CI jobs).

Lifecycle Hooks

Hooks run at four scopes. All are optional — unset hooks are simply skipped.

Hook Scope Runs
globalSetup / globalTeardown run Once per run. globalSetup's return value is injected into every test as ctx.state.
deviceSetup device Once per device, before its first test.
beforeAll / afterAll scope (file / describe) Once per (scope, device) — see note below.
beforeEach / afterEach test Around every test.
appclaw.config.ts — hooks
defineConfig({
  globalSetup: async ({ pool }) => {
    const token = await loginViaApi();
    return { token };           // → ctx.state in every test
  },
  globalTeardown: async ({ state }) => { await cleanup(state.token); },

  deviceSetup: async (app, ctx) => { await app.run('grant all permissions'); },
  beforeEach:  async (app, ctx) => { await app.run('reset app'); },
  afterEach:   async (app, info) => { /* info.status, info.durationMs */ },
});

beforeAll / afterAll are registered inside spec files (file-scoped at the top, or inside a describe):

tests/checkout.spec.ts
import { test, describe, beforeAll, afterAll } from '@appclaw/runner';

beforeAll(async (app) => { /* once per device, before this file's tests */ });

describe('Cart', () => {
  beforeAll(async (app) => { /* once per device, for this group */ });
  test('add item', async (app) => { /* … */ });
});

Execution order (on a device)

order
deviceSetup → beforeAll (file → describe) → beforeEach → test → afterEach
           → … → afterAll (describe → file, reverse) at device drain
Once per (scope, device)

beforeAll/afterAll run once per (scope, device), not once globally. Every device that runs tests from a file/describe gets that scope's beforeAll before its first such test, and afterAll when it finishes its queue. This preserves per-test parallelism (a file's tests can spread across devices). Use them for device/run-level setup (seed/clean data, start/stop a service), not for "immediately after this block" timing.

Fixtures

Fixtures are reusable setup a test opts into by destructuring it from the first argument — the Playwright model. They are lazy (built only when a test asks), support dependency injection, and use the use() pattern for setup/teardown.

tests/fixtures.ts — define once
import { test as base } from '@appclaw/runner';
import type { AppClaw } from '@appclaw/core';

// loggedInApp: taps login, hands the app to the test, then tears down.
export const test = base.extend<{ loggedInApp: AppClaw }>({
  loggedInApp: async ({ app }, use) => {
    await app.run('Click on login button');   // setup
    await use(app);                            // hand to the test
    // teardown here, after the test
  },
});
tests/home.spec.ts — consume by destructuring
import { test } from './fixtures.js';

test('list is visible', async ({ loggedInApp }) => {
  await loggedInApp.verify('the list is visible');
});
Concept Behavior
Lazy A fixture is built only if a test (or a requested fixture) destructures it.
Dependency injection A fixture declares deps by destructuring them ({ app }); they're built first.
use(value) Code before use is setup; code after is teardown (run in reverse dependency order).
test.extend(defs) Compose new fixtures onto a test; returns a new, typed test.

Built-in fixtures

Destructure these without defining anything: app (the AppClaw on the leased device), device, state (from globalSetup), title, and retry. Your own fixtures can depend on them.

Built-in Type What it is
app AppClaw The session on the leased device — fresh per test.
device Device name, udid, platform, state of the leased device.
state State Whatever globalSetup returned — shared across the run.
title string The current test's title.
retry number 0-based retry attempt (0 = first try).

Returning data from a fixture (device info + an API user)

A fixture isn't limited to the app — it can do any setup and return data the test needs, by passing it to use(value). The test reads it back by destructuring the fixture's name, fully typed. A common case: provision a user through your backend before the test and tear it down after. The built-in device is available to both the fixture and the test.

tests/fixtures.ts — a fixture that provisions and returns a user
import { test as base } from '@appclaw/runner';
import { createUser, deleteUser, type ApiUser } from './api.js';

export const test = base.extend<{ apiUser: ApiUser }>({
  apiUser: async ({ device }, use) => {
    const user = await createUser(device.name);   // setup — POST /users
    await use(user);                            // hand the data to the test
    await deleteUser(user);                      // teardown — DELETE /users/:id
  },
});
tests/api-user.spec.ts — read device info + the returned data
import { test } from './fixtures.js';

test('sign up a fresh user', async ({ app, device, apiUser }) => {
  // device info — name, udid, platform, state
  console.log(`on ${device.name} (${device.udid})`);

  // fixture data — typed as ApiUser, so apiUser.email autocompletes
  await app.run(`Type ${apiUser.email} into the email field`);
  await app.run(`Type ${apiUser.password} into the password field`);
  await app.run('Click on login button');
  await app.verify('the home screen is visible');
});
Type the return value

Declare the fixture's type in extend<{ apiUser: ApiUser }>. The value you pass to use(...) is checked against it, and the test sees apiUser as ApiUser — not any. The fixture's own { device } is typed too, so device.name autocompletes.

Scope: test vs worker

By default a fixture is test-scoped — built fresh for every test, torn down right after. Pass { scope: 'worker' } (the [fn, options] tuple form) to build it once per worker (= once per device), reuse it across that worker's tests, and tear it down once when the worker finishes. Use it to avoid repeating expensive setup (accounts, tokens, seeded data, a mock server) on every test.

tests/fixtures.ts — worker-scoped
export const test = base.extend<{ account: Account }>({
  // built once per device, shared by all its tests
  account: [async ({ device }, use) => {
    const acct = await createAccount();   // setup once
    await use(acct);
    await deleteAccount(acct);            // teardown once, at worker drain
  }, { scope: 'worker' }],
});

test('uses the shared account', async ({ app, account }) => {
  await app.run(`sign in as ${account.email}`);
});
Scope Built Good for
test (default) Per test Per-session state — login, per-test data.
worker Once per device Shared resources — account, token, seeded data, mock server.
Worker fixtures can't depend on the session

Each test gets a fresh Appium session, so the built-in app is test-scoped. A worker-scoped fixture may only depend on other worker fixtures or the worker-stable built-ins device and state — depending on app (or any test-scoped fixture) throws. Worker scope sets up the world once; test scope (and beforeEach) gets each session into the right state.

Positional form still works

The legacy signature async (app, ctx) => { … } is unchanged — the runner detects whether the first parameter is destructured ({ … } → fixtures) or positional (app → legacy). For a single argument, prefer the object form async ({ app }) => { … }: a lone (app) is indistinguishable from a destructured arg to TypeScript, so the object form keeps the types precise (use the positional form when you also need ctx).

Parallelism

The Runner runs tests across all your devices at once. The unit of parallelism is the test: every test goes into one shared queue, and each device-worker pulls the next test when free (work-stealing). A device is held by its worker for the whole run (sticky lease), and each test gets its own isolated Appium session — unique systemPort / mjpegServerPort / wdaLocalPort and a pinned UDID — so concurrent sessions never collide.

What runs in parallel — tests, not files

A common assumption is that each *.spec.ts file runs on its own device. It doesn't. The runner flattens every file into a single queue of tests, and each device-worker pulls the next test when free — so two tests from the same file routinely run on different devices at the same time.

Notice the login.spec.ts tests (teal) landed on both devices — the file was never the unit of work; each test was.

How many run at once

workers
workers = min( request , devices , tests )
                  │         │         └ can't have more workers than tests
                  │         └ hard ceiling: number of discovered devices
                  └ concurrency / --workers

Ways to control it

Lever Effect
concurrency: 'auto' (default) One worker per discovered device.
concurrency: N (config) Cap workers at N.
--workers N (CLI) Override config for this run (CLI wins).
device count Hard ceiling — can't exceed connected devices.
test count Ceiling — 1 test never uses 2 workers.

Examples

Tests × Devices What happens
2 tests, 2 devices Both run at once, one per device. Wall-clock ≈ the slower test.
3 tests, 2 devices Two run immediately; the 3rd goes to whichever device frees first. One device runs 2 tests, the other 1 (≈ 2× a single test).
1 test, 2 devices One worker; the second device sits idle (can't split one test).
4 tests, 1 device All run sequentially on the single device.
Terminal — run 2 devices in parallel
appclaw test --workers 2

# Summary shows which device each test ran on:
#   ✓ Login › user can sign in   [emulator-5554]
#   ✓ Cart  › add item          [emulator-5556]
Scope & sharding

The current Runner uses one local appium-mcp node, so max real parallelism = the number of locally-connected devices. deviceSetup and beforeAll run once on each participating device. Sharding (--shard x/n) is a separate, cross-machine mechanism — it splits the test set across runs and pairs with --workers for parallelism within each.

CLI Reference

appclaw test [filter…] [options]. The positional filter narrows by file path or substring within testDir. Run it via an npm script ("test": "appclaw test") or directly with npx appclaw test ….

Flag Description
[filter…] File path, folder, or substring to run a subset.
-c, --config <file> Config file (default: appclaw.config.{ts,js}).
--env-file <path> Load a dotenv file before config (alias: --env-path). A .env in the cwd auto-loads.
--workers <n> Parallel workers (default: device count).
--retries <n> Retry failed tests up to n times.
--timeout <ms> Per-test timeout.
--grep <regex> Run only tests whose title matches.
--grep-invert <regex> Skip tests whose title matches.
--shard <x/n> Run shard x of n (split across machines).
--reporter <name> Reporter: list, html.
--platform <p> android or ios.

Reports & Cleanup

Every test writes its own run data to .appclaw/runs/ (screenshots per step, status, timing), the console prints a file-grouped summary table, and the Runner generates a self-contained HTML report for that run. The process exit code is 0 when all tests pass, 1 otherwise — so it drops straight into CI.

Run Summary
Results

tests/login.spec.ts (2)
  ✓ Login › user can sign in        emulator-5554  6.1s
  ✓ Login › toggle remember-me      emulator-5556  7.0s
tests/cart.spec.ts (1)
  ✗ Cart  › add item                emulator-5554  3.2s  ↻1
      expected badge "1", found "0"

──────────────────────────────────────────────────────────
✓ 2 passed  ✗ 1 failed  in 16.3s  · emulator-5554: 2  emulator-5556: 1
→ report  .appclaw/runs/suite-…/index.html

The HTML report

Open the printed path in a browser. The report is scoped to the current run only — it never folds in past runs — and is built for mobile: a pass-rate summary, a per-device breakdown, results grouped by spec file, and, on expanding a test, a device-framed screenshot of every step, the failure reason, and a screen recording when one was captured.

Current run vs. history

The report at the end of appclaw test shows just that run. The appclaw --report viewer is a separate index of every historical run — grouped by suite and spec file, with a search bar, platform filters, and a 14-day trend. Use it to browse past results, not to read the run you just executed. Pass --report-dir <path> to serve a project from anywhere.

Cleanup is automatic and layered:

  • Per test — the session is deleted on teardown, which frees its adb port forwards and the on-device server.
  • End of run — the local appium-mcp node is stopped.
  • On interrupt — a signal guard (Ctrl-C / SIGTERM / crash) kills the node so it is never orphaned.

View the HTML reports any time with appclaw --report (add --report-dir <path> to serve reports from a location outside cwd).