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.
{
"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:
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 --strictRefs (@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
waitof 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
typestep 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 pacedelayMslike 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 (
mson 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 recordends 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.
| field | type | description |
|---|---|---|
| url | string | Page to record (overridable with --url). |
| viewport | object | |
| viewport.width* | integer 320.. = 1280 | |
| viewport.height* | integer 240.. = 720 | |
| setup | array | Run 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[].id | string | |
| setup[].selector | string | |
| setup[].text | variant | |
| setup[].key | string | press: a Playwright key name (Enter, Tab, Escape) |
| setup[].url | string | goto: a page to open, for a sign-in that lives elsewhere |
| setup[].ms | number | wait: 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) |
| mask | array | Hidden 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[].text | string | shown instead, with as: "text" |
| steps* | array | |
| steps[wait].do* | "wait" | |
| steps[wait].ms* | number 0.. | |
| steps[wait].id | string | Optional 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].caption | string | a 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].ms | number 0.. = 700 | Dwell — parked cursor becomes a zoom signal. |
| steps[hover].id | string | Optional 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].caption | string | a 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].ms | number 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].id | string | Optional 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].caption | string | a 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].ms | number 0.. | the settle after the last character, ms (default 150) |
| steps[type].selector* | string | |
| steps[type].text* | string | |
| steps[type].delayMs | number 0.. = 40 | |
| steps[type].focus | boolean = true | The 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].id | string | Optional 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].caption | string | a 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].ms | number 0.. | the settle after the scroll lands, ms (default 200) |
| steps[scroll].dy* | number | |
| steps[scroll].id | string | Optional 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].caption | string | a 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].id | string | Optional 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].caption | string | a 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].selector | string | |
| steps[drag].x | number 0.. | |
| steps[drag].y | number 0.. | |
| steps[drag].tx* | number 0.. | |
| steps[drag].ty* | number 0.. | |
| steps[drag].ms | number 0.. = 700 | |
| steps[drag].id | string | Optional 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].caption | string | a 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 |