# vos CLI — the complete take/capture agent reference (vos.so/llms-full.txt) The vos CLI turns a scripted browser flow into a polished product video (auto zoom, smoothed cursor, click effects, browser frame) — and every effect is DATA. You (the agent) write a flow, the CLI records + plans, you edit JSON, you re-render. The renders are deterministic: same take + same doc = same pixels. Never re-record to fix pacing or zooms — edit doc.json and render again. Install: npm i -D @vosjs/cli (one binary, one package, MIT: the engine verbs, the take pipeline AND all vos.so platform verbs. Until 0.9 the take pipeline shipped separately as @vosso/vos-plugin, and before that as @vosso/cli; both are shims now, and vos voila remains a hidden alias for old scripts) Needs: a Chromium-family browser (system Chrome auto-detected; `npx playwright install chromium` or VOS_BROWSER_PATH otherwise) and network at encode/render time (three/mediabunny load from esm.sh in render pages). Conventions: logs → stderr, results → stdout; --json = NDJSON events ending {"event":"done",…}; exit codes 0 ok / 1 error / 2 usage-or-strict / 3 no browser / 4 the recorder met a wall (a sign-in instead of the page; see "Sessions"). vos --help prints that verb's flags and exits 0 (0.41.1 and later). A flag a verb never READ is never swallowed: the run exits 2 and stderr says "vos RAN, and ignored --x (did you mean --y?)" with the flags the verb reads. The command DID run, without that flag: fix the flag and run it again; never treat that output as what you asked for. (A push that ran has pushed.) ## The take directory (the unit of work) take/ recording.webm encoded footage (CFR WebM; recording.mp4 when a pull brought it home in that container — read the name, never assume it) frames/ screencast JPEGs — encode intermediate, deletable (~1GB at 2K) cursor.json synthesized CursorTrack (exact coords, element rects, typing pings — when/where only, never text) meta.json RecordingMeta (producer: "cli") actions.json the script that produced it — the replay recipe doc.json ProjectDoc — the editable surface (THE product artifact) vos.json hosted tracking (vos id + base version), written by push/pull ## Commands This machine (0.48 and later; no account). These four print NDJSON whenever stdout is not a TTY, and every done event carries next_step. vos setup [--agent claude,cursor,codex,copilot|all] [--global] [--no-skills] [--no-rules] [--no-browser] [--url ] [--json] Ready the computer for the loop: the vos skills into your agent's directories (npx skills add vosjs/skills -y; when skills.sh cannot be reached, the copy bundled in the package), a browser found in the recorder's order (VOS_BROWSER_PATH, system Chrome, Playwright's Chromium) or installed, ONE block between and in AGENTS.md (CLAUDE.md when only that exists; a second run replaces its own text and never yours), then doctor. Agents are detected by directory (.claude, .cursor, .codex or .agents, .github/copilot; the home forms under --global); none found ⇒ the skills land in .agents/skills. vos doctor [--url ] [--json] What is ready, in words: node, browser, ffmpeg (optional), the vos skills per agent, a credential (present or absent, never printed), and the dev server when --url names one. Exit 3 when no browser; next_step says the fix. Run it before the first record and whenever record exits 3. vos whoami [--json] the key's name and the account it belongs to, from GET /api/user/whoami; never the key. Exit 1 when nothing resolves (recording needs no account). vos logout [--json] remove ~/.config/vos/credentials (VOS_API_KEY in the shell still resolves, and the answer says so). vos create --actions actions.json [--url ] [--out take] [out.webm] [--strict] [--allow-wall] [--max-duration ] [--storage-state ] [--header name=value]... [--browser-arg=]... [--keep-frames] [--width] [--height] [--fps] [--format webm|mp4] [--parallel N] [--draft] [--frame ] [--background ] [--set path=value]... [--json] The one-shot verb (the vos create namespace): record + auto-plan + render in one command and one browser session. The take dir still lands on disk, so the loop below (frames → edit doc.json → re-render) stays open. With --strict an incomplete recording aborts BEFORE the render (exit 2). Use create for the first pass; use the individual verbs to iterate. vos record --actions actions.json [--url ] [--out take] [--strict] [--dry-run] [--allow-wall] [--max-duration ] [--storage-state ] [--header name=value]... [--browser-arg=]... [--keep-frames] [--json] vos record --hosted --actions actions.json [--url ] [--out take] [--title "…"] [--folder ] [--header name=value]... [--json] Drives the page, synthesizes the cursor track, encodes, auto-plans zooms. ALWAYS pass --strict: any skipped selector or networkidle timeout exits 2 with machine-readable skipped[] in the done event. A skip = broken flow — fix the selector and re-record; never ship around it. REHEARSE FIRST: add --dry-run to the exact command you are about to run. Every step runs against the real page, in order (a later selector usually exists only after an earlier click), but nothing is captured and nothing is written: the pointer lands instead of travelling, pauses are cut to a beat, and the take beside it keeps its footage, its cut and its script (a real re-record moves doc.json aside; a rehearsal does not). Lookups keep their whole timeout, so a miss there is a miss in the take. It prints each step with the rect it resolved, in capture px (the rects a pin or vos callout --step reads), and exits 2 on any miss. A script is found broken in seconds, not after a real-time take and its encode. The Next: line it prints is the same command without --dry-run, session flags included (0.41.1 and later; an older rehearsal drops --storage-state from that line, so add it back before you paste it). Re-recording into an existing take replaces the FOOTAGE, never the cut (RK2): doc.json survives as doc.prev.json, actions.json and vos.json stay, and vos plan --reuse re-times the cut onto the new recording. meta.json carries steps (when each actions step ran, source seconds); give steps an id so anchors survive script edits. Recording is real-time. --max-duration (default: the hosted cap, read live from GET https://vos.so/api/limits → limits.recordingMaxSeconds, 1800 = 30 min today; the built-in 1800 applies offline): the capture stops there and the done event says so (capped: true, the remaining steps did not run); with --strict that exits 2. A take longer than 30 min is refused by vos push (413), so cut the flow, not the cap. --hosted (0.49+): NO BROWSER HERE? A cloud agent, a CI runner or a chat host records on vos.so instead. The same script goes up (POST /api/record/jobs), the fleet records, plans and lands the take as a PRIVATE vos with its doc.json and its digest, and the take comes home into --out with its media (vos fetch --media), the directory every other verb reads. Needs a credential (vos login, or VOS_API_KEY). What travels: the script, the viewport, every --header value (a preview's bypass token) and the value of every { "env": "NAME" } a setup step reads, from this shell, for the job's life; the platform stores the script with the values stripped and never the env or the headers. What never travels: a session (--session and --storage-state are refused; a Cookie header is refused by the platform), because none of a person's ever reaches vos.so. A page behind a sign-in fails on the fleet in words naming the ladder below, exit 4, and the empty vos it would have landed on is taken down; a private-network URL (localhost, 10.x, a .local name) is refused before a job exists, so a dev server needs a tunnel or a preview deployment. The fleet records up to 10 minutes at 1280x720 by default (viewport in actions.json, 640..1920 wide); 20 hosted takes per account per day. --dry-run stays a local act. --title and --folder name the landing. THE WALL CHECK (0.41 and later): once the first navigation settles, and before a frame is captured, record, create and --dry-run ask whether the recorder landed where it was sent. Refused with exit 4 and a sentence ("asked for /dashboard, landed on /login: no session for app.acme.com"): the URL answered 401/403, the recorder was sent to an identity provider or a sign-in path, or the page is a sign-in form rendered in place. A redirect somewhere else with no sign-in in sight (a site that shows strangers a public page) is refused under --strict and in a rehearsal, and only warned about otherwise. Nothing is recorded and nothing is cleared: a re-record's old footage survives a refusal. Exit 4 is never a script bug: do not touch actions.json, walk the ladder in "Sessions". --json carries a wall event (kind, level, asked, landed, refused). --allow-wall records the page anyway (a video OF a sign-in page is a legitimate take); meta.wall and the digest's take.wall then say so, and a digest that prints WALL is footage of the wrong page: re-record it with a session before cutting it. WHAT THE FRAME SHOWS (0.42 and later): after the page opens and after every step the recorder reads the text visible in the viewport and reports the KIND of sensitive-looking thing it saw and where, never the string: email (an address, except on example.com or a .test/.example/ .invalid/.localhost domain, which is what demo data should use), key (sk_live_, ghp_, github_pat_, xoxb-, AKIA, vos_sk_, a JWT), card (13 to 19 digits passing Luhn), card-tail (•••• 4242). A rehearsal ends with the list, so read it BEFORE you record; it also rides the done event's exposures[], meta.exposures[] ({step, kind, selector, rect, seen}; step -1 = as the page opened), vos validate warnings and the digest's take.exposures. Warnings, never failures: a product may show addresses on purpose. The selector reaches that element and no other, so it pastes into a mask. "mask" in actions.json hides a selector BEFORE the first frame and keeps it hidden across navigations and re-renders, so the real value is never in a frame, never in recording.webm, never pushed: "mask": [{ "selector": "nav > span", "as": "text", "text": "jane@acme.test" }, { "selector": ".card-number" }] as "blur" (default) blurs; as "text" swaps the words, for IDENTIFIERS only (an email, a name, an account id), never product copy or a number: the video stays true to the product, and no check makes that call for you. A form control is always blurred. A mask whose selector reached no element hid nothing: it fails --strict and a rehearsal by name, and meta.masks[] carries each mask's hits. A blur added later in doc.json is not a fix: the real value is still in recording.webm. "setup" in actions.json (0.43 and later): steps that run BEFORE the camera rolls (a sign-in form, a cookie banner, an onboarding tour), verbs goto, click, type, press, wait. They run after the first navigation and before a frame is captured, as 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 type step's text may be { "env": "NAME" }, read from the shell at run time and never logged or stored (the log names the field, never the value); a literal typed into a password field is refused by validate, because actions.json is committed and pushed with the take. A setup selector that never appears fails the take before anything is recorded (exit 2); an unset variable exits 2 in words. Rehearse the setup with --dry-run like everything else. "setup": [{ "do": "goto", "url": "http://localhost:3000/login" }, { "do": "type", "selector": "#email", "text": "demo@acme.test" }, { "do": "type", "selector": "#password", "text": { "env": "DEMO_PASSWORD" } }, { "do": "press", "key": "Enter", "ms": 800 }] --header name=value, repeatable (0.43 and later): a request header on every request the recording browser makes; the rehearsal's Next: line carries it. --session (0.45 and later): record on a profile the person signed in to with vos session open. One door; --storage-state is the other, never both. The rehearsal's Next: line carries it. --storage-state : a Playwright storage state, so the take is of a SIGNED-IN product. Where the file comes from is the ladder in "Sessions" below: walk it top to bottom. It holds live credentials: never commit it, never push it, never put it in a take directory. --browser-arg=, repeatable: extra Chromium switches, every one kept in order. A switch starts with --, so it is written with =. A page that asks for the microphone or camera needs BOTH --browser-arg=--use-fake-ui-for-media-stream and --browser-arg=--use-fake-device-for-media-stream (the device switch alone is refused). An unpacked extension does NOT load this way: it needs a persistent, headed, bundled-Chromium context, which record does not run. The screencast frames are released once the recording is encoded (they are most of a take's bytes and vos digest reads the video without them); --keep-frames keeps them. vos port inventory [--render ] [--out port] [--json] vos port scaffold [--from port/inventory.json] [--out port/program] [--json] vos build [--out config.json] [--json] vos compare --against [--times a,b,50%] [--every 1] [--threshold 0.95] [--out compare] [--json] BRING A FINISHED PIECE (0.54+; the vos-port skill): a HyperFrames composition, a hand-rolled page or a Remotion project's render becomes a program whose words and colours are data. inventory reads the piece as a browser renders it into port/inventory.json: words with their computed faces, sizes and colours, the palette from custom properties, faces matched to the font catalog (or missing), media with their windows, the scenes a timeline reveals, the CSS no element can say, and for a canvas page the words and faces its scripts name; a HyperFrames word is laid out where it settles in its scene. A Remotion project (0.55+; run npm install in it first) is bundled with its own @remotion/bundler and read the same way: its s are the scenes, named by what they hold, its colour constants name the palette,