Demo Flow Runner
You generate and run Playwright automation scripts from natural language scenario descriptions, with full recording (video + click/keystroke visualization). Supports both browser demos (HAR + video) and terminal/CLI demos (video of real terminal sessions via xterm.js + node-pty).
Preamble (run first)
bash
# Check skill lib
if [ -f ".claude/skills/llmer-demo/lib/index.js" ]; then
echo "SKILL_LIB: ready"
else
echo "SKILL_LIB: missing"
fi
# Check Playwright
if npx playwright --version >/dev/null 2>&1; then
echo "PLAYWRIGHT: $(npx playwright --version 2>&1)"
else
echo "PLAYWRIGHT: missing"
fi
# Check Chromium
if [ -d "$(npx playwright install --dry-run chromium 2>/dev/null | head -1)" ] || npx playwright install --dry-run chromium 2>&1 | grep -q "is already installed"; then
echo "CHROMIUM: ready"
else
echo "CHROMIUM: missing"
fi
# Check ffmpeg (required for video)
if command -v ffmpeg >/dev/null 2>&1; then
echo "FFMPEG: $(ffmpeg -version 2>&1 | head -1)"
else
echo "FFMPEG: missing (video conversion will fail)"
fi
# Check node-pty (required for terminal demos, optional otherwise)
if node -e "require('node-pty')" 2>/dev/null; then
echo "NODE_PTY: ready"
else
echo "NODE_PTY: missing (install with 'npm install node-pty' for terminal demos)"
fi
# Check for .demoflow
if [ -d ".demoflow" ]; then
echo "DEMOFLOW: initialized"
ls .demoflow/scenarios/ 2>/dev/null | head -10
else
echo "DEMOFLOW: not initialized (run /llmer-demo init)"
fi
If
is
: the skill was not installed correctly. Tell the user to run
npx skills add llmer/skill-demoflow
.
If
is
: run
npm install --save-dev @playwright/test
.
If
is
: run
npx playwright install chromium
.
If
is
: warn the user that video conversion requires ffmpeg. They can install it with
(macOS) or
(Linux). Recording will still work but MP4 output will be skipped.
If
is
and the user requests a terminal demo: run
. This is only required for terminal/CLI demos, not browser demos.
Subcommands
- → Run the Init Flow to explore the project and scaffold
- → List available scenarios from and targets from
- → Launch the DemoFlow Studio web UI for adjusting frame options on existing recordings: (or if built). Opens at http://localhost:3274
render [scenario-name] [--style macos|windows-xp|windows-98|macos-terminal|vscode|none] [--title "..."]
→ Re-render an existing capture without re-recording. Only works if output/{name}/recording.webm
exists.
- Anything else → Run a scenario (see Run Flow below)
Init Flow
When
is
, you explore the project and bootstrap
.
What to do
-
Ensure runtime dependencies are installed:
- Verify the skill lib exists:
.claude/skills/llmer-demo/lib/index.js
(installed by )
- Check if is in . If not:
npm install --save-dev @playwright/test
- Check if Playwright Chromium is available. If not:
npx playwright install chromium
-
Explore the codebase using the Explore agent:
- Find all routes/pages (look for or directory structures, route files, page components)
- Identify the auth flow (login, signup, verify, OAuth — look for auth pages, middleware, session handling)
- Find key interactive components (forms, wizards, modals, dialogs)
- Detect the framework (Next.js, Remix, SvelteKit, etc.) and UI library (shadcn, MUI, etc.)
- Look for existing environment config (.env.example, .env.local) to discover URLs, API endpoints, email services
- Check if Supabase, Mailpit, or other local services are configured (docker-compose, supabase/config.toml)
-
Generate from what you discover:
- Auth flow (login page selectors, OTP input, submit buttons)
- Key pages and their interactive elements
- Navigation patterns (sidebar, breadcrumbs, tabs, header links)
- Modals/dialogs that might appear during flows
- Rate limits or gotchas you spot in the code
- Keep it concise — focus on what a Playwright script would need to know
-
Generate target configs in
:
- — detect the dev server URL (from package.json scripts, .env), and if there's a local email server (Mailpit/Inbucket), configure auto-OTP
- (if a prod URL is discoverable) — use prompt strategy for OTP
- Ask the user to confirm URLs and credentials you can't find
-
Suggest 3-5 scenarios based on the flows you discover:
- The primary happy path (signup → first action → core feature)
- A CRUD flow (create, read, update, delete of the main entity)
- Navigation coverage (visit every major section)
- An edge case or error flow if obvious
- Present these to the user and ask which to generate. Write the selected ones to
-
Create the directory structure if it doesn't exist:
.demoflow/
├── context.md
├── targets/
│ ├── local.md
│ └── production.md
└── scenarios/
└── (suggested scenarios)
-
Ensure is excluded from the build:
- Read and check if is in the array
- If not, add to the array — Playwright imports are Node-only and will break framework builds (Next.js, Remix, etc.) if included in the compilation scope
- If there's no array, create one with
-
Report what was created and suggest the user review context.md for accuracy.
Run Flow
Step 1: Resolve the scenario
If
matches a file in
(by name, with or without
), read that file. Otherwise treat
as an inline flow description.
If
is present in arguments, use that target. Otherwise use the
field from the scenario's
section.
Step 2: Read target + context
- Read
.demoflow/targets/{target-name}.md
— this is the DUT (Device Under Test) config that tells you the base URL, how to handle auth/OTP, timeouts, and environment-specific behavior
- Read if it exists — app-specific UI patterns, selectors, navigation hints
- Read the scenario file (or use the inline description)
- If needed, read relevant source files to understand selectors and page structure
Step 2b: Check for valid existing capture
Before generating and running a new script, check if a valid capture already exists:
typescript
import { isCaptureValid, render } from '../.claude/skills/llmer-demo/lib/index.js'
if (isCaptureValid('output/scenario-name', { scenarioPath, targetPath })) {
// Skip re-recording — just re-render with current options
const result = await render('output/scenario-name', { frameStyle: 'macos' })
console.log('Re-rendered from cache:', result.mp4Path)
} else {
// Capture is stale — run the full recording flow below
}
If
returns true, skip to rendering. This avoids expensive browser replay when only frame options changed.
Step 3: Generate a step-based script
Write a script to
scripts/demo-{scenario-name}.ts
(e.g.
) that uses
with a declarative
array.
Do not write imperative Playwright code — the step runner handles selectors, waits, retries, and error screenshots automatically.
- Imports from the skill lib (path relative to ):
typescript
import { launchWithRecording, finalize, runSteps, isCaptureValid, render, type Step } from '../.claude/skills/llmer-demo/lib/index.js'
- Config variables at the top from the target config: , , timeouts
- A array that maps 1:1 from the scenario
- Fixed boilerplate: → →
Step Type Reference
| Action | Fields | What it does |
|---|
| , | Go to URL, auto-waits for networkidle |
| | Click element — runner resolves text to the right selector |
| , | Type into input — clears first by default |
| , | Choose dropdown option |
| | Press keyboard key (e.g. , ) |
| or | Wait for time, URL pattern (), or element text |
| , | Verify element is visible/hidden/attached |
| | Save screenshot to output dir |
| , (//), , | Extract value into a variable |
| , | Prompt user for input (OTP, etc.) — auto-pauses video |
| / | — | Manually trim idle time from video |
| | Escape hatch for custom logic (OTP fetch, API calls) |
Target resolution ( field)
The runner tries multiple Playwright strategies automatically. Just use the visible text:
- → tries getByRole(button), getByText, etc. — finds what works
- → — use when text is ambiguous
- →
page.getByPlaceholder('Enter name')
- →
page.getByRole('link', { name: 'Dashboard' })
- →
page.getByTestId('submit-btn')
- →
page.locator('.custom-selector')
— last resort
Default: just use the visible text. Only add prefixes when you need to disambiguate.
Variable interpolation
and
steps store values. Use
in any later step's string fields:
typescript
{ action: 'save', name: 'ws_id', from: 'url', pattern: '/workspaces/([^/]+)' },
{ action: 'navigate', url: `${BASE_URL}/workspaces/\${ws_id}/board` },
Note: use
in template literals so JavaScript doesn't interpolate — the runner handles it at runtime.
Key pattern
typescript
import { launchWithRecording, finalize, runSteps, isCaptureValid, render, type Step } from '../.claude/skills/llmer-demo/lib/index.js'
const BASE_URL = '...' // from target
const TEST_EMAIL = '...' // from target
const SCENARIO_PATH = '.demoflow/scenarios/scenario-name.md'
const TARGET_PATH = '.demoflow/targets/local.md'
const steps: Step[] = [
{ action: 'navigate', url: `${BASE_URL}/login` },
{ action: 'fill', target: 'label:Email', value: TEST_EMAIL },
{ action: 'click', target: 'Continue with Email' },
{ action: 'wait', for: '**/verify' },
// OTP via Mailpit (custom logic → use exec)
{ action: 'exec', description: 'Fetch OTP from Mailpit', fn: async (ctx) => {
const res = await fetch('http://127.0.0.1:8025/api/v1/messages')
const msgs = await res.json() as any
const body = msgs.messages[0]?.Text ?? ''
ctx.vars.otp = (body.match(/(\d{6})/)?.[1]) ?? ''
}},
{ action: 'fill', target: 'placeholder:Enter code', value: '\${otp}' },
{ action: 'click', target: 'Verify' },
{ action: 'wait', for: '**/dashboard' },
// OR: OTP via user prompt
// { action: 'input', message: 'Enter the OTP code', saveAs: 'otp' },
]
async function main() {
if (isCaptureValid('output/scenario-name', { scenarioPath: SCENARIO_PATH, targetPath: TARGET_PATH })) {
console.log('Valid capture exists — re-rendering only')
const result = await render('output/scenario-name', { frameStyle: 'macos' })
console.log('Video:', result.mp4Path)
return
}
const session = await launchWithRecording({
outputDir: 'output/scenario-name',
headed: true,
slowMo: 100,
desktopFrame: true,
scenarioPath: SCENARIO_PATH,
targetPath: TARGET_PATH,
})
try {
await runSteps(session, steps, {
onStep: (i, step, status) => console.log(`[${status}] Step ${i + 1}: ${step.description ?? step.action}`)
})
} finally {
const result = await finalize(session)
console.log('HAR:', result.harPath)
console.log('Video:', result.mp4Path)
}
}
main()
Step 4: Run the script
- Run the script in the background:
npx tsx scripts/demo-{scenario-name}.ts
- Monitor for the input signal file: poll
output/{name}/.waiting-for-input
- When the signal appears, read its contents (the prompt message) and ask the user for the value
- When the user responds, write their answer to
output/{name}/.input-value
- The script will pick it up and continue automatically
- Wait for the script to complete
Step 5: Report results
Tell the user:
- Target used (local/production/etc.)
- HAR file path
- Video file path (MP4)
- Any errors that occurred
- Summary of what was captured
Step 6: Offer adjustments
After reporting results, ask the user if they'd like to adjust the video. Present these options:
- Change frame style — re-render with , , , , , or (raw viewport)
- Change title — update the text shown in the frame's titlebar/tab
- Open Studio — launch the DemoFlow Studio web UI at http://localhost:3274 for live preview and adjustments
- Keep as-is — done
If the user picks a re-render option, call
on the existing capture (no re-recording needed):
typescript
import { render } from '../.claude/skills/llmer-demo/lib/index.js'
const result = await render('output/{scenario-name}', {
frameStyle: 'windows-xp', // or 'macos', 'none'
title: 'Custom Title', // optional
})
console.log('Updated video:', result.mp4Path)
If the user picks Studio, run:
node -e "import('./.claude/skills/llmer-demo/lib/studio.js').then(m => m.startStudio())"
After any adjustment, report the updated file path and offer again — the user may want to try multiple styles.
Terminal Demo Flow
For CLI-based product demos (agentic coding tools, CLIs, terminal applications). Uses xterm.js rendered in Playwright connected to a real PTY via node-pty.
Detecting terminal scenarios
A scenario is a terminal demo if:
- Its section includes
- The scenario only describes CLI commands (no URLs, no browser navigation)
- The user explicitly asks for a terminal/CLI demo
Terminal scenario format
markdown
# Claude Code Refactoring Demo
## Config
type: terminal
shell: /bin/zsh
cwd: ~/projects/demo-app
frame: macos-terminal
typing_speed: 40ms
theme: dark-plus
## Steps
[require: node npx]
[hide]
cd ~/projects/demo-app && git checkout demo-branch
[show]
1. Show the project: `ls -la`
2. [pause: 2s]
3. Show the code: `cat server.ts`
4. [pause: 3s]
5. Run Claude Code: `npx claude "refactor server.ts to use async/await"`
6. [wait-for: /✓|Done/ timeout: 120s]
7. Show result: `cat server.ts`
8. [pause: 3s]
9. Run tests: `npm test`
10. [wait-for: "passing" timeout: 30s]
Terminal directives
| Directive | Generated Code |
|---|
| await session.exec('command')
|
| await session.type('text')
|
| await session.type('text', { delay: 100 })
|
| await session.press('Enter')
|
| await session.press('Tab')
|
| await session.press('Ctrl+C')
|
| await new Promise(r => setTimeout(r, 3000))
|
[wait-for: /pattern/ timeout: 30s]
| await session.waitForOutput(/pattern/, { timeout: 30000 })
|
[wait-for-prompt timeout: 10s]
| await session.waitForPrompt({ timeout: 10000 })
|
| / | / (hides setup from video) |
| Check dependencies exist before running |
| Set via TerminalRecordingOptions.env
|
| |
| await page.screenshot({ path: ... })
|
| await requestInput(outputDir, 'message', { session })
|
Terminal script pattern
typescript
import { launchTerminal, finalize, pauseRecording, resumeRecording } from '../.claude/skills/llmer-demo/lib/index.js'
const session = await launchTerminal({
outputDir: 'output/cli-demo',
shell: '/bin/zsh',
cwd: '/path/to/project',
desktopFrame: { style: 'macos-terminal', title: 'Terminal' },
theme: 'dark-plus',
typingSpeed: 50,
})
try {
// [hide] — setup commands hidden from video
pauseRecording(session)
await session.exec('cd ~/projects/demo-app')
resumeRecording(session)
// Visible demo steps
await session.exec('ls -la')
await new Promise(r => setTimeout(r, 2000))
await session.exec('npx claude "refactor server.ts"')
await session.waitForOutput(/Done|✓/, { timeout: 120_000 })
await session.exec('cat server.ts')
await new Promise(r => setTimeout(r, 3000))
} catch (err) {
await session.page.screenshot({ path: 'output/cli-demo/error.png' })
throw err
} finally {
const result = await finalize(session, { pageTitle: 'Claude Code Demo' })
console.log('Video:', result.mp4Path)
}
launchTerminal(options) → TerminalSession
Launch a terminal session with Playwright recording: xterm.js in browser connected to real PTY.
| Option | Type | Default | Description |
|---|
| | required | Output directory for video, screenshots. |
| | | Terminal canvas pixel size. |
| | | Show the browser window. |
| boolean | DesktopFrameOptions
| (macos-terminal) | Desktop frame style. |
| | or | Shell to spawn. |
| | | Working directory for PTY. |
| | | Extra environment variables. |
| | | Color theme: , , , or custom object. |
| | | Font size in px. |
| | 'Menlo, Monaco, monospace'
| Font family. |
| | | Default ms delay per character. |
TerminalSession methods
| Method | Description |
|---|
| Type text character-by-character with visual delay. |
| Send keystroke: , , , , , etc. |
exec(command, { timeout? })
| Type command + Enter + wait for prompt to return. |
waitForOutput(pattern, { timeout? })
| Wait for regex/string to appear in terminal buffer. Default timeout: 30s. |
waitForPrompt({ timeout? })
| Wait for shell prompt to return (command finished). Default timeout: 30s. |
| Clear the terminal screen. |
The session also has
,
,
,
like
. Use
/
for pause trimming. Pass to
finalize(session, { pageTitle: '...' })
at the end.
Recording Library Reference
All functions are exported from the skill lib at
.claude/skills/llmer-demo/lib/index.js
.
runSteps(session, steps, options?) → { vars }
Execute a declarative
array against a recording session. Handles selector resolution, waits, inter-step delays, error screenshots, and variable interpolation.
| Option | Type | Default | Description |
|---|
| | | Ms between steps for video readability. |
| | | Ms to wait for elements before failing. |
| | | Take error screenshot when a step fails. |
| (index, step, status) => void
| — | Progress callback for logging. |
Returns
{ vars: Record<string, string> }
— all saved variables from
and
steps.
On failure, throws with message:
Step N failed (description): original error
. Takes
screenshot automatically.
launchWithRecording(options) → RecordingSession
Launch a Chromium browser with full recording: HAR capture, video, and click visualization.
| Option | Type | Default | Description |
|---|
| | required | Output directory for HAR, video, screenshots. Created if missing. |
| | | Browser viewport size. Also sets video resolution. |
| | | Show the browser window. Set for CI. |
| | | Delay between actions in ms. Higher = more readable video. |
| | | Bypass HTTPS certificate errors (useful for local dev). |
| boolean | DesktopFrameOptions
| | Wrap video in desktop chrome. See Desktop Frame. |
Returns a
with
,
,
, and
.
finalize(session, overrides?) → RecordingResult
Close the browser, save a capture manifest, convert video to MP4, and apply desktop frame compositing. Must be called in a finally block — skipping this loses the HAR and video.
Optional
:
{ pageTitle?: string, pageUrl?: string }
— use to set meaningful metadata for terminal sessions (which otherwise show
).
Returns:
| Field | Type | Description |
|---|
| | Path to the HAR file (always present) |
| | Path to MP4 video ( if ffmpeg missing) |
| | Path to raw WebM video |
Pipeline: close browser → rename WebM → save manifest.json (git hash, viewport, pauses, page info) → trim pauses (if any) → convert to MP4 → composite with desktop frame → clean up temps.
render(outputDir, options?) → RenderResult
Re-render an existing capture to MP4 without re-recording. Reads viewport and pause data from
. Use this to change frame style, title, or resolution on an already-captured recording.
| Option | Type | Default | Description |
|---|
| 'macos' | 'windows-xp' | 'windows-98' | 'macos-terminal' | 'vscode' | 'none'
| | Frame style. produces raw viewport video. |
| | manifest page title | Window title / tab text. |
| | manifest page URL | Address bar URL (XP style). |
| | | Desktop resolution for the frame. |
Returns
{ mp4Path: string | null }
.
isCaptureValid(outputDir, options?) → boolean
Check whether a previous capture can be reused. Returns
if:
- and exist in
- Current git HEAD matches the manifest's commit hash
- Working tree is clean (not dirty)
- Skill lib hash matches (detects lib code changes across linked repos)
- Scenario/target file hashes match (if paths provided)
| Option | Type | Description |
|---|
| | Path to scenario file — hash is compared to manifest. |
| | Path to target file — hash is compared to manifest. |
Use this before recording to skip re-capture when only render options changed.
requestInput(outputDir, message, options?) → string
Pause the script and wait for external input (OTP codes, 2FA tokens, manual confirmation).
| Option | Type | Default | Description |
|---|
| | — | If provided, video is auto-paused while waiting and idle time is trimmed from final video. |
| | | Max wait time in ms before throwing. |
Writes
signal file. Polls for
response file. Auto-cleans both files after input is received.
/
Manually mark idle periods. The paused segments are trimmed from the final MP4 using ffmpeg trim + concat filters. Use when you have a known wait that isn't handled by
.
typescript
pauseRecording(session)
await someSlowOperation()
resumeRecording(session)
provideInput(outputDir, value)
Write input from the skill/CLI side. Called by the skill runner after getting the value from the user.
checkWaiting(outputDir) → string | null
Check if a script is waiting for input. Returns the prompt message or
.
Desktop Frame
Videos are composited onto a desktop OS frame (titlebar + window chrome + wallpaper background) for polished output.
Options
Pass to
launchWithRecording({ desktopFrame: ... })
:
| Value | Behavior |
|---|
| (default) | macOS Sonoma style frame |
| No frame — raw viewport video |
| macOS Sonoma with traffic lights and tab |
| Windows XP with IE chrome, taskbar, Start button (uses XP.css) |
| Windows 98 with classic grey chrome (uses 98.css) |
{ style: 'macos-terminal' }
| macOS Terminal.app style — dark titlebar, traffic lights, no address bar |
| VS Code integrated terminal — dark chrome, tab bar, blue status bar |
| Override the tab/titlebar text (default: page title at finalize) |
{ resolution: { width: 1920, height: 1080 } }
| Desktop resolution (default: 1920x1080) |
| Shift window up/down from center (negative = up) |
{ wallpaperColor: '#008080' }
| Solid wallpaper color (overrides default gradient) |
Frame anatomy
macOS (default):
- Dark gradient wallpaper (purple/blue tones)
- Window vertically centered with rounded corners, drop shadow
- Titlebar: traffic lights (red/yellow/green) + centered tab with page title
- Content area: your recorded video
Windows XP (via XP.css):
- Blue sky + green hills wallpaper
- Authentic XP title bar with minimize/maximize/close
- Address bar with URL + Go button, status bar
- XP taskbar at bottom with green Start button + clock
Windows 98 (via 98.css):
- Teal wallpaper (classic default)
- Classic grey window chrome with beveled edges
- Address bar with URL, status bar
- Grey taskbar with Start button
macOS Terminal:
- Same purple/blue gradient wallpaper as macOS browser frame
- Dark titlebar (#3c3c3c) with traffic lights
- Centered title text (shell name or custom)
- No address bar, no tab — clean terminal window look
- Best for CLI/terminal demos
VS Code:
- Dark chrome (#1f1f1f) with traffic lights
- Tab bar with terminal tab icon ()
- Blue status bar with branch name + line/col info
- Best for agentic coding tool demos shown in IDE context
How it works
- Browser records WebM video at viewport size
- converts WebM → MP4 (with pause trimming if needed)
- Playwright renders the frame HTML to a PNG at the desktop resolution
- ffmpeg overlays the MP4 onto the frame PNG at the calculated content position
- Framed MP4 replaces the original
The frame is a static PNG — it doesn't change during the video. The page title shown in the tab is captured from the page at finalize time.
Click Visualization
Every page automatically gets a click visualization script injected via
. When the user clicks anywhere:
- A red circle (30px, semi-transparent) appears at the click point
- The circle expands to 2.5x and fades out over 900ms
- Removed from DOM after 1200ms
This is captured in the video recording — no post-processing needed. The visualization works across all page navigations (re-injected on each new page load).
Output Files
After a successful run,
contains:
| File | Description |
|---|
| Full network capture (importable in Chrome DevTools Network tab) |
| Polished video with click indicators + desktop frame |
| Raw video from Playwright (pre-conversion) |
| Capture metadata (git hash, viewport, pauses) + render options |
| Screenshot at point of failure (only on error) |
If desktop frame is disabled,
is the raw viewport video without chrome.
Target Resolution
Targets are Markdown files in
that describe the runtime environment. They contain:
- Connection: URL and email/credentials
- Auth: How to handle OTP (auto via Mailpit, or prompt the user)
- Behavior: Timeouts, headed/headless, slow motion
- Notes: Environment-specific gotchas
When generating the script, read the target file and use its values. If a value uses
, generate a unique ID (e.g.
).
Handling Directives
When you see these in the scenario, map them to
objects:
| Directive | Step |
|---|
| { action: 'input', message: '...', saveAs: 'varName' }
|
| { action: 'save', name: 'var', from: 'url', pattern: '...' }
|
| { action: 'wait', ms: N * 1000 }
|
| { action: 'assert', target: '...', state: 'visible' }
|
| { action: 'screenshot', name: '...' }
|
Script Generation Guidelines
- Map scenario text directly to Step objects. "Click Create Trust" →
{ action: 'click', target: 'Create Trust' }
. Don't overthink selectors.
- Use plain text targets by default. The runner resolves them automatically. Only add prefixes (, , etc.) when the text is ambiguous.
- Don't add manual waits or delays. The runner injects between steps and auto-waits for navigation after steps.
- Use steps sparingly — only for custom logic like fetching OTP from Mailpit or calling APIs. Most scenarios need 0-2 exec steps.
- Never skip — even on error, it saves the HAR and whatever video was captured. The try/finally structure in the template handles this.
- Use variable interpolation () for dynamic values from or steps. Don't thread local variables through code.
- The boilerplate never changes. Only modify the array and the config constants at the top.
Tips
- Always call in finally. If you skip it, the HAR is lost and the browser process leaks.
- Pass to . This auto-pauses video during idle waits so the final MP4 doesn't have dead time.
- Use for video quality. 100ms is a good default. Bump to 200-300ms for demos where you want the viewer to see each step clearly.
- Use / around long waits. Any operation where the screen is static for >2s should be trimmed.
- Set viewport to match your target audience. 1280x720 is a safe default. Use 1920x1080 for full-HD demos, but note the desktop frame adds chrome around it.
- The desktop frame title is captured at finalize. Navigate to the most meaningful page before the script ends so the title bar shows something useful.
- Use
desktopFrame: { style: 'windows-xp' }
or for retro frames. XP uses authentic XP.css styling with IE chrome and taskbar. 98 gives classic grey beveled chrome. Both use the XP.css library for faithful rendering.
- Use or frame for terminal demos. gives a clean Terminal.app look; wraps the terminal in VS Code chrome — great for agentic coding demos.
- For terminal demos, pass to . Terminal sessions run at so the default page title/URL are meaningless.
- Check for ffmpeg before running. Without it, you get HAR + WebM but no MP4 and no desktop frame compositing.
Completion Status
When the run finishes, report status:
- DONE — Scenario completed. HAR + video saved. Report file paths.
- DONE_WITH_CONCERNS — Completed, but with issues (flaky selectors, slow loads, skipped steps). List each concern.
- BLOCKED — Cannot proceed. State what is blocking (missing dependency, app not running, auth failed) and what was tried.
- NEEDS_CONTEXT — Missing information required to continue (no target URL, unknown auth flow, ambiguous scenario steps). State exactly what you need.
Example Invocations
- — explore the project, generate context + targets + suggested scenarios
- — show available scenarios and targets
/llmer-demo workspace-switching
— runs .demoflow/scenarios/workspace-switching.md
with its default target
/llmer-demo workspace-switching --target production
— override to use production target
/llmer-demo "log in and navigate to the dashboard"
— generates from inline description using default target
/llmer-demo "run npm install then npm test and show the output"
— terminal demo from inline description (auto-detected: no URLs)
/llmer-demo cli-onboarding
— runs a terminal scenario from .demoflow/scenarios/cli-onboarding.md
(detected via in Config)