Quick Start
Install AppClaw globally and start automating in seconds.
# 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.
# 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:
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
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:
cd my-app npm install # paste your key into .env → LLM_API_KEY=... npm test
Or set it up manually
# 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.
{
"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" }
}
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:
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
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.
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.
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.
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.
# 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:
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.
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.
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.
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:
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:
name: Self-contained flow env: variables: app_name: youtube search_term: appium 3.0 --- - open ${variables.app_name} app - type ${variables.search_term}
--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.
# 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.
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.
- 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.
- 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.
- 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) |
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.
# 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"
- type: "hello world"
Wait / Pause
Pause execution for a fixed duration.
- wait 3s - wait 1.5 seconds - sleep 500ms - pause 2 sec - wait # defaults to 2 seconds - wait a moment # defaults to 2 seconds
- 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 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 until loading spinner is gone - wait for the popup to be hidden - wait until progress bar disappeared
- wait until screen is loaded - wait until screen is stable - wait 5s until screen is ready
# 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"
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.
- scroll down - scroll up 3 times - swipe left - swipe right 2 times
# 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"
- 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).
- drag the green circle slider to the +100 mark - slide the price handle to +80 - move the volume knob to maximum
# "drag: from to to" - drag: "green circle slider to +100 mark"
- drag: from: green circle slider to: +100 mark
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.
- verify "Welcome back" is visible - assert Dashboard is visible - check that the login button is on the screen - verify TestMu AI is visible
- 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.jsonin.envor 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.
{
"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.
{
"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:
- Built-in defaults (MJPEG port, app from
APP_PATH, etc.) -
Your capabilities file (top-level /
common/ platform-specific, in that order) -
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.
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:appin the file takes precedence over theAPP_PATHenv var, which itself is overridden by a per-flowapp: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, orlt: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.
# 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.
# 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
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:
{
"lt:options": {
"video": true,
"network": true,
"deviceOrientation": "PORTRAIT",
"idleTimeout": 300
}
}
Load it via CLI or SDK the same way you do for local runs:
appclaw --caps ./caps.json --flow flows/checkout.yaml
CLI usage
Once .env is configured, run AppClaw exactly as you would locally:
# 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:
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.
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.
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()orrunGoal()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 yourafterAllhook 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.
npm install appclaw
Create a .env file in your project root (or pass options directly to the
constructor — see Options Reference).
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.
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.
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();
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.
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.
// 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 |
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.
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:
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() 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.
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:
const app = new AppClaw({ platform: 'android', agentMode: 'dom', locatorCache: true });
Or isolate the cache file and namespace for CI:
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:
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.
{ "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
- AppClaw reads page source and builds a cache key from namespace, platform, app ID, semantic screen fingerprint, action kind, and label.
-
If an entry exists, AppClaw calls one Appium lookup:
findElement(strategy, selector). - If Appium returns a fresh element UUID, AppClaw performs the action with that UUID.
-
On success, the report manifest records
"cacheHit": truefor that step.
{ "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:
- Try the cached locator, if a cache entry exists.
-
If the cached
findElementreturns no element, do not fail and do not mark the cache stale. The page may still be loading. - Fall back to normal DOM parsing, scoring, and locator probing.
- If a matching element is found, perform the action and update the cache.
- If no match is found, wait
waitIntervaland try again.
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:
- The cached lookup for
login1returns no element. - AppClaw falls back to DOM scoring in the same poll attempt.
- The matching element is found and tapped.
-
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:
cat ~/.appclaw/locator-cache.json
To see whether a step used the cache, inspect the latest SDK report manifest:
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.
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().
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
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:
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
.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):
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.
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:
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.
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):
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:
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:
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.
uses: AppiumTestDistribution/AppClaw@v1 with: flow: flows/login.yaml platform: android api-key: ${{ secrets.LLM_API_KEY }}
Quick Start
Android — run a YAML flow
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
- 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
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
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.
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)
- 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
- 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
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: trueis set in your flow)
Use report path in a downstream step
- 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.
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.
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.
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
-
Custom guide —
.appclaw/guides/<appId>.md(highest priority, overrides built-ins) - Built-in guide — bundled guides for 10 common apps
- No guide — agent explores the app from scratch using only what it sees on screen
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 |
| Android | com.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
## 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
## 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
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.
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
-
Find your app's package name (Android) or bundle ID (iOS). You can get this from the
appIdfield in your YAML flow, or by inspecting the device. - Create the directory
.appclaw/guides/in your project root. - Write a Markdown file named
<appId>.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.
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) |
Installation
Install globally so appclaw-agent is available in any terminal session.
npm install -g @appclaw/agent
Verify the install and read the built-in workflow guide — agents should run this before starting any session:
appclaw-agent help workflow
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.
Workflow
Every session follows the same five-step pattern. Re-snapshot after every state-changing action — refs are invalidated when the screen changes.
# 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:
{ "ok": true, "session": "login", "message": "Snapshot contains 12 element(s)", "output": "@e1 [EditText] \"Email\" id=\"com.example:id/email\"\n...", "data": { "elements": [ ... ] } }
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 |
Any command that changes screen state (press, fill,
scroll, hardware keys) invalidates all current @eN refs. Run
snapshot -i --json again before referencing elements.
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.
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 |
# 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
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.
# 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
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.
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.
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.
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:
appclaw test --env-file ../../.env appclaw test --env-file .env.ci --workers 2
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:
appclaw.android.ts // platform: 'android', capabilitiesFile: './caps/android.json' appclaw.ios.ts // platform: 'ios', capabilitiesFile: './caps/ios.json' tests/ // shared specs — unchanged across platforms
{
"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.
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. |
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):
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)
deviceSetup → beforeAll (file → describe) → beforeEach → test → afterEach
→ … → afterAll (describe → file, reverse) at device drain
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.
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 }, });
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.
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 }, });
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'); });
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.
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. |
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.
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 = 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. |
appclaw test --workers 2 # Summary shows which device each test ran on: # ✓ Login › user can sign in [emulator-5554] # ✓ Cart › add item [emulator-5556]
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.
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.
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).