SKILL.md
---
name: record-web-journey
description: Record a web journey as evidence on the browser host - a WebM video, a screenshot after every click and page load with a red marker at the click point, and a steps.md log. Works two ways - an agent drives the browser itself through agent-browser over CDP, or a human clicks through a Playwright-launched window while the script records. Use when asked to "record a web journey", "record the flow", "capture a user flow", "show me what you clicked", or when a walkthrough needs screenshots and video as proof.
---
# Record a web journey
Produces `<out>/<NAME>-<timestamp>/` with `steps.md`, `screenshots/NNN-<kind>-<label>.png`, and
`videos/*.webm`. `steps.md` keeps typed values and query strings omitted.
Run the recorder and browser driver on the same host. Save the artifacts directly on that
host: a browser running in a VM writes to the VM, and a browser running locally writes to the
local machine. Leave the completed folder in place for later review and report its absolute
path.
Set `SKILL_DIR` to the absolute directory containing this `SKILL.md`, then use the bundled
recorder through its absolute path:
```bash
SKILL_DIR="/absolute/path/to/record-web-journey"
RECORDER="$SKILL_DIR/scripts/record-flow.mjs"
AB="$SKILL_DIR/scripts/node_modules/.bin/agent-browser"
```
Re-establish `SKILL_DIR`, `RECORDER`, `AB`, the retained CDP port, and `DISPLAY` when applicable
at the start of each separate shell call; agent harness shells may be independent.
## Setup (once per browser host)
```bash
"$SKILL_DIR/scripts/setup.sh"
"$AB" skills get core
```
Installs agent-browser and Playwright next to the recorder, adds Playwright's ffmpeg helper for
video, and adds Chromium when Google Chrome is absent. Prefer `--chrome` when Google Chrome
exists: it looks like a normal browser to sign-in pages.
## Agent-driven recording (you drive)
Parse the request into: start URL, recording NAME, and the steps to perform.
1. Check the host. `pgrep -a Xvnc` or `echo $DISPLAY` tells you whether a desktop exists; if
it does, `export DISPLAY=<it>` in every shell call and the human can watch live. Without a
display add `--headless`. Check `df -h /dev/shm`; remount to 2G if it is 64M or Chrome
renderers crash. On shared hosts, inspect active `agent-browser` processes, choose a unique
session, and close only that named session at completion.
2. From the workspace being recorded, choose an absolute output directory on this host and
an available local CDP port. Start the recorder in a dedicated long-lived terminal or
harness process session and keep that session running:
```bash
OUT="$PWD/.context/flow-recordings"
PORT="$(node -e 'const s=require("node:net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})')"
BROWSER_ARGS=()
if command -v google-chrome >/dev/null || { [[ "$(uname -s)" == Darwin ]] && [[ -x "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]]; }; then
BROWSER_ARGS=(--chrome)
fi
printf 'CDP port: %s\n' "$PORT"
node "$RECORDER" "https://app.example.com/" --name "journey-name" "${BROWSER_ARGS[@]}" --cdp "$PORT" --out "$OUT"
```
Retain the printed port and absolute recording directory across shell calls. Pass
`--profile <dir>` for a signed-in journey (see
`references/auth-and-bot-checks.md`).
3. Attach agent-browser to that Chrome with a unique session name and drive as usual:
```bash
"$AB" --session "rec-journey-name" --cdp "$PORT" snapshot -i -u
"$AB" --session "rec-journey-name" --cdp "$PORT" click @e5
```
Every command uses both `--session` and `--cdp`. Continue from the start URL loaded by the
recorder, and use `open` when a requested step calls for direct navigation.
4. Pace like a person: about a second between actions, `wait --load networkidle` after
navigations. Each screenshot is taken 250 ms after the click, so back-to-back clicks blur
which screenshot belongs to which step.
5. For anything below the fold: `scrollintoview @ref`, then `wait 1000`, then `click @ref`.
Sites with `scroll-behavior: smooth` animate the scroll; explicit settling keeps click
coordinates aligned with the target and avoids the mid-animation coordinate read verified
on claude.ai. Drive clicks through fresh, visible refs from `snapshot -i`. A visually hidden
1x1 input records as "(no visible target)", so select the visible label or control that
represents it.
6. Finish through `<recordingDir>/STOP`, wait for "Saved N visual steps" in the recorder
session, then close the named agent-browser session. This sequence finalizes the WebM
before handoff.
```bash
touch "/absolute/recording/directory/STOP"
```
Resume or wait on the recorder's long-lived session until it prints `Saved N visual steps`,
then close the named driver session:
```bash
"$AB" --session "rec-journey-name" close
```
7. Complete `steps.md` on the browser host with one short observation under each step: visible
copy, relevant responses from `agent-browser network requests`, and any result that differs
from the expected flow. Leave the artifact folder there. Report its absolute path, the step
count, the observed outcome, and anything that blocked a requested step.
## Human-driven recording (a person drives)
```bash
node "$RECORDER" https://app.example.com/ -name "F06" --chrome --profile .context/chrome-profile
```
The person uses the window normally and presses Enter in the terminal when done. The resulting
folder stays under the selected output directory on that machine for later review.
## Flags
| Flag | Meaning |
| --- | --- |
| `-name "<NAME>"` | Folder prefix, e.g. `F06-custom-mcp-<timestamp>` |
| `--chrome` | Use installed Google Chrome (recommended when present) |
| `--profile <dir>` | Reuse a Chrome user-data dir so sign-ins survive between recordings |
| `--cdp <port>` | Expose CDP so an agent can attach with `agent-browser --cdp <port>` |
| `--headless` | No window; video and screenshots still produced |
| `--out <dir>` | Parent directory, default `.context/flow-recordings` |
| `--viewport WxH` | Viewport and video size, default `1440x900` |
| `--sign-in` | With `--profile`: open plain Chrome to pass a bot check or sign in, then quit |
Ending signals: Enter (TTY), `STOP` file, browser close, SIGTERM. Enter or `STOP` gives
Playwright the clean finalization path.
scripts/record-flow.mjs
#!/usr/bin/env node
// Records a browser journey: a WebM video, a screenshot after every click and page load with a
// red marker at the click point, and a steps.md log. Two drivers share one code path:
// human: a person uses the window; the recording ends on Enter in the terminal.
// agent: `--cdp <port>` exposes the same Chrome so an agent drives it with
// `agent-browser --cdp <port>`; the recording ends when <recordingDir>/STOP appears.
// Clicks dispatched over CDP are trusted events, so the in-page listener sees both the same way.
import { spawn } from 'node:child_process';
import { access, appendFile, mkdir, readdir, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
let chromium;
try {
({ chromium } = require('playwright'));
} catch {
console.error(`playwright is not installed next to this script. Run:\n ${resolve(scriptDir, 'setup.sh')}`);
process.exit(1);
}
const usage = `Usage:
node record-flow.mjs <url> [-name "Recording name"] [--chrome] [--profile dir] [--out dir]
[--cdp port] [--headless] [--viewport WxH]
node record-flow.mjs <url> --profile dir --sign-in
Options:
-name, --name, -n Prefix the output folder with a human-readable name
--chrome Use the installed Google Chrome instead of Playwright's Chromium
--profile, -p Reuse a browser profile directory across recordings, keeping logins
--out Parent directory for recordings (default: .context/flow-recordings)
--cdp <port> Expose a CDP port so an agent can drive the browser (agent-browser --cdp)
--headless No window (for hosts without a display); video and screenshots still work
--viewport WxH Viewport and video size (default: 1440x900)
--sign-in Open the profile in a plain Chrome to sign in past a bot check, then exit
-h, --help Show this help
Ending a recording: press Enter in this terminal, close the browser, send SIGTERM, or
create the file <recordingDir>/STOP (agents use this).`;
const opts = parseCliArgs(process.argv.slice(2));
const parsedUrl = new URL(opts.requestedUrl);
if (opts.signInOnly) {
if (!opts.profileDir) throw new Error(`--sign-in needs --profile: a sign-in only survives in a reused profile.\n\n${usage}`);
await runSignIn(opts.profileDir, parsedUrl.toString());
process.exit(0);
}
const timestamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-');
const folder = opts.recordingName ? `${safeFolderPrefix(opts.recordingName)}-${timestamp}` : timestamp;
const recordingDir = resolve(opts.outDir, folder);
const screenshotDir = resolve(recordingDir, 'screenshots');
const videoDir = resolve(recordingDir, 'videos');
const stepsPath = resolve(recordingDir, 'steps.md');
const stopPath = resolve(recordingDir, 'STOP');
await Promise.all([mkdir(screenshotDir, { recursive: true }), mkdir(videoDir, { recursive: true })]);
await writeFile(
stepsPath,
[
`# ${opts.recordingName ? `${opts.recordingName} — ` : ''}User-flow recording`,
'',
...(opts.recordingName ? [`- Name: ${opts.recordingName}`] : []),
`- Started: ${new Date().toISOString()}`,
`- Start URL: ${redactUrl(parsedUrl.toString())}`,
`- Browser: ${opts.useRealChrome ? 'installed Google Chrome' : "Playwright's Chromium"}${opts.headless ? ' (headless)' : ''}`,
`- Driver: ${opts.cdpPort ? `agent over CDP port ${opts.cdpPort}` : 'human'}`,
`- Profile: ${opts.profileDir ? `reused (${opts.profileDir})` : 'ephemeral'}`,
'',
'The screenshots show the viewport shortly after each click or page load.',
'Typed field values are intentionally omitted from this log.',
'',
'## Steps',
'',
].join('\n'),
);
let step = 0;
let captureQueue = Promise.resolve();
function parseCliArgs(args) {
const out = {
requestedUrl: 'http://localhost:3000',
recordingName: undefined,
useRealChrome: false,
profileDir: undefined,
outDir: resolve('.context', 'flow-recordings'),
cdpPort: undefined,
headless: false,
viewport: { width: 1440, height: 900 },
signInOnly: false,
};
let hasUrl = false;
const value = (flag, i) => {
const v = args[i + 1];
if (!v || v.startsWith('-')) throw new Error(`${flag} requires a value.\n\n${usage}`);
return v;
};
for (let i = 0; i < args.length; i += 1) {
const a = args[i];
if (a === '-h' || a === '--help') { console.log(usage); process.exit(0); }
if (a === '-name' || a === '--name' || a === '-n') { out.recordingName = value(a, i).trim(); i += 1; continue; }
if (a.startsWith('--name=')) { out.recordingName = a.slice(7).trim(); continue; }
if (a === '--chrome') { out.useRealChrome = true; continue; }
if (a === '--headless') { out.headless = true; continue; }
if (a === '--sign-in') { out.signInOnly = true; continue; }
if (a === '--profile' || a === '-p') { out.profileDir = resolve(value(a, i)); i += 1; continue; }
if (a.startsWith('--profile=')) { out.profileDir = resolve(a.slice(10)); continue; }
if (a === '--out') { out.outDir = resolve(value(a, i)); i += 1; continue; }
if (a === '--cdp') { out.cdpPort = Number(value(a, i)); i += 1; continue; }
if (a === '--viewport') {
const [w, h] = value(a, i).split('x').map(Number);
if (!w || !h) throw new Error(`--viewport expects WxH.\n\n${usage}`);
out.viewport = { width: w, height: h }; i += 1; continue;
}
if (a.startsWith('-')) throw new Error(`Unknown option: ${a}\n\n${usage}`);
if (hasUrl) throw new Error(`Unexpected argument: ${a}\n\n${usage}`);
out.requestedUrl = a; hasUrl = true;
}
if (out.recordingName !== undefined && !out.recordingName) throw new Error(`Recording name cannot be empty.\n\n${usage}`);
if (out.cdpPort !== undefined && !Number.isInteger(out.cdpPort)) throw new Error(`--cdp expects a port number.\n\n${usage}`);
return out;
}
function defaultChromePath() {
if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
if (process.platform === 'darwin') return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
if (process.platform === 'linux') return '/usr/bin/google-chrome';
throw new Error('Set CHROME_PATH to the Google Chrome binary.');
}
// Cloudflare-style interstitials detect the CDP session Playwright needs, not the browser
// build, so no launch flag clears them. Passing the check in a Chrome that Playwright never
// touches leaves cf_clearance and the login cookie in the profile, and the recording reuses
// them. Chrome only flushes its cookie database on exit, so this waits for a full quit.
async function runSignIn(userDataDir, url) {
const executable = defaultChromePath();
try { await access(executable); } catch {
throw new Error(`Google Chrome not found at ${executable}. Set CHROME_PATH to its binary.`);
}
console.log(`\nOpening Google Chrome with no automation attached.\nProfile: ${userDataDir}\n`);
console.log('Sign in and clear any bot check, then quit Chrome entirely (Cmd+Q on macOS).');
console.log('Closing only the window loses the session: Chrome writes its cookies on exit.\n');
const chrome = spawn(executable, [`--user-data-dir=${userDataDir}`, '--no-first-run', '--no-default-browser-check', url], { stdio: 'ignore' });
const [code, signal] = await new Promise((done) => chrome.once('exit', (c, s) => done([c, s])));
if (code !== 0) {
throw new Error(`Chrome exited ${signal ? `on ${signal}` : `with code ${code}`}, so it never flushed its cookies. The profile may hold no usable session.`);
}
console.log('Chrome quit. Re-run the same command without --sign-in to record.');
}
function safeFolderPrefix(value) {
const prefix = value.replaceAll(/[^a-zA-Z0-9._-]+/g, '-').replaceAll(/^-+|-+$/g, '').slice(0, 80);
if (!prefix) throw new Error('Recording name must contain at least one letter or number.');
return prefix;
}
function safeSlug(value) {
const slug = value.toLowerCase().replaceAll(/[^a-z0-9]+/g, '-').replaceAll(/^-|-$/g, '').slice(0, 50);
return slug || 'page';
}
function redactUrl(value) {
try { const u = new URL(value); return `${u.origin}${u.pathname}`; } catch { return '[URL unavailable]'; }
}
function queueCapture(page, kind, label, clickPoint) {
captureQueue = captureQueue.then(async () => {
if (page.isClosed()) return;
await page.waitForTimeout(250);
if (page.isClosed()) return;
step += 1;
const fileName = `${String(step).padStart(3, '0')}-${kind}-${safeSlug(label)}.png`;
try {
if (clickPoint) {
await page.evaluate(({ x, y }) => {
document.getElementById('__record-flow-click-marker')?.remove();
const marker = document.createElement('div');
marker.id = '__record-flow-click-marker';
Object.assign(marker.style, {
position: 'fixed', left: `${x - 12}px`, top: `${y - 12}px`,
width: '24px', height: '24px', border: '3px solid #ef4444', borderRadius: '9999px',
background: 'rgb(239 68 68 / 20%)', boxSizing: 'border-box', pointerEvents: 'none',
zIndex: '2147483647',
});
document.documentElement.append(marker);
setTimeout(() => marker.remove(), 800);
}, clickPoint).catch(() => {});
}
await page.screenshot({ path: resolve(screenshotDir, fileName), fullPage: false });
if (clickPoint) {
await page.evaluate(() => {
document.getElementById('__record-flow-click-marker')?.remove();
}).catch(() => {});
}
await appendFile(
stepsPath,
[`${step}. **${kind}** — ${label}`, ` - URL: ${redactUrl(page.url())}`, ` - Screenshot: [${fileName}](screenshots/${fileName})`, ''].join('\n'),
);
} catch (error) {
await appendFile(stepsPath, `${step}. **${kind}** — ${label} (screenshot unavailable: ${String(error)})\n\n`);
}
});
return captureQueue;
}
// An empty user-data directory gives an ephemeral profile, so OAuth cookies are not saved
// beside the artifacts; --profile trades that away for surviving logins. The blink flag
// clears navigator.webdriver, dropping --enable-automation removes the infobar, and
// --remote-debugging-port makes Playwright use that port instead of its pipe, which is what
// lets agent-browser attach to the same Chrome.
const context = await chromium.launchPersistentContext(opts.profileDir ?? '', {
headless: opts.headless,
...(opts.useRealChrome ? { channel: 'chrome' } : {}),
ignoreDefaultArgs: ['--enable-automation'],
args: [
'--disable-blink-features=AutomationControlled',
'--window-position=0,0',
...(opts.cdpPort ? [`--remote-debugging-port=${opts.cdpPort}`] : []),
],
recordVideo: { dir: videoDir, size: opts.viewport },
viewport: opts.viewport,
});
await context.exposeBinding('__recordManualClick', async ({ page, frame }, event) => {
if (frame !== page.mainFrame()) return;
const label = typeof event?.label === 'string' ? event.label : 'unlabelled element';
const clickPoint =
Number.isFinite(event?.x) && Number.isFinite(event?.y) ? { x: event.x, y: event.y } : undefined;
await queueCapture(page, 'click', label, clickPoint);
});
await context.addInitScript(() => {
document.addEventListener(
'click',
(event) => {
const element =
event.target instanceof Element
? event.target.closest('button, a, input, textarea, select, [role], [aria-label], [title]') ?? event.target
: null;
if (!element) return;
// A click on a visually hidden 1x1 input lands on <html>; name the element under the
// pointer instead of dumping the document's text.
const named =
element === document.documentElement || element === document.body
? document.elementFromPoint(event.clientX, event.clientY) ?? element
: element;
document.getElementById('__record-flow-click-marker')?.remove();
const marker = document.createElement('div');
marker.id = '__record-flow-click-marker';
Object.assign(marker.style, {
position: 'fixed', left: `${event.clientX - 12}px`, top: `${event.clientY - 12}px`,
width: '24px', height: '24px', border: '3px solid #ef4444', borderRadius: '9999px',
background: 'rgb(239 68 68 / 20%)', boxSizing: 'border-box', pointerEvents: 'none',
zIndex: '2147483647',
});
document.documentElement.append(marker);
setTimeout(() => marker.remove(), 800);
const label =
named.getAttribute('aria-label') || named.getAttribute('title') ||
(named !== document.documentElement && named !== document.body
? named.textContent?.trim().replaceAll(/\s+/g, ' ').slice(0, 100)
: '') || `${named.tagName.toLowerCase()} (no visible target)`;
void window.__recordManualClick({ label, x: event.clientX, y: event.clientY });
},
true,
);
});
function observePage(page) {
page.on('load', () => {
void page.title().catch(() => 'page loaded').then((t) => queueCapture(page, 'load', t || 'page loaded'));
});
}
context.on('page', observePage);
for (const page of context.pages()) observePage(page);
const page = context.pages()[0] ?? (await context.newPage());
await page.goto(parsedUrl.toString());
console.log(`\nRecording to:\n${recordingDir}\n`);
if (opts.cdpPort) {
await writeFile(resolve(recordingDir, 'cdp-port'), String(opts.cdpPort));
console.log(`CDP port: ${opts.cdpPort}`);
console.log(`Drive it with: agent-browser --session <unique> --cdp ${opts.cdpPort} ...`);
}
console.log(`Finish with: touch ${stopPath}${process.stdin.isTTY ? ' (or press Enter here)' : ''}`);
console.log('Closing the browser also ends the recording.\n');
const finishers = [
new Promise((done) => { const t = setInterval(() => access(stopPath).then(() => { clearInterval(t); done('stop-file'); }, () => {}), 500); }),
new Promise((done) => context.once('close', () => done('browser'))),
new Promise((done) => { for (const s of ['SIGINT', 'SIGTERM']) process.once(s, () => done(s)); }),
];
if (process.stdin.isTTY) {
process.stdin.resume();
process.stdin.setEncoding('utf8');
finishers.push(new Promise((done) => process.stdin.once('data', () => done('enter'))));
}
const finishReason = await Promise.race(finishers);
console.log(`Finishing (${finishReason})...`);
await captureQueue;
if (finishReason !== 'browser') await context.close();
const videos = (await readdir(videoDir)).filter((n) => n.endsWith('.webm')).sort();
await appendFile(
stepsPath,
['## Video files', '', ...(videos.length ? videos.map((n) => `- [${n}](videos/${n})`) : ['- No video file was produced. End the next session with Enter or STOP so Playwright can finalize it.']), ''].join('\n'),
);
console.log(`\nSaved ${step} visual steps and ${videos.length} video file(s).\n${recordingDir}\n`);
process.exit(0);