A product video from a URL, scripted by the model from what the app really shows. Vision opens the page in a browser session and takes two snapshots: the outline (headings and controls, one per line, the shape an agent reads) and the content of <main>. Then, for each tab, button and link in the outline, it goes back to the page, clicks it, waits until the network is quiet and reads <main> again. A click qualifies as a move when it shows at least two lines the page itself does not, so a tab that only says "Nothing here yet" never makes it into the video, and never when it lands on a page with a password field: the tour does not record a sign-in form.

The model gets the page's content and each move's view, and writes an opening line and up to three beats: a title, one sentence about what that move shows, and the move, chosen from a JSON schema enum of the qualifying moves. Beats that stay on the page (tabs, buttons) go before links, which leave it. Before anything is recorded, vision plays the moves in that order from the page, so a move whose element is gone after an earlier one fails the run instead of the video. render's videos then takes the beats as they are: it records the page, clicks each target by its role and accessible name while the node's speech model says the line, burns in karaoke captions and returns the MP4, a poster and WebVTT captions. The outputs below give each step's time in the last green run.

Check

Verified 2026-11-01: this recipe's last run passed against https://beta-api.b3os.org, label live-recorded (recorded from production), from commit 29fca0d1. The recipe harness runs it again, and a failed run takes this page out of the cookbook.

Run it

bash
API=https://beta-api.b3os.orgAUTH="Authorization: Bearer $B3OS_API_KEY"PAGE=https://beta.b3os.org/demo/MAIN='{"kind": "snapshot", "mode": "full", "root": "main", "max_tokens": 800}'post() { curl -s "$API$1" -H "$AUTH" -H "Content-Type: application/json" -d @-; }act() { jq -n --argjson a "$1" '{actions: $a, continue_on_error: true}' | post "/v1/vision/sessions/$SID/act"; }lines() { sed -E 's/ \[ref=[^]]*\]//g; s/ \[selected\]//g; s/^ +//' | grep -v '^$'; }PASSWORD='{"kind": "evaluate", "expression": "document.querySelector(\"input[type=password]\") !== null"}'# 1. vision: the page's outline and content, then what each tab, button and link showsSID=$(echo '{}' | post /v1/vision/sessions | jq -r .id)act "[{\"kind\": \"navigate\", \"url\": \"$PAGE\"}, {\"kind\": \"snapshot\", \"mode\": \"skeleton\"}, $MAIN]" > first.jsonjq -r '.results[2].snapshot.text' first.json | lines > home.txtjq -r '.results[1].snapshot.text' first.json | sed -E 's/ \[ref=[^]]*\]//g' \| grep -oE -- '- (tab|button|link) "[^"]+"( \[(selected|disabled)\])?' | grep -vE '\[(selected|disabled)\]' \| sed -E 's/^- (tab|button|link) "(.*)"$/click \1 \2/' | awk '!seen[$0]++' > candidates.txt: > moves.txt: > views.txtwhile read -r kind role name; do act "$(jq -n --arg url "$PAGE" --arg role "$role" --arg name "$name" --argjson pw "$PASSWORD" --argjson main "$MAIN" \ '[{kind: "navigate", url: $url}, {kind: "click", target: {role: $role, name: $name}}, {kind: "wait", for: {network_idle: true}}, $pw, $main]')" \ | jq -r 'select(.ok and .results[3].value == false) | .results[4].snapshot.text // empty' | lines > view.txt if [ "$(grep -cvxFf home.txt view.txt)" -ge 2 ]; then # two lines the page does not show, and no password field echo "$kind $role $name" >> moves.txt { echo; echo "Move \"$kind $role $name\" then shows:"; head -16 view.txt; } >> views.txt fidone < candidates.txtcurl -s -X DELETE "$API/v1/vision/sessions/$SID" -H "$AUTH" > /dev/null# 2. infer: the script, its moves held to the qualifying ones{ echo "Page: $PAGE"; echo "What the page shows:"; head -20 home.txt; cat views.txt; } > read.txtjq -R . moves.txt | jq -s . > moves.jsonN=$(jq 'if length > 3 then 3 else length end' moves.json)jq -n --rawfile read read.txt --slurpfile moves moves.json --argjson n "$N" '{ model: "b3os/qwen3.5-9b", temperature: 0, max_tokens: 600, chat_template_kwargs: {enable_thinking: false}, response_format: {type: "json_schema", json_schema: {name: "tour", strict: true, schema: { type: "object", additionalProperties: false, required: ["intro", "beats"], properties: { intro: {type: "string", maxLength: 160}, beats: {type: "array", minItems: $n, maxItems: $n, items: { type: "object", additionalProperties: false, required: ["title", "say", "move"], properties: { title: {type: "string", maxLength: 30}, say: {type: "string", maxLength: 160}, move: {type: "string", enum: $moves[0]}}}}}}}}, messages: [ {role: "system", content: "You script a short narrated tour of a web app for a product video. Write an opening line (at most 16 words) about what the page shows, then \($n) beats. Each beat has a title of two or three words, one sentence of at most 18 words, and one move; use \($n) different moves, and put moves on this page (tabs, buttons) before links, which open other pages. A beat'"'"'s sentence describes what its move shows, using only the lines given for that move. Plain words, no exclamation marks."}, {role: "user", content: $read}]}' \| post /v1/infer/chat/completions \| jq '.choices[0].message.content | fromjson | .beats |= sort_by(.move | startswith("click link "))' > script.jsonjq -r '.intro, (.beats[] | "\(.move): \(.say)")' script.json# 3. render: the beats as they are, a voice and captionsjq --arg page "$PAGE" '{ source: {url: $page}, beats: ([{title: "Overview", say: .intro}] + [.beats[] | {title, say, do: [.move | split(" ") as $m | {kind: "click", target: {role: $m[1], name: ($m[2:] | join(" "))}}]}]), voice: {model: "kokoro-82m", voice: "af_heart"}, captions: {burn_in: "karaoke", sidecars: ["vtt"]}, style: {background: "midnight", frame: "browser-dark", cursor: "arrow", captions: "default", click_effect: "ripple"}, output: {width: 1280, height: 720, fps: 30}, formats: ["mp4", "poster"]}' script.json \| post "/v1/render/videos?wait=60" > video.jsonjq -r '.status, .result.duration_ms' video.jsonjq -r '.result.artifacts[] | select(.kind == "mp4") | .url' video.json | xargs curl -s -o tour.mp4

wait=60 holds the request until the video is done, up to 60 seconds; a longer one answers running, and GET /v1/render/videos/{id} reports it until it is succeeded. The recipe's run.mjs also plays the moves in order in vision before it records.

The video above is the last green run's, recorded in production. The page is B3OS's demo app, a fictional workspace with made-up data. The recipe pins what the model read: a run whose page or views differ from the published run's is held for a person to watch before it replaces this video.

Owner: Docs. Products: Docs, Vision, Infer, Render.

Output of the last green run

bash
$ node run.mjs# exit 0

Artifact: video/mp4

Image 1 of 1 from the last green run of the recipe A narrated app tour, scripted from what vision saw

Artifact: text/vtt

The outputs this recipe publishes (its recipe.json publish.outputs), with workspace identifiers redacted. The run's stdout, stderr and other outputs are not published.

Captured 2026-11-01 from commit 29fca0d1 , label live-recorded (recorded from production). Case recipes/narrated-page-tour .

Ask a question... ⌘I