Install
$ agentstack add skill-tjwds-record-demo-record-demo ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Record Demo Skill
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 directory (distributed): Contains only this SKILL.md prompt. Installed via marketplace or
--plugin-dir. - Data directory (
~/.claude/record-demo/): All runtime state — Playwright installation, auth state, recordings, project configs. Created automatically on first use. Never distributed. - Shared config (optional):
demo.config.jsonin the project repo — committed, shared with the team. - Local config (optional):
~/.claude/record-demo/projects/.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
.json
auth/ # Saved browser auth state
.json
recordings/ # All recordings
/
-/
script.mjs
recording.webm
recording.mp4
screenshot-failure.png
/ # 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.
1. Read Config and Validate Setup
Determine the project name:
- Find the git root of the current working directory (
git rev-parse --show-toplevel) - Take the basename as the project name
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):
- Start with defaults (see schema below)
- Look for
demo.config.jsonin 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. - Look for
~/.claude/record-demo/projects/.json. If found, merge it on top. This is the local config — personal overrides that aren't committed. - If neither file exists: ask the user for a base URL and auth strategy, then create the local config file at
~/.claude/record-demo/projects/.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 containingmatch(e.g."@@auth0spajs@@"for Auth0,"supabase.auth.token"for Supabase)"cookie"— poll for a cookie whose name containsmatch"selector"— wait for a CSS selector inmatchto appear in the DOM (e.g."[data-testid='user-avatar']")"url"— wait for the page URL to match the regex inmatch(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 forreadySelector. Default:15000.
Validate tooling:
- Check that Playwright is installed in the data directory:
``bash ls ~/.claude/record-demo/node_modules/playwright/package.json ` If not found, run: `bash cd ~/.claude/record-demo && npm install ` Then check if the Chromium browser is available: `bash cd ~/.claude/record-demo && npx playwright install --dry-run chromium ` If browsers are not installed, tell the user to run: `bash cd ~/.claude/record-demo && npx playwright install chromium `` and stop.
- Check if auth state file exists (if strategy is
"saved-state"):
``bash ls ~/.claude/record-demo/auth/.json ``
- Check if
ffmpegis available:which ffmpeg
2. Understand the Demo Request
Parse $ARGUMENTS to understand what the user wants to demo.
If $ARGUMENTS is empty or vague:
- This skill runs in the same conversation, so you have full context of what the user has been working on.
- Look at the conversation history to identify what was just built, changed, or discussed.
- Offer a suggestion: "Based on what we just worked on, I can demo [specific feature/flow]. Want me to go with that, or did you have something else in mind?"
If $ARGUMENTS is specific:
- Use it directly as the demo target
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.
3. Explore the Codebase
Use Glob and Grep to find the frontend routes and components relevant to the demo:
- Use
hints.routesDirfrom config if provided (e.g.src/app/for Next.js app router) - Use
hints.componentsDirfrom config if provided - Search for route definitions, page components, and relevant UI elements
Read the actual component files to find real selectors. Look for:
data-testidattributes- aria labels and roles
- Button/link text content
- Form input names and placeholders
- Unique CSS classes (as a last resort)
IMPORTANT: 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.
4. Handle Authentication
Based on auth.strategy in the config:
Strategy: "saved-state" (recommended)
Auth state file: ~/.claude/record-demo/auth/.json
If the file exists:
- It will be loaded into the Playwright browser context automatically
- Continue to Step 5
If the file does NOT exist, run the one-time auth capture (Step 4a).
Strategy: "form-login"
The generated script will fill the login form. Ask the user for credentials if not provided previously.
Strategy: "none"
Skip authentication entirely.
Strategy: "custom"
Import the user-provided login script specified in auth.customScript.
Step 4a: One-Time Auth Capture
Generate and run a small script at ~/.claude/record-demo/auth/capture-.mjs that:
- Opens a visible (headful) browser
- Navigates to the app's login URL
- Tells the user: "Log in manually in the browser window"
- Polls for successful login using the configured
captureDetectmethod (up to 5 minutes) - Saves
context.storageState()to~/.claude/record-demo/auth/.json
> Why polling, not readline? Claude Code's Bash tool does not have interactive stdin, so readline-based "press Enter when done" approaches fail with ERR_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.):
``javascript await page.waitForFunction((match) => { for (let i = 0; i document.cookie.includes(match), '{match}', { timeout: 300000, polling: 2000 }); ``
"selector"(e.g. wait for a user avatar or logout button):
``javascript await page.locator('{match}').waitFor({ state: 'visible', timeout: 300000 }); ``
"url"(e.g. wait for redirect back from auth provider):
``javascript 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-.mjs
After auth state is captured, continue to Step 5.
5. Generate the Playwright Script
Create the output directory:
~/.claude/record-demo/recordings//-/
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:
- Imports from
playwright(resolves from data dir's node_modules since we run from there) - Launches the configured browser
- Creates a context with:
recordVideo: { dir: '', size: { width, height } }from config viewportstorageStateloaded from~/.claude/record-demo/auth/.json(if applicable)viewportfrom config
- Creates a page
- Executes each demo plan step as Playwright actions:
page.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 configwaitStrategy)page.waitForTimeout(1500)between steps for visual pacingpage.waitForTimeout(2500)for "pause and show" moments
- Wraps everything in try/catch:
- On error: takes a screenshot to
screenshot-failure.png, logs the error
- In
finally: callscontext.close()(flushes video), thenbrowser.close() - After context close, finds the video file and prints its path to stdout
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//';
const authState = '/Users/.../.claude/record-demo/auth/.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///script.mjs
6. Run and Present
Execute the script with a timeout (use timeoutMs from config, default 120 seconds):
cd ~/.claude/record-demo && node recordings///script.mjs
If exit code is 2 (auth expired):
- Tell the user: "Auth state has expired. Running auth capture so you can log in again..."
- Delete the expired auth state file
- Go back to Step 4a to re-capture auth state
- Then re-run the recording
If exit code is 1 (script error):
- Show the error output
- If
screenshot-failure.pngexists, tell the user to look at it for context - Ask the user what to do — do NOT auto-retry
If exit code is 0 (success):
- Attempt mp4 conversion if ffmpeg is available:
``bash ffmpeg -i recording.webm -c:v libx264 -preset fast -crf 23 -movflags +faststart recording.mp4 `` If ffmpeg fails, skip silently and use the
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: tjwds
- Source: tjwds/record-demo
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.