# Set up your agent (/docs/agent-setup) Your coding agent needs the warden skill to know the claim, drive, release rules. Run `warden install`, or paste the prompt below into the agent. ```bash warden install # skill + rule + SessionEnd hook for every detected agent; shows a diff, asks first ``` ## Paste this into your agent [#paste-this-into-your-agent] ```text Set up warden for mobile device work in this repo. 1. Run `npm i -g @delacour/warden && warden install` (or `bunx @delacour/warden install`). Approve the diff. 2. Run `warden doctor` and fix anything it reports. 3. Run `warden skill show` and read it. Follow its rules from now on: claim a device with `warden claim ios --json`, pass the returned udid on every call, drive it with `warden ui`, never touch a device you did not claim, and release with `warden ui close --release`. 4. In an Expo app, run `warden init --bundle-id ` and install the packages it names. ``` ## What gets installed [#what-gets-installed] | Piece | Where | Agents | | --------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Skill | `skills/warden/`: `SKILL.md` + `references/{ui,specs,suites,troubleshooting}.md`, embedded in the binary | anything the [`skills`](https://github.com/vercel-labs/skills) CLI installs into | | Rule | `~/.claude/rules/warden.md`, written directly | Claude Code | | SessionEnd hook | `warden hook session-end` | Claude Code, Codex | The skill loads into Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, OpenCode, Windsurf, Zed and the other agents the `skills` CLI supports. Hooks exist only for Claude Code and Codex. Agents are detected from `~/.claude` / `claude` on `PATH` and `$CODEX_HOME` (`~/.codex`) / `codex` on `PATH`; with none found, only the binary is installed. The skill is placed by the `skills` CLI, which may prompt (`--yes` is passed through). ## Per agent [#per-agent] ```bash warden install --claude # Claude Code only: skill (via `skills add`) + rule + SessionEnd hook warden install --codex # Codex only: hooks in $CODEX_HOME/hooks.json warden skill install # the skill, into any agent the skills CLI offers ``` Every agent-config change shows a diff and asks first. `--yes` skips the prompt, `--dry-run` only prints, and a non-TTY without `--yes` skips. `warden install --claude` installs the skill, the rule and the SessionEnd hook. It also removes the `PreToolUse` hook (`warden hook pretool`) that older versions installed. Details: [Agent rules](/docs/agents/rules#claude-code). `warden install --codex` merges the SessionEnd hook into `$CODEX_HOME/hooks.json`. Run `/hooks` in Codex once to trust it. Codex does not set a session id, so set `WARDEN_SESSION_ID`, or leases count as your shell's user leases. Details: [Agent rules](/docs/agents/rules#codex). ```bash warden skill install # global, this binary's copy warden skill install --from github # delacournz/warden, updatable via `skills update` warden skill install --project -a cursor -y # into the current project, one agent warden skill show | pbcopy # raw SKILL.md to paste by hand ``` There is no session hook for these agents. Their leases expire by TTL (5 min for a bare claim) and `warden gc` reclaims them, so tell the agent to run `warden ui close --release` when it is done. ## Verify [#verify] ```bash warden doctor ``` `doctor` checks hooks, the skill and the rule among other things. Then ask the agent to run `warden claim ios --json` and `warden ui snapshot -i`. # warden dev (/docs/agents/dev) `warden dev` replaces `expo run:ios` in a worktree: it claims a device, installs the dev build cached for your native fingerprint, starts Metro on a leased port and opens the dev client. Use it whenever you or your agent need the app running. ```bash warden dev # ios by default warden dev android # an emulator warden dev ios -- --clear # extra args go to `expo start` warden dev ios --json # one line { udid, port, metroUrl, leaseIds } once Metro is ready ``` ## What it does [#what-it-does] 1. Checks the bundle id and `expo-dev-client`. 2. Claims a device and leases a Metro port (default range `8081:100`). 3. Installs the build for this fingerprint: installed, cache, EAS, then local build. 4. Starts Metro on the leased port. 5. Opens the dev client once the port is verified to serve this checkout. `warden dev` owns its Metro. A Metro left running by another worktree is refused, never attached to. It holds the device and the port until Metro exits; Ctrl-C releases both. The `--json` line is printed first, so an agent can read the `udid` and `metroUrl` and pass them to `warden ui open --metro `. Metro gets `WARDEN_WORKTREE` (the checkout directory name) and `WARDEN_BRANCH` (absent on a detached HEAD) in its environment, so `app.config.ts` can name the dev server after the worktree. Native builds never see them, so the fingerprint is unchanged. ## Why not `expo run:*` [#why-not-expo-run] `expo run:ios` rebuilds natively in every worktree: Xcode's DerivedData is keyed by the checkout's path, so a fresh worktree compiles every pod again even when the native code is identical. warden caches the built app by native fingerprint, shared by every worktree of the repo. * Same fingerprint as any earlier build (any worktree, EAS or local): no prebuild, no Xcode, seconds to a running app. * A native change: one local build, cached for every other worktree. * Two worktrees missing the same hash at once share one build (build lock). ## Options [#options] | Flag | Effect | | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--profile ` · `--runtime ` | device profile (`iphone-17`, `pixel-10`) and runtime (`latest`, `26.5`) | | `--udid ` · `--lease ` | use this device instead of claiming one | | `--port ` | Metro port range to lease from (default `8081:100`) | | `--project ` · `--bundle-id ` | project directory (default cwd); override the bundle id / package | | `--variant ` | `projects[].variants` build (default `dev` when the project has one) | | `--scheme ` | dev-client URL scheme (default `exp+`) | | `--ready-timeout ` | wait this long for Metro (default `2m`) | | `--no-eas` · `--no-build` | skip EAS downloads; do not build locally on a cache miss | | `--install-mode clean\|reinstall` | `clean`: fresh app data each run without reinstalling an unchanged build; `reinstall`: uninstall + install each run. `--reset-keychain` also resets the simulator keychain | | `--no-open` | start Metro and take the leases only | | `--wait ` · `--no-wait` · `--priority

` | boot-queue behaviour (default `--wait 30m`) | | `--headless` · `--no-headless` | close or keep the Simulator.app windows | | `--count ` · `--max ` · `--ttl ` · `--label

--skill warden -g -a claude-code -y`, `npx` fallback; `--dry-run` prints the command). The CLI places it at `~/.agents/skills/warden` and symlinks `~/.claude/skills/warden`. Without bun or node the step is skipped; use `warden skill show`. The rule `~/.claude/rules/warden.md` is written directly. A leftover `~/.claude/rules/agent-device.md` or `argent.md` from the retired setup is reported by `warden install` but never deleted. ## Codex [#codex] `warden install --codex` merges the SessionEnd hook into `$CODEX_HOME/hooks.json` (default `~/.codex/hooks.json`). Hooks already declared in `config.toml` `[[hooks.*]]` tables count as installed. Codex hooks use Claude's format and stdin JSON, so `warden hook session-end` serves both agents unchanged: ```json title="~/.codex/hooks.json" { "hooks": { "SessionEnd": [{ "hooks": [{ "type": "command", "command": "$HOME/.local/bin/warden hook session-end", "timeout": 3 }] }] } } ``` Codex **skips new or changed hooks until you trust them**: run `/hooks` in Codex once after installing. * Codex caps SessionEnd hooks at 3 s and always sends reason `other`, so the `/clear` exception never applies. Anything SessionEnd can't finish in time is picked up by `warden gc` once the leases go stale. * For the skill in Codex, use `warden skill install` (pick the agent with `-a`). `warden skill install --from github` installs the updatable copy from the release repo instead of this binary's embedded one. * Codex sessions have no session id of their own: set `WARDEN_SESSION_ID`, or leases count as your shell's user leases. # warden ui (/docs/agents/ui) `warden ui` is how your agent looks at a device and drives it: read the screen as text, tap, type, scroll, wait, read the app's console and React tree. Use it to explore, reproduce a bug, verify a change, or get the selectors for a spec. ```bash warden claim ios --json # lease a sim, keep the udid warden ui open com.example.app --udid $UDID # launch warden ui snapshot -i --udid $UDID # the screen as @eN lines warden ui press @e3 --settle --udid $UDID # act, print what changed warden ui close --release --udid $UDID # stop the helper, release the lease ``` It runs on the same driver as [`@delacour/warden-e2e`](/docs/e2e/specs) (see [iOS driver helper](/docs/reference/e2e-driver)), so there is nothing to install on the device and no daemon to manage. For checks that must keep passing, write a [spec](/docs/e2e/specs). Every call is stateless on the command line: the last snapshot and the Metro URL live under `$WARDEN_HOME/ui//`. ## The agent loop [#the-agent-loop] ```bash warden claim ios --json # 1. a device: use the returned udid on every call warden ui open com.example.app --udid $UDID # 2. launch (add --metro for a dev client) warden ui snapshot -i --udid $UDID # 3. what is on screen, as @eN lines warden ui press @e3 --settle --udid $UDID # 4. act; --settle prints what changed warden ui wait id=todos-screen --udid $UDID # 5. verify with wait / is / get, not a screenshot warden ui close --release --udid $UDID # 6. stop the helper and release the lease ``` 1. **Claim.** `warden claim ios --json` (or `android`); keep the returned `udid` (Android: the serial). Pass it on every call as `--udid ` (`--serial` / `--lease ` also work), or export `WARDEN_UDID` once. With none of those, `warden ui` uses the single device you already lease. 2. **Open.** A bundle id launches the app; a URL opens through the system. For a dev client, run [`warden dev`](/docs/agents/dev) or `warden app ensure` first, then `open --metro ` (iOS; on Android see [Android](#android)). 3. **Snapshot.** `snapshot -i` lists only interactive elements and anything with a `testID`. The `@eN` refs it prints are valid until the next `snapshot`, `find` or `--settle`. 4. **Act on a ref or a selector.** Prefer `id=` selectors for anything you will touch twice: refs go stale when the screen changes. 5. **Verify** with `wait`, `is` and `get`. A bare screenshot is not verification. 6. **Close.** `close --release` stops this device's helper and releases the lease. Leave the device alone only if another step of your task needs it. ## Record a session as a spec [#record-a-session-as-a-spec] Between `warden ui record start ` and `record stop`, every action and query is journaled (`--no-record` skips one call) and `stop` writes a [spec](/docs/e2e/specs) and runs it once on the same device. While recording, `snapshot -i` marks interactive elements with no testID `[no-id]`, and `status` shows the recording. `record use ` runs a helper on the device and records a call to it. Full workflow, route capture, lints and helpers: [Author specs by recording](/docs/e2e/author-specs). ## Reading the screen [#reading-the-screen] ```text $ warden ui snapshot -i --udid $UDID generation 4 · axbridge · 393×852@3x · 5 of 41 elements @e1 [textbox] "Add a todo" id=todo-input value="" enabled hittable (24,118 345×48) @e2 [button] "Add" id=todo-add enabled hittable (24,176 345×48) @e3 [checkbox] "Write specs" id=todo-1 unchecked enabled hittable (24,244 345×56) ``` A line is `@ref [role] "name" id= value="…" (x,y w×h)`. States are `enabled`/`disabled`, `hittable`, `checked`/`unchecked`, `selected`, `focused`, `secure`, `hidden`, `expanded`/`collapsed`; the frame is `(x,y w×h)` in the screen units the header reports. The header says how many elements are shown and whether the backend truncated the tree. | Command | Use | | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `snapshot [-i\|--interactive] [--all] [--depth n]` | the screen. `-i` filters to interactive elements and `testID`s; the default caps at 200 lines (`--all` removes the cap); `--depth n` keeps the first `n` levels | | `snapshot --settle [ms]` | wait for a quiet screen (the tree hash is unchanged for 250 ms; default budget 3000 ms), then print what changed against the previous snapshot as `-` / `+` lines and a `= N unchanged` count | | `find ` | only the elements matching a [selector](#targets-and-selectors), each with a fresh `@eN` | | `screen screenshot [path]` | a PNG; default `$WARDEN_HOME/ui//shots/NNN.png` | | `screen video start [path]` · `screen video stop` | record the screen (iOS `simctl recordVideo`, Android `screenrecord`, 3 min device limit); default `$WARDEN_HOME/ui//videos/NNN.mp4` | `snapshot` and `find` bump the **generation** (`generation 4` above). So does anything else that takes a snapshot for you: an action with `--settle`, a `wait` that matches, a `scroll --until` that finds its target. The refs you held before are replaced. ## Targets and selectors [#targets-and-selectors] Actions take a **target**: an `@eN` ref, a selector, or screen coordinates `x y` (points). Coordinates are for canvas content with no accessibility node; everything else should be a selector. **Refs.** `@eN` comes from the latest snapshot of this device. A ref older than 30 s, or any ref with `--verify`, is re-checked against a fresh snapshot first (same id, else same role and name, else same position in the tree). When the element is no longer there the call fails with `stale_ref` (exit 4): take a new snapshot. **Selectors** are `key=value` words joined by spaces: | Key | Matches | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `id=` | the `testID` / accessibility identifier, exactly | | `label=` | the accessibility label, case-insensitive substring | | `text=` | the visible text, case-insensitive substring | | `value=` | the element's value (field content, switch state), case-insensitive substring | | `role=` | the normalized role (`button`, `textbox`, `checkbox`, `switch`, `text`, `heading`, …), the same names on iOS and Android | | `editable=true\|false` | text fields vs everything else | | `nth=` | the i-th match (0-based) when several remain | A value is bare (`id=sign-in`), quoted when it has spaces (`label="Sign in"`), or a regex (`text=/^\d+ left$/i`). Keys combine with AND: `role=button label=add`. When a selector matches several elements, an action picks the most specific visible one: visible first, then the most exact text terms, then the smallest frame, then reading order. Pin a different one with `nth=`. `find` lists every match, and `get count` counts them; `wait`, `is` and `get` use the same most-specific pick as an action. ## Acting [#acting] Every action accepts `--settle [ms]`: wait for a quiet screen afterwards (default budget 3000 ms) and print what changed, so the next ref is already on your screen. | Command | Effect | | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `open [--relaunch] [--metro ] [--args …]` | launch the app, or open a URL (on iOS the "Open in …?" sheet is accepted). A bundle id that is already running is attached to, not restarted (`attached to `, `--json` `attached: true`); `--relaunch` starts it fresh. `--metro` starts the dev client on that Metro and remembers it for the devtools commands | | `press ` (alias `tap`) | tap. `--verify` re-checks an `@eN` first | | `fill ` | focus the field, empty it, type, then read it back (retried once). `--secure` skips the read-back and never echoes the text; `--no-verify` skips only the read-back. Use `--` before text that starts with `-` | | `type [--submit]` | type into whatever has focus; `--submit` then presses Enter | | `key ` | `Enter` `Backspace` `Tab` `Space` `Escape` `Delete` `Up` `Down` `Left` `Right` | | `scroll [amount]` | scroll the content that way (`down` reveals what is below). `amount` is the share of the region one swipe travels (0-1, default 0.5). `--in ` scrolls inside an element; `--until ` swipes until the selector is on screen (at most `--max` swipes, default 10) and prints its fresh ref | | `gesture swipe [--from ]` | a raw finger swipe: `gesture swipe up` moves the finger up, which reveals what is below. `--from` swipes inside an element | | `gesture longpress [ms]` | press and hold (default 800 ms). `--verify` re-checks an `@eN` first | | `gesture pinch [target] ` | two fingers around an element, a point or the screen centre; `scale` above 1 zooms in, below 1 zooms out; 0.1 to 10, not 1. `--duration` (default 500 ms), `--radius` (finger distance from the centre, a positive number) | | `gesture rotate [target] ` | two fingers turn around the target or the screen centre; positive is clockwise, negative works: `gesture rotate @e3 -45`; not 0, at most 720 either way. `--duration` (default 500 ms), `--radius` | | `gesture path --path 'x,y x,y …' [--path …] [--duration]` | trace 1-2 finger paths (each at least 2 points, on the screen; points split on spaces or `;`); the fingers go down and lift together. `--duration` defaults to 600 ms. Coordinates are points on iOS and px on Android, the same as `x y` targets | | `alert accept\|dismiss\|get` | answer the system alert or sheet on top, or read its title, message and buttons. With no alert showing, `get` succeeds with an empty `buttons` list (`--json`: `present: false`) and `accept` / `dismiss` fail with `no_alert` | | `home` · `back` | the home button; back is Android only | | `screen rotate [orientation]` | `portrait`, `portrait-upside-down`, `landscape-left`, `landscape-right`; without an argument it reads the current one | | `screen appearance [light\|dark]` | set or read light / dark mode | ### `screen` vs `gesture` [#screen-vs-gesture] `screen …` acts on the device's screen: orientation, appearance, screenshots, video. `gesture …` is touch. The same verb can name either job: `screen rotate landscape-left` turns the device, `gesture rotate @e3 45` turns two fingers around an element. This replaced the old top-level `rotate`, `appearance`, `screenshot`, `video`, `swipe` and `longpress`, which are removed (no aliases; they fail as unknown commands). `pinch` and `rotate` take the amount last. The target before it is optional: an `@eN` ref, a selector, `x y`, or nothing for the screen centre. `pinch`, `rotate` and `path` accept `--settle`; `pinch` and `rotate` also `--verify`. A scale or degrees out of range, a lone coordinate, or a bad `--path` is a `usage` error (exit 1) with the syntax as the hint; a device that cannot do the gesture fails `failed` with `details.driver: E_UNSUPPORTED` (exit 1), and nothing is sent. Both platforms cap a gesture at 2 fingers, which go down and lift together (the iOS simulator's HID event carries one phase for both contacts). ## Waiting and asserting [#waiting-and-asserting] These poll the live screen every 100 ms for the first second, then every 250 ms (`wait stable` keeps 100 ms), for up to `--timeout` (default 5000 ms) and never need a screenshot. | Command | Result | | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `wait ` | until a match is on screen; prints it with a fresh `@eN` (the snapshot it matched in becomes the new generation) | | `wait absent ` | until no match is visible | | `wait text "…"` | until that text is visible | | `wait stable [ms]` | until the screen has stopped changing for that long (default 250 ms) | | `is visible\|hittable\|enabled\|checked\|selected\|focused` | prints `true` / `false`; exit 0 when true, 1 when false or nothing matches: usable in a shell `if` (`--json`: `{ matched, state, element?, waitedMs, reason? }`) | | `get text\|value\|label\|id\|role\|frame\|count` | print one bare value (`frame` as `x y w h`; `count`: how many match, `0` is a success); no match is `target_not_found` | A `wait` that runs out fails (exit 1) with `wait_target_absent` (never in the tree), `wait_target_hidden` (in the tree, not visible), `wait_target_present` (`absent`: still visible), `wait_text_absent` or `wait_not_stable`. `--json` failures carry `details: { matched: false, waitedMs, reason }`. Into an active spec, `wait ` (as a visible check), `is` and `get` (a text property) are recorded; `wait absent|text|stable` are not. ## The app's console, network and React tree [#the-apps-console-network-and-react-tree] For a dev client launched with `open --metro `. A release build, or a dev client with no Metro, fails with `devtools_unavailable` (exit 3). `--metro ` overrides the Metro for one call; otherwise warden uses `WARDEN_METRO_URL`, the URL `open --metro` remembered, then the Metro behind the ports your session leases. | Command | Effect | | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `eval [--await]` | run JavaScript in the app (Hermes) and print the value; an exception is reported as `failed` with its details | | `tree [--all] [--max n] [--screen ]` | the React components with `testID`, label, text, screen and rect. Use it for a `testID` the accessibility tree does not show; `--all` includes inactive screens | | `logs start [--bodies]` | start a background collector that records the app's console and `fetch` calls (`--bodies` also records bodies) | | `logs [n]` · `logs mark