Agent Skillssoftware-mansion/argent › argent-create-flow

argent-create-flow

GitHub

记录可复用的MCP工具调用序列(Flow),支持e2e和fragment两种类型,通过YAML保存步骤并现场验证执行。

packages/skills/skills/argent-create-flow/SKILL.md software-mansion/argent

Trigger Scenarios

用户请求创建、录制或构建自动化流程 脚本化设备操作序列 检测到多步交互即将重复时主动建议保存

Install

npx skills add software-mansion/argent --skill argent-create-flow -g -y
More Options

Non-standard path

npx skills add https://github.com/software-mansion/argent/tree/main/packages/skills/skills/argent-create-flow -g -y

Use without installing

npx skills use software-mansion/argent@argent-create-flow

指定 Agent (Claude Code)

npx skills add software-mansion/argent --skill argent-create-flow -a claude-code -g -y

安装 repo 全部 skill

npx skills add software-mansion/argent --all -g -y

预览 repo 内 skill

npx skills add software-mansion/argent --list

SKILL.md

Frontmatter
{
    "name": "argent-create-flow",
    "description": "Record a reusable flow (scripted sequence of MCP tool calls) that can be replayed later with a single command. Use when the user asks to create, record, or build a flow, or to script a sequence of device actions. Also used proactively, without an explicit request, when a multi-step interaction sequence is about to be repeated (re-profiling, re-testing, or a complex path worth saving)."
}

Overview

A flow is a sequence of steps saved to a .yaml file in the .argent/flows/ directory. Each recorded step is executed live as you add it, so you verify it works before it becomes part of the flow. Replay a finished flow with flow-execute, or — for an e2e flow — headlessly with argent flow run <name>.

Flows store no device id: the runner binds a device (the single booted one, or pass device/platform). A recorded coordinate gesture-tap is captured as a portable tap: { selector } step whenever the tapped element has stable text/identifier.

Two flow types

  • e2e — begins with a launch: step, which starts that app from scratch (terminate + relaunch), so the flow controls its own start state. No executionPrerequisite. May run: other flows, and (on iOS/Android) may itself be a run: target — when nested, its launch runs inline, restarting the app for that sub-scenario. Chromium is the exception: the runner boots one Electron app per run (the top-level flow's), so a nested chromium e2e flow's launch can't boot its own instance and fails the run — keep chromium e2e flows top-level. Record one by adding a restart-app of the app under test as the first step — it is captured as the launch step.
  • fragment — doesn't begin with a launch; runs against the device's current state. May declare an executionPrerequisite (a documented entry-state contract). Invoked from other flows via a run: step, or directly by you at any time.

Both run via argent flow run <name> — a fragment simply runs against whatever is on screen (its prerequisite is printed as a reminder). Only e2e flows are meaningful CI/suite entries, since only they give a deterministic verdict from a clean start.

Step directives

Beyond raw tool: steps and echo:, flows support declarative directives interpreted by the runner (they are not agent-callable tools). Every directive hard-stops the flow on failure; later steps are reported skip.

Directive YAML Meaning
launch - launch: com.acme.app or - launch: { ios: …, android: … } start the app from scratch (terminate + relaunch) and wait until ready
tap - tap: Login, - tap: { x: 0.5, y: 0.57 }, - tap: { on: Login, times: 2 }, - tap: { on: { x: 0.5, y: 0.57 }, times: 2 } tap by selector (auto-waits) or raw point; times (2 = double-tap) needs the target nested under on: — a selector or a point ({ x, y, times } is rejected)
long-press - long-press: Row 3, - long-press: { x: 0.5, y: 0.6 }, - long-press: { on: <sel>, duration: 1200 }, - long-press: { on: { x: 0.5, y: 0.6 }, duration: 1200 } press and hold an element or raw point (default 800ms; Chromium: mouse press-hold); duration needs the target nested under on: — a selector or a point
type - type: { into: email, text: "a@b.com" } focus a field, type, then press Enter to submit + dismiss the keyboard
scroll-to - scroll-to: "Order #1234" (scrolls down) or - scroll-to: { target: …, direction: right, within: … } momentum-free scroll until the target is visible
pinch - pinch: { on: "Map", scale: 3 } or - pinch: { scale: 0.5 } two-finger zoom in (scale > 1) or out (< 1); big scales chain gestures; on optional — defaults to screen center; open-loop — assert the visible result
rotate - rotate: { on: "Map", by: 90 } or - rotate: { by: -45 } two-finger rotation by degrees (+ CW, − CCW, within ±3000°; options map only); on optional — screen center default; not tool: rotate (orientation)
await - await: { visible: Home } wait for a UI condition
wait - wait: 500 pause for a fixed number of milliseconds (last resort — prefer await)
assert - assert: { visible: Welcome } check a condition, hard-fail if it never holds
snapshot - snapshot: home or - snapshot: { name: home, maxMismatch: 0.5, cropOn: { id: order-summary } } diff a screenshot — or one element's region — against a stored baseline
run - run: login execute another flow's steps inline (fragment or e2e)
when - when: { visible: "What's new" } + steps: [...] run a guarded step block only when the condition holds (no else)

Selectors

A selector is { text?, id?, role? } plus the optional scopes below (all-must-match; text/role are case-insensitive substrings, id matches the element's testID / accessibilityIdentifier / resource-id exactly, case-insensitive, also accepting the unqualified Android resource-id name — submit matches com.example.app:id/submit) — the same semantics await-ui-element uses, though that tool spells the id field identifier (flow YAML also accepts identifier as an alias for id, but id is the canonical spelling and what the recorder writes). A bare string is a loose selector: it resolves identifier-first, then falls back to text (label/value), so tap: Login matches a testID="Login" or, failing that, visible text "Login" — no need to know which. Loose fallback applies uniformly to every selector slot (tap, type.into, await, assert, scroll-to). Use the map form to be strict: { id: submit-btn } (identifier only) or { text: Login } (text only, no fallback).

text also takes a regex matcher map{ text: { matches: '^Order #\d+$' } }, in any selector slot — for dynamic text no literal can pin. It tests each node's native own label/value (not the adapter-hoisted subtreeText), though on iOS a container's own label may itself aggregate descendant text, so a wrapper and its leaf can both match. Same regex rules as text.in's matches (see await and assert): unanchored, case-sensitive, single-quoted, invalid pattern fails at parse. So assert: { visible: { text: { matches: '^Taps: \d+$' } } } asserts a counter is on screen with no locator at all, and tap: { text: { matches: '^Order #\d+$' } } taps a dynamic row — though a stable id stays the more robust action target.

Scopes — CSS combinators, read off frames

A map selector may also carry a scope: a nested selector naming another element the match must sit in a given spatial relation to. These are the geometric readings of the CSS combinators that survive a flattened tree — the child combinator > has no analog, since parent/child structure does not reach replay. They combine, and nest up to six scopes per selector:

Scope CSS Reads as Example
within: <sel> A B the match's frame sits inside the scope element's frame tap: { text: Delete, within: { id: profile-card } }
after: <sel> A ~ B the match follows the scope element in reading order assert: { visible: { role: Button, after: { text: Danger zone } } }
next: <sel> A + B as after, narrowed to the nearest follower of each anchor tap: { role: Switch, next: { text: Wi-Fi } }

And any: true is the CSS * universal selector. It carries no locator of its own, so the parser enforces two rules: it needs at least one scope (a bare any: true would match the whole screen), and it may not sit beside text/id/role (which it would only make redundant — write { role: Switch, next: … }, not { any: true, role: Switch, next: … }). Only the literal true is accepted. assert: { hidden: { any: true, within: { id: empty-state } } } asserts an empty container. It works for actions too — tap: { any: true, next: { text: Airplane Mode } } taps whatever sits right after that label — but on a real tree "whatever" includes spacers and wrappers, so name the target (role, id) whenever you can and keep any for conditions and for next, which reduces to a single element per anchor.

All of it is visual (frame-based), not tree ancestry — flow trees are flattened, and "inside the card" / "the switch after this label" mean what the screen shows — the same frame-based reading of "within" that scroll-to's container anchor uses. Every scope needs a distinct element (nothing scopes itself, so { id: card, within: { id: card } } needs two nested elements), the synthetic screen root never counts, and a scope only narrows where to look — the selector still needs its own text/id/role naming what to find there, or any: true. The nested slot takes every selector form: a bare string keeps the loose identifier-first fallback (within: profile-card), the map form stays strict, the regex matcher works (within: { text: { matches: '^Card \d+$' } }). Scopes are flow-YAML only; the raw await-ui-element tool's selector accepts none of them. Prefer a unique id on the target itself when one exists — reach for a scope when the target has no unique locator of its own (repeated row actions, per-card buttons, list cells).

withintap: { text: Delete, within: { id: profile-card } } taps the Delete button in the profile card even when other cards show identical ones; tap: { text: "Pin feed", within: { text: "For You" } } picks one card's button out of a whole list. Scope to a container with a tight frame (a row, card, dialog, toast): a full-screen wrapper contains everything and scopes nothing. It chains outward: { text: Save, within: { id: cards, within: Settings } } reads "Save inside cards inside Settings", each container's frame inside the next.

after / next — reading order is row-band aware: an element follows the anchor when it starts below the anchor's bottom edge, or shares its row band and sits entirely to its right. That is what makes { role: Switch, next: { text: Wi-Fi } } resolve the Wi-Fi row's own switch even though the taller switch's frame starts a couple of pixels higher than the label's. next keeps only the nearest follower — a match in the anchor's own row beats anything on the rows below, leftmost first — while after keeps them all, so assert: { hidden: { role: Button, after: { text: Danger zone } } } holds when nothing button-like appears past that heading. Both union over anchors exactly as CSS does: with three rows on screen, { role: Switch, next: { role: AXStaticText } } yields all three switches, one per label. Note that "follows" is not transitive — against a tall anchor, an element can follow something that itself follows the anchor without following the anchor directly — so nesting after scopes is not the same as chaining CSS ~: { after: { …, after: … } } can match elements a single after excludes. Nest them only when each link is a container-sized step. An element sitting inside the anchor does not follow that anchor — containment is not reading order — so scope by within for that.

Prefer the map form for a scope's anchor. A bare-string scope (next: wifi-row) keeps the loose identifier-first fallback, and the runner takes the first pass that finds a visible match — so if some unrelated element carries testID="wifi-row", the identifier pass wins and the text pass never runs. Reproduced: with a decoy testID="Wi-Fi" elsewhere on screen, tap: { role: Switch, next: Wi-Fi } taps the decoy's neighbour and reports a pass. This is the ordinary identifier-first doctrine, but a scope makes a decoy likelier to "succeed": a decoy container only wins if it actually holds a match, while a decoy anchor wins if anything at all sits after it — which on a real screen it usually does. Spell an anchor you care about as a map: next: { text: Wi-Fi } or next: { id: wifi-row }.

One way next is deliberately looser than CSS +: where A + B matches nothing unless the very next sibling is a B, next keeps looking and returns the nearest match further on. That is what makes it survive the wrapper and spacer nodes a flattened tree is full of, but it also means a row that is missing the control you asked for silently resolves to the next row's — { role: Switch, next: { text: Wi-Fi } } on a Wi-Fi row rendered without a switch returns the Bluetooth row's switch rather than failing. When a row may legitimately lack the control, assert it first (assert: { visible: { role: Switch, within: { id: wifi-row } } }) or scope by within instead.

Scopes compose, and it matters which one carries them. { role: Button, next: { text: Name, within: { id: card-b } } } scopes the anchor — one label, so one pick, but that pick may land outside card-b. { role: Button, next: { text: Name }, within: { id: card-b } } scopes the target — every label is still an anchor, but only card-b's buttons can be picked. They agree on a well-formed screen and diverge when card-b has no button: the first reaches on to the next card's, the second returns nothing. Scope the target when the container is the thing you trust. Conditions honor scopes like any other selector: assert: { hidden: { text: Saved, within: { id: toast-area } } } holds when nothing matching "Saved" is inside the toast area — matches elsewhere on screen don't count, and a missing scope element satisfies hidden (and fails visible/exists).

Selectors resolve against the full native hierarchy (iOS: the UIView tree; Android: the complete accessibility hierarchy including not-important views) — strictly more than describe or the raw await-ui-element tool see (both use the trimmed tree), with complete testID/resource-id coverage. So an id selector works even when describe collapses or omits the element — don't fall back to coordinate taps just because a testID isn't visible in describe output. And when several elements match — including wrappers whose native text aggregates descendant content — the action directives (tap, type, scroll-to) pick the most specific match: an exact text/identifier match beats a substring hit (for a regex matcher, a pattern consuming the element's whole text counts as exact), then the smallest frame wins. (A universal any: true selector has no field to be exact about, so its matches rank by reading order instead — the first element in the scope, which is the element a condition reads too. Where two matches share a top-left corner, an action breaks the tie toward the smaller, more specific one and a condition does not, so those two can name different elements.)

Quote strings YAML would mangle. An unquoted # starts a YAML comment — tap: Order #1234 silently parses as tap: Order — and bare yes/no/on/off/numbers coerce to non-strings. When a selector or typed text contains #, :, quotes, or could read as a boolean/number, wrap it: tap: "Order #1234".

await and assert

The condition is the key, and its value is the selector:

  • { visible: Home }, { exists: { id: row } }, { hidden: spinner }
  • { text: { in: <selector>, contains: "Taps:" } } or { text: { in: <selector>, equals: "Taps: 0" } }text locates an element (in) and checks its rendered content against exactly one of contains (case-insensitive substring) or equals (case-insensitive exact match — use it when boundaries matter: contains: "Taps: 3" is also satisfied by "Taps: 30"). Reach for text only when the locator is an identifier/role; to assert a string is simply on screen, prefer { visible: "Taps: 0" }.
  • { text: { in: total, matches: 'Total: \$\d+\.\d{2}' } } — the third comparator: a JS regex for dynamic content (counters, prices, dates) that neither literal mode can pin. Unanchored like contains (anchor with ^…$ for the equals analog) and — unlike the literal modes — case-sensitive: the pattern carries its own semantics. An invalid pattern fails at parse time. Quote the pattern in single quotes: single-quoted and plain YAML scalars keep backslashes; double quotes would need \\d. To assert a dynamic string is simply on screen with no locator, prefer a regex selector{ visible: { text: { matches: '^Taps: \d+$' } } } (see Selectors); text.in + matches is for checking a specific element's aggregated text.
  • A container's text aggregates its descendants' text (space-joined), so text can assert what a testID wrapper visibly shows even when the string lives in a child node. That also means equals against a wrapper must match everything it shows or exactly the wrapper's own label/value — targeting the leaf holding exactly the value (or using contains) stays the clearer spelling.

This condition-as-key form is the only spelling. await also accepts an optional timeout sibling key in milliseconds — - await: { visible: Home, timeout: 15000 } — for a transition that legitimately needs longer than the default budget. Omit timeout by default: the default budget covers normal transitions, and a habitual generous override just delays failure reporting on every broken step. Add one only after a step demonstrably needs it — it timed out at the default and the wait is legitimately slow (a cold start, a network round-trip, a long animation). assert has no timeout override: a check that needs seconds to become true is a wait — spell it await.

For a custom poll interval or bundleId, drop to an explicit - tool: await-ui-element step — but the raw tool polls the trimmed describe tree, so a testID it reports as not found can still resolve fine as an await: directive (see Selectors). Prefer the directive.

type and scroll-to

type presses Enter after typing to commit the value and dismiss the keyboard, so it can't cover later targets. For a chained form whose fields feed one explicit submit — e.g. email then password then a tap: "Log in" — set submit: false on the intermediate fields so a premature Enter doesn't fire the form early: type: { into: password, text: "hunter2", submit: false }.

Never record a real credential into a flow — the YAML is committed to the repo. Use a secret placeholder instead: type: { into: password, text: "{{secret:APP_PASSWORD}}" }. The placeholder is stored verbatim (the YAML stays secret-free) and is resolved at run time by the tool-server from the ARGENT_SECRET_APP_PASSWORD environment variable — including agent-less argent flow run in CI, where the variable comes from the job's secrets.

scroll-to takes an optional direction (up | down | left | right, default down — so the common case is just - scroll-to: <selector>) and optionally a within: <selector> that anchors the scroll inside a specific container — required to drive a nested scroller (e.g. a horizontal carousel inside a vertical list), since the device can't be asked which container to scroll. This step-level within (a sibling of target) anchors the gesture, and it is the only scope key the step body takes — after:/next:/any: beside target are rejected. It is distinct from a selector's scopes (see Selectors), which target may itself carry — scroll-to: { target: { text: Delete, within: { id: cards } }, within: { id: settings-list } } scrolls the settings list until the Delete button inside the cards container is visible. It scrolls in bounded momentum-free increments, re-checks after each, and stops if a scroll reveals nothing new (end of the container). tap/type do not scroll — add a scroll-to before any target that may be off-screen. It's a no-op when the target is already visible, so a defensive scroll-to costs nothing on replay and keeps the flow working on smaller screens.

snapshot cropping

cropOn: <selector> narrows a snapshot's comparison to one element's region — - snapshot: { name: cart-total, cropOn: { id: order-summary } }. The selector resolves like a directive target (settled tree, auto-wait, the standard not-found failure; selector-only, no point form), and the cropped image is what gets compared, stored as the baseline, and reported as the current artifact; the baseline filename still keys on the full capture's resolution — plus a -crop-<hash> selector suffix — so device-class drift is still caught. A cropped comparison never masks any region — every pixel of the crop is compared — so prefer elements clear of the top status-bar band (a crop overlapping it leans on best-effort status-bar pinning, and the clock/battery may diff). Crop fixed-size containers, addressed by id: a text selector resolves to the smallest matching node — typically the label itself, whose frame tracks text metrics — and the crop tracks the element's frame, so an element that grew or shrank by a pixel fails on dimensions ("nothing was compared") rather than on content. Baseline storage and seeding are covered under Standalone runner.

TV targets (Vega)

A Vega (Fire TV) device is remote-driven — there is no touch input, so the touch directives (tap, long-press, type, scroll-to, pinch, rotate) fail on it with guidance. Drive focus with tool: tv-remote steps and type with tool: keyboard instead; everything else (launch, await, assert, wait, snapshot, echo, run, selectors) works unchanged — the tree comes from the on-device automation toolkit, which attaches at app launch (the launch step waits for it, so a leading launch also guarantees selectors resolve).

steps:
  - launch: com.example.app.main # the interactive component id from manifest.toml
  - await: { visible: Home }
  - tool: tv-remote
    args: { button: [down, select] } # move focus, then confirm — one step per navigation
  - await: { visible: Explore Screen }
  - snapshot: explore

Since a tv-remote path is positional (like a coordinate tap), gate each navigation with an await on the destination screen and echo where focus should be — that is what makes the flow diagnosable when the focus order changes.

Standalone runner

argent flow run <name> [--device <id>] [--platform ios|android|chromium|vega] [--update-baselines] [--output <dir>] [--json] runs a flow with no LLM in the loop and exits non-zero on any failure — suitable for CI (e2e flows; a fragment runs against the current device state, useful while authoring). snapshot baselines live in .argent/flows/__baselines__/<flow>/, keyed by platform + resolution; a snapshot step fails when no baseline exists for the run's device class, so seed baselines with --update-baselines and have the user review and commit __baselines__/ — and pin the device class in CI (--device/--platform, same simulator model) so runs compare against the committed key. The status bar is pinned (iOS simctl status_bar, Android demo mode) for the run so it doesn't drive visual diffs. --output <dir> writes each failed snapshot's baseline/current/diff images to <dir>/<flow>/ — a stable path for CI artifact upload.

Tools

Tool Purpose
flow-start-recording Start recording — takes a name and (fragments only) an optional executionPrerequisite; creates the file
flow-add-step Execute a tool call live and record it if it succeeds
flow-add-echo Add a label/comment that prints during replay
flow-finish-recording Stop recording and get a summary
flow-read-prerequisite Read a flow's execution prerequisite without running it
flow-execute Replay a saved flow by name

Every tool during recording returns the current flow file contents, so you can track what has been recorded. Rules:

  • Every step runs live. You see the real tool result (including screenshots) — verify the step worked before continuing. Only successful steps are recorded: a failed call writes nothing to the flow file; fix the issue and try again.
  • Pass project_root once. Give the absolute project_root (an error is returned if the path is not absolute) to flow-start-recording — it is stored for the session and used by all subsequent flow tools. You do not pass a flow name to flow-add-step, flow-add-echo, or flow-finish-recording — the active flow is tracked automatically.
  • Start before adding. Calling those tools without an active recording returns "No active flow. Call flow-start-recording first."
  • One flow at a time. flow-start-recording while already recording switches to the new flow — the response tells you which flow was abandoned and which is now active; the old flow's file remains on disk.
  • Mistakes can be edited out. Edit the .yaml file directly to remove or reorder steps.

flow-add-step arguments

The command parameter is the MCP tool name; args is a JSON string (not an object), omitted entirely for tools with no arguments:

command: "gesture-tap"
args: "{\"udid\": \"<UDID>\", \"x\": 0.5, \"y\": 0.35}"

command: "await-ui-element"
args: "{\"udid\": \"<UDID>\", \"condition\": \"visible\", \"selector\": {\"text\": \"Continue\"}}"

Record an await-ui-element step to gate the next step on a screen transition — it blocks until the element is visible/hidden (or contains text), so the following step runs only once the screen has actually settled; prefer this over a fixed delayMs. If its condition is not met before the timeout, replay stops at that step (the steps after it assume the transition happened). See the await-ui-element section of argent-device-interact for the full condition/selector reference. The live call sees only the trimmed describe tree — if it can't find an identifier you know exists, gate on visible text to get the step recorded, then retarget the identifier in the await: form during polish (the directive resolves the full hierarchy — see Selectors); don't conclude the testID is unusable in the flow.

Recording

  1. Start, then launch as the first step (e2e) or set the stage yourself (fragment). Call flow-start-recording with a descriptive name and the absolute project_root. For an e2e flow, record a restart-app of the app under test as the first step — it runs live (resetting the device for the rest of the recording) and is captured as the flow's launch step. For a fragment, bring the device to the entry state before recording and pass an executionPrerequisite describing it (e.g. "App on the login screen") to flow-start-recording instead.
  2. Build step-by-step: for each action, call flow-add-step with the tool name and args. The tool runs immediately — check the result before moving on, and gate each navigation with an await-ui-element step.
  3. Add labels: use flow-add-echo between steps — echo the expected state, not just the action (see Making flows resilient).
  4. Finish: call flow-finish-recording. It returns the file path where the flow was saved and a summary of all steps.
  5. Polish: read the saved .yaml file and convert the raw tool: steps that have a cleaner directive form (the recorder leaves these as tools):
    • tool: keyboard typing into a field → type: { into: "<field>", text: "…" }, folding in the tap that focused the field.
    • tool: await-ui-element gating a transition → await: { visible: "…" } / { hidden: … } / { text: { in: …, equals: … } }, carrying a custom timeoutMs over as a timeout sibling key. Converting also upgrades the wait from the trimmed describe tree to the flow's full-hierarchy tree (see Selectors). Keep the raw tool: await-ui-element step only when it sets a custom pollIntervalMs/bundleId the directive can't express.
    • A scroll-to-reach-an-element — a tool: gesture-swipe (or its chromium analog, gesture-scroll) used to bring a specific element on screen before interacting with it (a tap, type, assert, …) → scroll-to: { target: "<that element>", direction: … }, dropping the swipe. This is far more robust than a fixed-distance swipe: it scrolls momentum-free and stops exactly when the target appears, so it survives layout and content changes. (tap/type do not scroll, so a raw swipe whose fling lands differently on another device leaves the following tap unresolved — always prefer the scroll-to rewrite.) Keep a gesture-swipe as a raw tool: step when it isn't scrolling toward a specific element — especially a velocity-dependent gesture like swipe-to-dismiss, edge-swipe-back, or swipe-to-reveal a row action, which a momentum-free scroll-to would not reproduce.
    • tool: gesture-pinchpinch: { on: "<target>", scale: … }, deriving scale as endDistance / startDistance. Set on: to the element under the pinch center when the pinch was aimed at one (the map or image being zoomed); omit it for a screen-center pinch. Don't carry the recorded distances/angle over — the directive re-derives the geometry (finger placement, system-edge avoidance, chaining of large scales) at run time, so the conversion swaps device-specific coordinates for a portable selector with auto-wait. Keep the raw tool: gesture-pinch step when the pinch is anchored at a specific point inside a large element (zooming toward a particular map location, not the map's center) or deliberately pans via endCenterX/endCenterYon: takes only a selector and re-centers the pinch on the element's frame center, so converting would silently move the zoom anchor.
    • tool: gesture-rotaterotate: { on: "<target>", by: … }, deriving by as endAngle − startAngle (the tool's endAngle > startAngle turns clockwise, matching the directive's positive by). Set on: to the element under the rotation center when the rotation was aimed at one (the map or image being rotated); omit it for a screen-center rotation. Don't carry the recorded centerX/centerY, radii (radius or radiusX/radiusY), startAngle, or durationMs over — the directive re-derives the geometry (finger placement, physical-circle radius, system-edge avoidance) and runs at a fixed pace (~90° per 300 ms), so the conversion swaps device-specific coordinates for a portable selector with auto-wait. Keep the raw tool: gesture-rotate step when the rotation is anchored at a specific point inside a large element rather than its center (the directive re-centers on the element's frame center, so converting would silently move the pivot), when the gesture's speed itself matters (the directive's pace is fixed), or when the sweep exceeds the directive's ±3000° bound.

Every other recorded tool (a velocity-dependent gesture-swipe, a fixed-distance gesture-scroll not aimed at an element, button, screenshot, …) has no directive form — leave it as a tool: step. The recorder already handles the rest: coordinate gesture-taps are captured as portable tap: selector steps, a restart-app is captured as a launch: step, a flow-execute of a sibling fragment is captured as a run: <name> composition directive, and device ids are stripped. Captured selectors are emitted in the strict map form (tap: { text: General }), never as a loose bare string — the recorder verified the exact element the tap hit, and a bare string would re-parse as loose and route through the identifier-first fallback it was never checked against. After editing, re-run with flow-execute to confirm the cleaned flow still passes.

Example session

flow-start-recording  { name: "open-about", project_root: "/Users/dev/MyApp" }
flow-add-echo  { message: "Start Settings from scratch" }
flow-add-step  { command: "restart-app", args: "{\"udid\": \"ABC\", \"bundleId\": \"com.apple.Preferences\"}" }   # ⇒ captured as `- launch: com.apple.Preferences` — this is now an e2e flow
flow-add-echo  { message: "On the Settings root list, tapping the 'General' row" }
flow-add-step  { command: "gesture-tap", args: "{\"udid\": \"ABC\", \"x\": 0.5, \"y\": 0.35}" }   # ⇒ captured as `- tap: { text: General }` (portable selector, no udid)
flow-add-step  { command: "await-ui-element", args: "{\"udid\": \"ABC\", \"condition\": \"visible\", \"selector\": {\"text\": \"About\"}}" }   # gate the transition
flow-add-echo  { message: "On Settings > General, tapping 'About'" }
flow-add-step  { command: "gesture-tap", args: "{\"udid\": \"ABC\", \"x\": 0.5, \"y\": 0.17}" }
flow-add-step  { command: "await-ui-element", args: "{\"udid\": \"ABC\", \"condition\": \"visible\", \"selector\": {\"text\": \"Model Name\"}}" }
flow-finish-recording  {}

Then polish the saved file: the two await-ui-element steps become await: directives (see the file below).

Replaying

Call flow-execute with the flow name (and project_root, unless a recording this session already stored it). If the flow has an execution prerequisite, the tool returns a notice with the prerequisite text instead of running — verify the prerequisite is met (you can also inspect it beforehand with flow-read-prerequisite) and call flow-execute again with prerequisiteAcknowledged: true. A flow without a prerequisite runs immediately. The run executes all steps in order and returns a structured report: { ok, passed, failed, skipped, errored, steps }.

What each step reports. Raw tool: steps include the underlying tool's full result (screenshots and other outputs render as usual). The directive steps are summarized: tap/type/await/assert report only status + reason, and snapshot adds artifacts only when there is something to look at — a failed comparison (baseline/current/diff paths), a missing-baseline failure (current only), or a baseline write; a clean pass reports just status + reason. So converting a tool: gesture-tap into a tap: directive during cleanup drops only that tap's (uninteresting) raw result — output-bearing tools like screenshot have no directive form and stay tool: steps, so their results keep flowing through.

Flow file format

The top-level is an object with steps (array) and — fragments only — executionPrerequisite (an e2e flow, one beginning with launch:, has none). Besides the directives above:

  • - echo: <message> — a label printed during replay
  • - tool: <name> with optional args: — a raw tool call. A tool step may also carry delayMs: <ms> to sleep that long before it runs. (await-ui-element is an ordinary tool step; see flow-add-step arguments and Making flows resilient for when to gate a transition with one.)
  • when: blocks handle one-sided divergences (interstitials, coach marks): - when: { visible: "What's new" } with a sibling steps: [...] list runs the block only if the condition holds — checked once with the short assert grace (~1s), so a skipped block barely costs a clean run. Guards are one condition key (exists/visible/hidden/text, the await/assert shapes) or platform: ios|android|chromium|vega. No else (parse-rejected): a block exists to dismiss the divergence and reconverge, never to test two paths — two paths are two flows. Failures inside an entered block are real failures; a skipped block reports skip lines. Tap-if-present is a one-step block (when: { visible: "Got it" } + steps: [tap: "Got it"]); there is NO per-step optional: key — it is rejected at parse with a pointer to when:.

The polished result of the example session above:

steps:
  - echo: Start Settings from scratch
  - launch: com.apple.Preferences
  - echo: On the Settings root list, tapping the 'General' row
  - tap: { text: General }
  - await: { visible: About }
  - echo: On Settings > General, tapping 'About'
  - tap: { text: About }
  - await: { visible: Model Name }

Note there is no device id anywhere in the file — the recorder strips them and the runner injects the bound device.

When to proactively record a flow

Proactive recording is part of this skill's scope (see the description). Record a flow without waiting to be asked — telling the user you are doing so — when you recognize any of these patterns:

  • About to re-profile: You completed a profiling session and are about to apply a fix and re-profile. Record the interaction steps now so the re-profile replays them identically (see argent-react-native-profiler and argent-native-profiler skills).
  • Repeating steps: You have already performed a multi-step interaction sequence once and the task requires doing it again (comparison, retry, re-test).
  • Complex path discovered: You worked through a non-trivial sequence of taps/swipes/navigation to reach a desired app state. Capture it before it is lost.
  • User says "again" / "one more time": Any request to redo what you just did is a signal to record first, then replay.

Flow self-improvement

Flows break. UI layouts change, coordinates drift, screens get added or removed. When flow-execute returns a failure, follow this procedure to diagnose and fix the flow instead of silently re-recording or giving up.

Classify the result

After every flow-execute, classify the outcome before proceeding:

Outcome Signal Action
Success All steps completed, final screenshot shows expected state Continue with task
Hard error A step has ERROR in the result — engine stopped there Enter Diagnose
Silent misfire All steps completed but final screenshot shows wrong screen Enter Diagnose
Partial divergence Intermediate screenshot shows wrong state even though later steps ran Enter Diagnose

For silent misfires and partial divergence, echo annotations (see Making flows resilient) are your reference for what each screen should look like.

Diagnose

  1. Note the failure step index and error message (if hard error).
  2. Call screenshot to see where the app actually is now.
  3. Call describe or debugger-component-tree to get the current element tree. Remember describe shows less than the flow tree — a testID missing from its output can still resolve as a selector (see Selectors).
  4. Compare current state to what the failed step expected. Classify the root cause:
Root cause Symptoms
Coordinate drift Tap succeeded but hit wrong element; elements shifted positions
Missing element Target element not present in element tree
Wrong screen Screenshot shows entirely different page than expected
Timing Element exists in tree but tap missed; loading spinner visible
State mismatch First step fails — executionPrerequisite was not actually met
  1. State the diagnosis in one sentence before attempting any correction.

Correct

Choose the lightest strategy that fits:

Strategy 1 — Edit the YAML (coordinate drift, parameter changes). Read .argent/flows/<flow-name>.yaml, update the broken step's x/y, bundleId, text, or other args. Re-run flow-execute to verify.

Strategy 2 — Manual recovery + continue (timing/transient issues, one-off replay). Manually execute the failed step with corrected coordinates from the Diagnose step, then manually execute remaining steps. Does not fix the YAML — use only when re-recording is not worth it.

Strategy 3 — Re-record from failure point (structural changes, new intermediate screens). Navigate the app to the state just before the failure point. Call flow-start-recording with the same flow name (overwrites). Re-add the working prefix steps via flow-add-step, then continue recording new steps from the divergence point. Call flow-finish-recording.

Strategy 4 — Full re-record (major changes, unclear diagnosis, or 3+ broken steps). Reset the app to prerequisite state (restart-app + launch-app). Record from scratch with the same flow name.

Decision heuristic:

  • 1 step broken, parameter-only change → Strategy 1
  • 1 step broken, transient issue, not worth persisting → Strategy 2
  • 2–3 steps broken or flow structure partially changed → Strategy 3
  • 3+ steps broken, or unclear root cause → Strategy 4
  • Flow used for profiling comparison (must be identical) → Strategy 4

Verify and bound retries

After applying a correction, re-run flow-execute to verify.

  • If it succeeds → done. Report what changed (e.g. "Fixed step 4: updated tap coordinates from 0.5,0.35 to 0.5,0.42").
  • If it fails at a different step → return to Diagnose for a second attempt.
  • If this is already the second correction attempt → stop. Report the diagnosis to the user and recommend a full re-record or manual investigation.

Hard cap: 2 correction cycles. Do not enter an unbounded fix loop.

Making flows resilient

Apply these when recording new flows to reduce future breakage:

  • Echo expected state, not just actions. Write "On Settings > General screen, about to tap About" not "Tap About". During diagnosis these tell you what the screen should look like.
  • Gate transitions with await-ui-element, not fixed delays. After a tap that triggers a navigation, record an await-ui-element step that waits for the next screen's element to be visible (or a spinner to be hidden) before the following step — converted to an await: directive during polish. This removes the Timing failure mode in Diagnose (the element is in the tree but the tap fired before the screen settled) and is more reliable than delayMs or an extra screenshot. An unmet wait stops replay at that step, so a mistimed step can never run blind.
  • Add screenshot steps after critical navigation. Insert screenshot steps after screen transitions. These produce images in the flow result you can inspect during diagnosis.
  • Write specific executionPrerequisites. "App on home tab, user logged in, simulator UDID is <X>" — not "App running". Verify with screenshot + describe before acknowledging.
  • Prefer launch-app / open-url over navigation chains. Deep links are more resilient to layout changes than tap sequences.
  • Echo accessibility labels for coordinate taps. When recording a tap, add an echo with the target's label or testID: "Tapping 'Submit' button (testID: submit-btn) at 0.5, 0.82". During repair, use describe to find the element by label and update coordinates. Only use screenshot for permission or system overlays when describe cannot expose the target reliably.

Version History

  • a6422a3 Current 2026-08-02 20:45

    新增CSS组合器选择器功能,支持基于元素相对位置(within/next/after)及通用选择器进行精准定位与条件判断。

  • a0460ee 2026-07-24 11:27

    新增 pinch 指令,支持针对特定选择器进行缩放手势操作;优化了缩放尺度的有效性检查与边缘安全策略,确保手势执行的几何计算准确性。

  • bc04c3b 2026-07-06 19:58

Same Skill Collection

packages/skills/skills/argent-android-emulator-setup/SKILL.md
packages/skills/skills/argent-device-interact/SKILL.md
packages/skills/skills/argent-ios-simulator-setup/SKILL.md
packages/skills/skills/argent-lens/SKILL.md
packages/skills/skills/argent-metro-debugger/SKILL.md
packages/skills/skills/argent-native-profiler/SKILL.md
packages/skills/skills/argent-react-native-app-workflow/SKILL.md
packages/skills/skills/argent-react-native-optimization/SKILL.md
packages/skills/skills/argent-react-native-profiler/SKILL.md
packages/skills/skills/argent-screen-recording/SKILL.md
packages/skills/skills/argent-screenshot-diff/SKILL.md
packages/skills/skills/argent-test-ui-flow/SKILL.md
packages/skills/skills/argent-tv-interact/SKILL.md
packages/skills/skills/argent-settings-permissions/SKILL.md

Metadata

Files
0
Version
a6422a3
Hash
2d9af578
Indexed
2026-07-06 19:58

Главная - Вики-сайт
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-08-06 20:17
浙ICP备14020137号-1 $Гость$