From record-demo
Record a video demo of a web app feature using browser automation
How this skill is triggered — by the user, by Claude, or both
Slash command
/record-demo:record-demoThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are recording a video demo of a web application feature using Playwright browser automation. Follow these steps exactly.
You are recording a video demo of a web application feature using Playwright browser automation. Follow these steps exactly.
Architecture: This plugin separates distributable code from runtime state:
--plugin-dir.~/.claude/record-demo/): All runtime state — Playwright installation, auth state, recordings, project configs. Created automatically on first use. Never distributed.demo.config.json in the project repo — committed, shared with the team.~/.claude/record-demo/projects/<project-name>.json — personal overrides.~/.claude/record-demo/ # Per-user runtime data (auto-created)
package.json # Playwright dependency
node_modules/ # Installed here
projects/ # Local per-project config overrides
<project-name>.json
auth/ # Saved browser auth state
<project-name>.json
recordings/ # All recordings
<project-name>/
<timestamp>-<slug>/
script.mjs
recording.webm
recording.mp4
screenshot-failure.png
<any-project>/ # Optional shared config
demo.config.json # Committed to repo
The project name is the basename of the git repo root (or working directory). For example, working in ~/foo/myCoolProject → project name is myCoolProject.
Determine the project name:
git rev-parse --show-toplevel)Ensure data directory exists:
mkdir -p ~/.claude/record-demo
If ~/.claude/record-demo/package.json does not exist, create it:
{
"name": "record-demo-data",
"private": true,
"type": "module",
"dependencies": {
"playwright": "^1.50.0"
}
}
Resolve config (layered, later wins):
demo.config.json in the project root (the git root). If found, merge it on top of defaults. This is the shared config — teams can commit this to their repo.~/.claude/record-demo/projects/<project-name>.json. If found, merge it on top. This is the local config — personal overrides that aren't committed.~/.claude/record-demo/projects/<project-name>.json.Merging is shallow per top-level key: if local config has "auth", it replaces the entire "auth" object from shared config (not deep-merged).
Config schema:
{
"baseUrl": "http://localhost:3000",
"auth": {
"strategy": "saved-state",
"loginUrl": "http://localhost:3000/login",
"customScript": null,
"captureDetect": {
"type": "localStorage",
"match": "authToken"
},
"readySelector": null,
"readyTimeout": 15000
},
"viewport": { "width": 1280, "height": 720 },
"browser": "chromium",
"timeoutMs": 120000,
"hints": {
"routesDir": null,
"componentsDir": null,
"selectorStrategy": "prefer text and role selectors",
"waitStrategy": "networkidle",
"notes": null
}
}
auth fields explained:
strategy: "saved-state" (recommended), "form-login", "none", or "custom".loginUrl: Where to navigate for login.customScript: Path to a custom login script (for "custom" strategy).captureDetect: How to detect that the user has finished logging in during auth capture. Replaces interactive stdin (which doesn't work in Claude Code). Types:
"localStorage" — poll for a localStorage key containing match (e.g. "@@auth0spajs@@" for Auth0, "supabase.auth.token" for Supabase)"cookie" — poll for a cookie whose name contains match"selector" — wait for a CSS selector in match to appear in the DOM (e.g. "[data-testid='user-avatar']")"url" — wait for the page URL to match the regex in match (e.g. "^http://localhost:3000(?!/login)" — useful when auth redirects away and back)readySelector: CSS selector that indicates the auth SDK has fully initialized and the UI is in an authenticated state. The recording script waits for this element after page load before interacting. This is critical for SPAs — auth SDKs (Auth0, Firebase, Supabase, etc.) need time to hydrate tokens from localStorage/cookies after page load. During that window the UI renders in an unauthenticated state, and clicking protected elements triggers login modals. Example: "a[href='/dashboard']" (a link that only renders for logged-in users). Default: null (skip this wait).readyTimeout: How long (ms) to wait for readySelector. Default: 15000.Validate tooling:
Check that Playwright is installed in the data directory:
ls ~/.claude/record-demo/node_modules/playwright/package.json
If not found, run:
cd ~/.claude/record-demo && npm install
Then check if the Chromium browser is available:
cd ~/.claude/record-demo && npx playwright install --dry-run chromium
If browsers are not installed, tell the user to run:
cd ~/.claude/record-demo && npx playwright install chromium
and stop.
Check if auth state file exists (if strategy is "saved-state"):
ls ~/.claude/record-demo/auth/<project-name>.json
Check if ffmpeg is available: which ffmpeg
Parse $ARGUMENTS to understand what the user wants to demo.
If $ARGUMENTS is empty or vague:
If $ARGUMENTS is specific:
In both cases, produce a numbered demo plan — a human-readable list of actions the recording will show. Example:
Demo plan:
1. Navigate to the home page
2. Click "Search" in the navigation
3. Type "NVIDIA" in the search bar
4. Wait for results to load
5. Click on the first listing result
6. Scroll down to show the full listing detail
7. Pause 2 seconds on the detail view
Present the plan to the user and wait for confirmation before proceeding. Let them add, remove, or reorder steps.
Use Glob and Grep to find the frontend routes and components relevant to the demo:
hints.routesDir from config if provided (e.g. src/app/ for Next.js app router)hints.componentsDir from config if providedRead the actual component files to find real selectors. Look for:
data-testid attributesIMPORTANT: Read the real frontend code. Do NOT guess selectors. Every click, fill, or wait target in the generated script must come from an actual selector you found in the source code.
Based on auth.strategy in the config:
"saved-state" (recommended)Auth state file: ~/.claude/record-demo/auth/<project-name>.json
If the file exists:
If the file does NOT exist, run the one-time auth capture (Step 4a).
"form-login"The generated script will fill the login form. Ask the user for credentials if not provided previously.
"none"Skip authentication entirely.
"custom"Import the user-provided login script specified in auth.customScript.
Generate and run a small script at ~/.claude/record-demo/auth/capture-<project-name>.mjs that:
captureDetect method (up to 5 minutes)context.storageState() to ~/.claude/record-demo/auth/<project-name>.jsonWhy polling, not readline? Claude Code's Bash tool does not have interactive stdin, so
readline-based "press Enter when done" approaches fail withERR_USE_AFTER_CLOSE. Instead, the capture script polls for a signal that login completed.
import { chromium } from 'playwright';
const authStatePath = '{authStateFile}';
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext({
viewport: { width: {viewportWidth}, height: {viewportHeight} }
});
const page = await context.newPage();
console.log('Opening browser — please log in. Will wait up to 5 minutes.');
await page.goto('{loginUrl}');
// Poll for successful login using configured captureDetect method
{captureDetectBlock}
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3000);
console.log('Login detected! Saving auth state...');
await context.storageState({ path: authStatePath });
console.log('Auth state saved to', authStatePath);
await browser.close();
Generate {captureDetectBlock} based on captureDetect.type:
"localStorage" (default — works for Auth0, Firebase, Supabase, etc.):
await page.waitForFunction((match) => {
for (let i = 0; i < localStorage.length; i++) {
if (localStorage.key(i)?.includes(match)) return true;
}
return false;
}, '{match}', { timeout: 300000, polling: 2000 });
"cookie":
await page.waitForFunction((match) => document.cookie.includes(match),
'{match}', { timeout: 300000, polling: 2000 });
"selector" (e.g. wait for a user avatar or logout button):
await page.locator('{match}').waitFor({ state: 'visible', timeout: 300000 });
"url" (e.g. wait for redirect back from auth provider):
await page.waitForFunction((pattern) => new RegExp(pattern).test(window.location.href),
'{match}', { timeout: 300000, polling: 2000 });
Run from the data directory so imports resolve:
cd ~/.claude/record-demo && node auth/capture-<project-name>.mjs
After auth state is captured, continue to Step 5.
Create the output directory:
~/.claude/record-demo/recordings/<project-name>/<timestamp>-<slug>/
Where timestamp is YYYYMMDD-HHmmss and slug is a short kebab-case version of the demo description.
Generate script.mjs in that directory. The script must be a self-contained ESM module that:
playwright (resolves from data dir's node_modules since we run from there)recordVideo: { dir: '<output-dir>', size: { width, height } } from config viewportstorageState loaded from ~/.claude/record-demo/auth/<project-name>.json (if applicable)viewport from configpage.goto() for navigationpage.getByRole(), page.getByText(), page.getByPlaceholder(), page.getByTestId() for element selectionpage.click(), page.fill(), page.hover() for interactionspage.waitForLoadState('networkidle') after navigations (or per config waitStrategy)page.waitForTimeout(1500) between steps for visual pacingpage.waitForTimeout(2500) for "pause and show" momentsscreenshot-failure.png, logs the errorfinally: calls context.close() (flushes video), then browser.close()Auth readiness wait (if readySelector is configured): After the first page.goto() and waitForLoadState, wait for the readySelector element to become visible (with readyTimeout). If it times out, take a failure screenshot and process.exit(2) to signal expired auth. This gives the auth SDK time to hydrate tokens from localStorage/cookies before the script interacts with the page. If readySelector is null, skip this wait.
Auth expiry detection (fallback when no readySelector): After the first page.goto(), check if the URL was redirected to an auth provider (e.g. contains auth0.com, login). If so, process.exit(2) to signal expired auth.
Use absolute paths in the generated script for auth state and output directory, since the script runs from the data directory, not the project directory.
Example script structure:
import { chromium } from 'playwright';
import path from 'path';
import fs from 'fs';
const outputDir = '/Users/.../.claude/record-demo/recordings/<project>/<dir>';
const authState = '/Users/.../.claude/record-demo/auth/<project>.json';
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
recordVideo: { dir: outputDir, size: { width: 1280, height: 720 } },
storageState: authState,
});
const page = await context.newPage();
try {
await page.goto('{baseUrl}');
await page.waitForLoadState('networkidle');
// Auth expiry check (fallback when no readySelector)
if (page.url().includes('auth0.com') || page.url().includes('/login')) {
console.error('Auth state expired — re-run auth capture');
process.exit(2);
}
// Wait for auth SDK to initialize (if readySelector configured)
// Auth SDKs (Auth0, Firebase, etc.) hydrate tokens from localStorage
// asynchronously. The UI starts unauthenticated and re-renders once
// the SDK initializes. readySelector targets an element that only
// appears in the authenticated state.
// {readySelectorBlock — include only if readySelector is not null}
console.log('Waiting for auth to initialize...');
try {
await page.locator('{readySelector}').waitFor({ state: 'visible', timeout: {readyTimeout} });
} catch {
console.error('Auth readySelector not found — auth may have expired');
await page.screenshot({ path: path.join(outputDir, 'screenshot-failure.png') });
process.exit(2);
}
await page.waitForTimeout(1500);
// Demo steps...
} catch (err) {
console.error('Recording failed:', err.message);
await page.screenshot({ path: path.join(outputDir, 'screenshot-failure.png') });
process.exitCode = 1;
} finally {
await context.close();
await browser.close();
}
// Find the newest video file (multiple takes may exist in the same directory)
const files = fs.readdirSync(outputDir).filter(f => f.endsWith('.webm'));
if (files.length > 0) {
const sorted = files
.map(f => ({ name: f, mtime: fs.statSync(path.join(outputDir, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
const videoPath = path.join(outputDir, sorted[0].name);
console.log('Video saved:', videoPath);
}
Run from the data directory:
cd ~/.claude/record-demo && node recordings/<project>/<dir>/script.mjs
Execute the script with a timeout (use timeoutMs from config, default 120 seconds):
cd ~/.claude/record-demo && node recordings/<project>/<dir>/script.mjs
If exit code is 2 (auth expired):
If exit code is 1 (script error):
screenshot-failure.png exists, tell the user to look at it for contextIf exit code is 0 (success):
ffmpeg -i recording.webm -c:v libx264 -preset fast -crf 23 -movflags +faststart recording.mp4
If ffmpeg fails, skip silently and use the webm file.open <video-path>Ask the user:
How does the recording look? Say adjust with what to change (e.g. "adjust: slow down the scrolling"), or done to finish.
On "adjust":
script.mjs based on feedback (edit in place)On "done":
Print a summary:
Demo recording complete!
Final video: <path>
Script: <path>
If there were multiple takes, list them all:
All takes:
1. <path-to-first-video>
2. <path-to-second-video> (final)
~/.claude/record-demo/ — auth state, recordings, scripts, node_modules. Never write these to the project directory. Shared config (demo.config.json) may optionally live in the project root.script.mjs should run independentlynpx claudepluginhub tjwds/record-demoRecords polished UI demo videos of web applications using Playwright, following a three-phase discover-rehearse-record process. Best for creating walkthroughs, tutorials, or feature showcase videos for documentation or presentations.
Records browser interactions into MP4 feature demo video, uploads to GitHub, and embeds in PR description for reviewer walkthroughs.
Records polished UI demo videos with Playwright, including visible cursor overlays, natural pacing, and professional WebM output. Uses a three-stage process of discovery, rehearsal, and recording.