CLI › Reference

actions.json

The flow recipe: steps, selectors, how pacing becomes the zoom plan, and how an agent-browser walk becomes one.

actions.json is the flow recipe: the script vos record drives the page with. It is small on purpose; the craft is in the pacing.

JSON
{
  "url": "https://target.example",
  "viewport": { "width": 1280, "height": 720 },
  "steps": [
    { "do": "wait", "ms": 800 },
    { "do": "hover", "selector": "a[href='/pricing']", "ms": 700 },
    { "do": "click", "selector": "#cta" },
    { "do": "type", "selector": "input[name=email]", "text": "demo@example.com" },
    { "do": "scroll", "dy": 400 },
    { "do": "move", "x": 640, "y": 320 }
  ]
}

The step verbs

wait · hover · click · type · scroll · move · drag (press-move-release: slide a range input, drag a canvas element, move a timeline clip; start is a selector center or x/y, end is tx/ty, eased over ms).

Prefer stable selectors: a[href='...'], ids, roles. Never nth-child chains; they break on the first layout change.

vos validate actions.json checks a script without running anything.

From an agent-browser walk

The agent that verified a feature in agent-browser already walked the product. vos actions from-agent-browser steps.jsonl turns that walk into the script, so the take needs no second one.

agent-browser's --json result does not say what ran (scroll answers {"scrolled":true}), so keep each command beside its result. A shell function does it per call, and a whole path run as one agent-browser batch … --json already has the shape:

Shell
ab() { agent-browser "$@" --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const r=JSON.parse(s);process.stdout.write(JSON.stringify({command:process.argv.slice(1),...r})+"\n")})' -- "$@" >> steps.jsonl; }
ab open https://target.example; ab snapshot -i -u; ab click @e27; ab wait 800
vos actions from-agent-browser steps.jsonl --out actions.json
vos record --actions actions.json --out take --strict

Refs (@e27) resolve through the last snapshot -i before them, to a role and name selector (role=link[name="Docs"]); -u gives links their href. CSS targets pass through. fill and type become type, press Enter a newline into the last field, scroll and mouse move their steps, set viewport the viewport, the first open the URL.

What the recorder cannot follow is named in the output, never dropped: a shortcut key, a drag, a coordinate click, a second open, a step that failed in the walk. Read the notes, write those steps by hand, then record.

Pacing is the zoom plan

The planner reads your dwells and your typing, so how you pace the flow is how the camera will move:

  • Open with a wait of at least 700ms, so hydration finishes and the first frame is settled.
  • Hover the things that matter for 700 to 900ms; those dwells become the zooms.
  • Typing is a zoom signal: a type step earns a held frame on its field. The click into the field opens the span, and the camera releases after the last character. About 0.6s or more of typing qualifies, so pace delayMs like a person (60 to 90ms), not a paste.
  • Wait 1200 to 2000ms after navigations, and end with a settle wait; the last frame is the poster.
  • Route the cursor away from hover-triggered menus (they open and blur the page behind their scrim); path through page content, not along the nav.
  • The recorder adds only the gestures: the pointer's travel, the press, and a short settle after a click, a type or a scroll (ms on the step sets it). A gesture runs by the clock, so a slow page costs samples, not seconds, and a take runs as long as the script asks plus the gestures. vos record ends with a pace line saying what the script asked, what the gestures added and what the page cost.

Record at the resolution you want

Footage resolution equals the viewport. If you want a 2K video, record at 2560x1440. Decide at record time; upscaling later is dishonest, and vos validate warns when an export preset exceeds the footage.

Give load-bearing steps an id ({ "do": "click", "selector": "#cta", "id": "cta" }). The recorder writes down when each step ran, and a cut anchored to a step id follows it through re-records even after the script gains or loses neighbouring steps. See Every release for the loop this enables.

Field reference

Every field, generated from schema/actions.schema.json (what vos validate enforces); step rows are grouped by verb, so steps[click].* is the shape of a click step. A * marks a required field.

fieldtypedescription
urlstringPage to record (overridable with --url).
viewportobject
viewport.width*integer 320.. = 1280
viewport.height*integer 240.. = 720
setuparrayRun BEFORE the camera rolls, after the first navigation: a sign-in form, a cookie banner, an onboarding tour. Plain actions with no cursor, no frames, no pace and nothing in meta.steps; then the recorder opens `url` again and the take begins where the setup left it. A selector that never appears fails the take before anything is recorded (exit 2). A `type` step's text may be { "env": "NAME" }, read at run time and never logged or stored; a literal typed into a password field is refused, because actions.json is committed and pushed.
setup[].do*"wait" | "click" | "type" | "press" | "goto"
setup[].idstring
setup[].selectorstring
setup[].textvariant
setup[].keystringpress: a Playwright key name (Enter, Tab, Escape)
setup[].urlstringgoto: a page to open, for a sign-in that lives elsewhere
setup[].msnumberwait: the pause; click: the read after the page settled, held from its last visual change (default 150); type and scroll: the settle after (defaults 150/200)
maskarrayHidden BEFORE the first frame is captured and kept hidden across navigations and re-renders: what a signed-in account shows that must not ship. The real value is never in a frame, never in the recording, never pushed. `vos record` reports what it still sees afterwards (meta.exposures), and a mask whose selector reached nothing fails --strict.
mask[].selector*string
mask[].as"blur" | "text" = "blur"blur the element, or swap its words for `text`. A form control is always blurred: writing into an input would change what the app submits. `text` is for IDENTIFIERS (an email, a name, an account id), never for product copy or numbers.
mask[].textstringshown instead, with as: "text"
steps*array
steps[wait].do*"wait"
steps[wait].ms*number 0..
steps[wait].idstringOptional stable identity: doc.json anchors name a step by this id (else by index), so a step survives moving or gaining neighbours across script edits. Unique across steps when present.
steps[wait].captionstringa beat's caption: vos deliver lands it as a lower-third at this step's moment on cuts that take words (feed cuts, the demo), never on loops or stills
steps[hover].do*"hover"
steps[hover].selector*string
steps[hover].msnumber 0.. = 700Dwell — parked cursor becomes a zoom signal.
steps[hover].idstringOptional stable identity: doc.json anchors name a step by this id (else by index), so a step survives moving or gaining neighbours across script edits. Unique across steps when present.
steps[hover].captionstringa beat's caption: vos deliver lands it as a lower-third at this step's moment on cuts that take words (feed cuts, the demo), never on loops or stills
steps[click].do*"click"
steps[click].msnumber 0..the read after the press, ms (default 150), held from the page's last visual change: the recorder watches the screencast and pays the settle itself (a change within 400 ms says the page is answering, 250 ms of quiet says it settled, 1.2 s bounds a page that never stops), so the same ms reads the same on every run. Size it as a reading beat, about 1000 after a navigation and 600 after a control, never padded for a slow page.
steps[click].selector*string
steps[click].idstringOptional stable identity: doc.json anchors name a step by this id (else by index), so a step survives moving or gaining neighbours across script edits. Unique across steps when present.
steps[click].captionstringa beat's caption: vos deliver lands it as a lower-third at this step's moment on cuts that take words (feed cuts, the demo), never on loops or stills
steps[type].do*"type"
steps[type].msnumber 0..the settle after the last character, ms (default 150)
steps[type].selector*string
steps[type].text*string
steps[type].delayMsnumber 0.. = 40
steps[type].focusboolean = trueThe recorder clicks the field before typing, and that click is what opens the typing zoom on it. false types into the field as it is already focused: for a keystroke that finishes earlier typing (a submitting Enter) rather than starting it, where a second click rings a click effect on empty space beside the text.
steps[type].idstringOptional stable identity: doc.json anchors name a step by this id (else by index), so a step survives moving or gaining neighbours across script edits. Unique across steps when present.
steps[type].captionstringa beat's caption: vos deliver lands it as a lower-third at this step's moment on cuts that take words (feed cuts, the demo), never on loops or stills
steps[scroll].do*"scroll"
steps[scroll].msnumber 0..the settle after the scroll lands, ms (default 200)
steps[scroll].dy*number
steps[scroll].idstringOptional stable identity: doc.json anchors name a step by this id (else by index), so a step survives moving or gaining neighbours across script edits. Unique across steps when present.
steps[scroll].captionstringa beat's caption: vos deliver lands it as a lower-third at this step's moment on cuts that take words (feed cuts, the demo), never on loops or stills
steps[move].do*"move"
steps[move].x*number 0..
steps[move].y*number 0..
steps[move].idstringOptional stable identity: doc.json anchors name a step by this id (else by index), so a step survives moving or gaining neighbours across script edits. Unique across steps when present.
steps[move].captionstringa beat's caption: vos deliver lands it as a lower-third at this step's moment on cuts that take words (feed cuts, the demo), never on loops or stills
steps[drag].do*"drag"
steps[drag].selectorstring
steps[drag].xnumber 0..
steps[drag].ynumber 0..
steps[drag].tx*number 0..
steps[drag].ty*number 0..
steps[drag].msnumber 0.. = 700
steps[drag].idstringOptional stable identity: doc.json anchors name a step by this id (else by index), so a step survives moving or gaining neighbours across script edits. Unique across steps when present.
steps[drag].captionstringa beat's caption: vos deliver lands it as a lower-third at this step's moment on cuts that take words (feed cuts, the demo), never on loops or stills
  • Verbs for record --strict and the loop around it.
  • doc.json for what the recording becomes.