Skip to content
Back to skills

Demo Recorder

CSecurity

Record a polished, narrated demo video (human-like cursor, captions, arrow callouts, zoom-ins, clean screenshots) of a flow in ANY running web app from a JSON manifest or a Playwright spec — no changes or dependencies in the target repo — and optionally post it to the PR. Use when the user asks to "record a demo", "make a demo/feature video", "showcase this feature", "record what changed on my branch/PR", "record PR 123", "record a walkthrough of X", or to re-record/adjust an existing demo.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 21, 2026
ai-agentspythongoshellbashvuegitapibackend

Works with

  • claude code
  • cursor
  • terminal
  • cli
  • api

Security analysis

C60/100
  • highPerforms destructive filesystem operations
  • criticalSends environment variables or credentials to an external URL

Pro scans all 20 files and shows the line behind each finding

Scanned October 21, 2026

npx -y skills add shlior7/demo-recorder --skill demo-recorder --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Demo Recorder?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Demo Recorder
[![Security: C — Skills Directory](https://www.skillsdirectory.com/api/skills/shlior7-demo-recorder/badge)](https://www.skillsdirectory.com/skills/shlior7-demo-recorder)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: demo-recorder
description: Record a polished, narrated demo video (human-like cursor, captions, arrow callouts, zoom-ins, clean screenshots) of a flow in ANY running web app from a JSON manifest or a Playwright spec — no changes or dependencies in the target repo — and optionally post it to the PR. Use when the user asks to "record a demo", "make a demo/feature video", "showcase this feature", "record what changed on my branch/PR", "record PR 123", "record a walkthrough of X", or to re-record/adjust an existing demo.
---

# Demo Recorder

Drive a real flow in a running web app with Playwright, overlay a human-looking cursor, callouts
and camera zoom, put each step title in a caption bar above the page (optionally read aloud), and
get back an `.mp4`, clean screenshots of the key components, and frames to verify it — then post it
to the PR. Works on any web app with a URL. Playwright lives in a
separate runner home (`$DEMO_HOME`, default `~/.demo-recorder`), so the target repo gets **no new
dependencies and no new files** (unless the team opts into a committed `.demo-recorder/` folder).

`S` below = this skill's directory: `${CLAUDE_SKILL_DIR}` when installed as a plugin, otherwise the
directory this SKILL.md lives in. Everything goes through `"$S/record-demo.sh"` (`--help` lists all).

```
$DEMO_HOME/
  config.json                         global settings (look, sharing) — `record-demo.sh config`
  projects/<project>/project.json     per-project settings (URL, start commands, auth, safety)
  projects/<project>/NOTES.md         lessons + recipes learned while recording this project (not a take log)
  projects/<project>/demos/*.json     demo manifests (or *.spec.ts) — edit and re-run any time
  auth/<name>.json                    saved logins (owner-only) — never print, copy or upload
  videos/<project>/<demo>-<ts>.mp4    output (+ -shots/ -steps/ -sheet.png -chapters.json .webm)
  runs/<id>/                          one per take in progress (runner snapshot + raw output), removed when it ends
<repo>/.demo-recorder/project.json    optional TEAM variant of project.json (committed, no `root`)
```

## 0. Project settings — create once, then reuse

**Start every request with one call** — `"$S/record-demo.sh" brief [--pr N]` prints the resolved
settings (team file in the repo wins over the personal one), `NOTES.md`, running servers + branch,
cached worktrees, existing demos with their newest take, and with `--pr` the PR's title, state,
branch, merge commit, files, body and whether it's in the default branch. Don't re-run those one by
one (`project`, `servers`, `cat NOTES.md`, `gh pr view`, `git merge-base`); batch other independent
lookups into one shell call too. None yet → `"$S/record-demo.sh" init` (add `--team` to create the committed variant)
detects the package manager, dev script and framework port, then **finish it by discovery**:

| key | meaning |
|---|---|
| `name` | project id (defaults to the git origin repo name, not the worktree folder) |
| `root` | repo path (personal file only; `up --root <worktree>` overrides per run) |
| `baseUrl` | where the app is served, e.g. `http://localhost:3000` |
| `start[]` | one entry per process: `{ "name", "cwd", "command", "ready", "env" }` — `ready` is a URL that answers once it's up (API first, then UI) |
| `setup` | shell command that readies a fresh checkout (`yarn install && yarn workspace x build`) — run by `worktree` when HEAD moved |
| `auth` | saved-login name (`auth/<name>.json`) or `null` |
| `viewport` / `colorScheme` | default `1680x1050` (wide = roomy layout) / `light` |
| `forbid` | click-safety: text patterns (`"/delete/i"`, `"Publish"`) a demo must never click |
| `failOn` | locators of error UI (`{ "text": "/something went wrong/i" }`) — waits fail fast on them |
| `localStorage` | key-prefix → value, to pre-dismiss onboarding tours / cookie banners |
| `look` | per-project look override (`{ "hud": { "position": "bottom" } }`) |
| `notes` | short facts; longer prose goes in `NOTES.md` |

Every demo of the project inherits `forbid`, `failOn`, `localStorage`, `look`, `baseUrl`, `auth`,
`viewport` automatically. Read `NOTES.md` too. Update both whenever you learn something.
- `NOTES.md` holds lessons and recipes, **not a log of takes** — no "13:34 voiced re-take --voice
  --speed 1.3" lines. Take flags (`--voice`, `--speed`, look) come from the user's request and
  `config`, never from earlier takes: a logged one turns the next plain "record PR 140" into a
  voiced 1.3× take.

## 1. Storyboard from the change ("record my branch / PR N")

Derive the flow from the code — don't guess:

```bash
# PR given: `brief --pr N` already printed its body (the best brief) and files
B=$(git symbolic-ref -q --short refs/remotes/origin/HEAD \
  || { git rev-parse -q --verify origin/main >/dev/null && echo origin/main; } || echo origin/master)
git log --oneline "$B"..HEAD; git diff --stat "$B"...HEAD
git diff "$B"...HEAD -- '*.tsx' '*.jsx' '*.vue' '*.svelte' '*.html' '*.css'
```

- List the user-visible changes; skip pure backend/test/refactor commits unless they change the screen.
- **Deterministic trigger first**: dev hooks, mock modes, `?flag=` params, feature flags, fixtures
  (unit-test fixtures are rarely reachable from the UI — check). **Prove it before scripting**:
  `curl` the endpoint or click it once in a probe; note how long it takes.
- **AI/LLM features**: imperative, fully-specified prompts under ~60 chars. Down (quota 429, no
  creds) or flaky → **replay**: manifest `routes` (or `page.route`) answer the AI endpoints with
  payloads shaped from the app's own response types or captured from one real run. Everything else
  stays live. **Disclose it** on screen and in the PR comment ("AI replies replayed") — a replay must
  never pass for a live check. Seeded data: same rule.
- Guards/confirms ("unsaved changes", "are you sure"): read the guard's condition first to know
  which navigation actually triggers it.
- 4–8 captioned beats: pitch → setup → **the 2–4 moments that ARE the change** (zoom + callout +
  `shot` each) → result. 30–90 s of final video. Found what looks like a product bug? Tell the user.

## 2. Get the RIGHT app running — branch matters

```bash
"$S/record-demo.sh" servers        # :port pid cmd cwd [git branch]  — `brief` already printed it
"$S/record-demo.sh" up [--root <worktree>]    # starts project.start[] in the background, waits for ready
"$S/record-demo.sh" down                      # stops exactly what `up` started
```

- A server only counts if its `[branch]` is the one being demoed — a server from another worktree
  serves other code and the take silently shows nothing. The user may already run the branch:
  reuse it for read-only flows.
- **Never kill or restart the user's servers.** Need the branch on busy ports? `up --root
  <worktree>` with a project whose `start[]` uses free ports (env/flags: Vite `--port N
  --strictPort`, Next `-p N`, `PORT=N`), pointing the UI at your API via the env/proxy var the dev
  script uses. Port hard-coded in code → a launcher in `$DEMO_HOME/projects/<p>/` that imports the
  entry point and starts it on your port — don't edit the repo.
- **No checkout of the branch** → `WT="$("$S/record-demo.sh" worktree <branch|sha> [--as pr<N>])"`
  (merged PR, branch gone → the merge commit `brief` printed). It's a **cached** worktree under
  `$DEMO_HOME/worktrees/<p>/`, reused by every later take and session: fetch + `checkout --detach`,
  then project `setup` only when HEAD moved (cold ~18 s, re-take ~0.2–2 s). Don't `git worktree
  remove` it after a take. It never touches the user's own checkouts. Fresh worktrees lack `.env` /
  local data: reuse the user's other checkout's venv/.env **read-only** (say so); empty data → run the
  repo's `seed*`/`fixtures` scripts into an **isolated** store, never the user's.
- **Keep the stack you started running across takes** in this conversation — a re-take reuses it.
  Stop it when the user is done or switches branch, not after every take.
- **Write flows**: check what a submit triggers beyond the DB (grep the diff for `slack`, `notify`,
  `webhook`, `mail`) and switch it off in your isolated stack (env vars, settings).
- Never record against production unless the user explicitly says so. Leave the target worktree
  pristine (`git status` clean, `PYTHONDONTWRITEBYTECODE=1`). Record the working recipe in `start[]`.

## 3. Logged-in apps

```bash
"$S/record-demo.sh" login <name> --from brave --domain app.example.com   # reuse the user's browser login (macOS)
"$S/record-demo.sh" login https://app.example.com/login <name> --browser chrome   # or log in in a real window
```

`--from` reads a temp copy of the browser's cookie DB and decrypts only that domain's cookies with
the browser's Keychain key (macOS asks once — the user clicks Allow; the profile is only read).
Manual login is interactive — ask the user to run it via `! "<S>/record-demo.sh" login …`. Then set
`"auth": "<name>"` in project.json. Expired → you'll see a login page in the frames; redo it.

## 4. Probe what you'll touch

```bash
"$S/record-demo.sh" probe --url http://localhost:3000/some/page     # ARIA tree + probe/probe.png
```

Pick locators from it: role+name, test ids, labels — never CSS chains or nth-child. States you can
only reach by clicking (open dialog, AI result): put `{ "snapshot": "after-x" }` in a draft
manifest at that point — it writes `$DEMO_HOME/probe/after-x.{txt,png}` during the run. Remove it
once the locators work.

## 5. Write the demo — a manifest (default) or a spec

**Manifest** (`projects/<p>/demos/<flow>.json`) — data only, easy for anyone to edit and re-run:

```json
{
  "title": "Bulk edit · rename many items at once",
  "forbid": ["/apply all/i"],
  "routes": [{ "url": "/api/ai/suggest", "method": "POST", "json": { "suggestion": "…" } }],
  "steps": [
    { "goto": "/items" }, { "start": true },
    { "caption": "Bulk edit · rename many items at once", "hold": 2200 },
    { "click": { "role": "checkbox", "name": "Select all" } },
    { "zoom": { "role": "button", "name": "Bulk edit" }, "do": [{ "click": { "role": "button", "name": "Bulk edit" } }] },
    { "type": { "label": "New name" }, "text": "Archived {name}" },
    { "callout": { "testId": "preview" }, "text": "Live preview of every rename" },
    { "shot": "bulk-preview", "target": { "testId": "preview" } },
    { "actAndWait": { "role": "button", "name": "Save" }, "caption": "Saving", "busy": { "css": "[aria-busy=true]" } },
    { "waitFor": { "text": "Saved" } },
    { "caption": "Done", "hold": 2000 }, { "hud": "" }
  ]
}
```

Step types: `goto` `start` `request` `caption` `hud` `click` `tap` `type` `press` `wait` `waitFor`
(`state: hidden`, fast-forwarded) `actAndWait` `zoom` (nested `do`) `callout` `clearCallouts`
`shot` `snapshot`; any step can be `"optional": true`. Top level: `title` `baseUrl` `auth` `forbid` `failOn`
`localStorage` `look` `routes` (`responses: [...]` for sequential polls — last one repeats).
Locators (`LOC`): `"Text"` | `{ role, name, exact }` | `{ testId }` | `{ text }` | `{ label }` |
`{ placeholder }` | `{ css }` + `in` (scope LOC), `nth`, `first`; `"/re/i"` strings are regexes.
Full reference: header of `runner/manifest.spec.ts`.

**Spec** (`.spec.ts`) when you need real logic (loops, branching, computed values):
`import { test, expect, demo } from 'demo-recorder'` — same toolkit: `demo.narrate/hud/click/tap/
slowType/actAndWait/fastForward/withZoom/callout/clearCallouts/shot/snapshot/forbid/failOn/
fakeLocalStorage/configure/loc/waitForCaption` (`waitForCaption` = let the caption on screen finish being read/spoken, e.g. before playing media with sound).

**Writing captions** — fewer, shorter, clearer (the toolkit warns `[caption-lint]` otherwise):
- One caption per beat, **≤ 8 words**, one idea, verb-first: "Ask the chat", "Send it to the owners".
- Don't narrate what's obvious on screen, and don't stack two captions where one works — merge
  "Open the panel" + "Type a question" into "Ask the chat a question".
- Details go in a **callout** next to the element, not in the caption.
- Open with the pitch (what's new + why it matters, ≤ 10 words); close with the outcome.
- Every caption/callout stays up at least its reading time (`hud.wpm`, `hud.minMs`, scaled by
  `--speed`) — so `hold` is only how long the *action* pauses; you can't make text flash by.
- Captions sit in a fixed bar **above** the page (`hud.position top`, default; `bottom`, `overlay`,
  `off`), drawn in post — they never cover the app or jump around, and aren't in `shot` stills.
- With `--voice` each caption is read aloud (Chatterbox) and stays up until spoken — write captions
  that sound right said out loud: plain words, no abbreviations or symbols that need reading.

Baked-in lessons (don't relearn):
- Always move through the wrappers — a raw `locator.click()` desyncs the cursor glide.
- In-page overlays (cursor, callouts) live on `<html>`, not `<body>` (framework roots reconcile body
  children away); `start` after load; re-`start` after a full navigation.
- **Two levels of text**: captions tell the story; **callouts** point at the exact change (≤ 6 words,
  placed on the free side with an arrow + ring). Inside `zoom`, put the callout in `do`; a callout
  whose target the zoom pushes off-screen belongs after the zoom.
- `zoom` waits for the target to stop reflowing, fits it on screen (wide cards don't crop), zooms a
  `position:fixed` layer (side panel) itself instead of the page, and skips while a dialog is open.
  Zoom only the 2–4 moments that ARE the feature, after async content has fully landed.
- `callout`/`shot`/cursor moves scroll a target out from under a sticky/fixed header first (plain
  scroll-into-view treats it as visible); a target hidden by a modal or the zoom is not auto-fixed.
- Waits > ~2 s go in `waitFor`/`actAndWait`/`fastForward` → played 8× faster with a ⏩ caption. Never
  pad with fixed sleeps to "wait for" a backend. `failOn` makes waits die in seconds on error UI.
- `type` replaces existing text and caps typing at ~3.5 s; `tap` dispatches for animated elements.
- `shot` = clean still (no cursor/caption bar; callouts kept unless `callout.inShots` is false), cropped
  to the target + padding, grown to include callout bubbles; its capture moment is cut from the video.
- One-time popups → `localStorage` prefixes (find keys in the app source or DevTools), not clicks.
- Callouts follow their target while shown (scroll, reflow, terminal output arriving).
- Playing a take inside a recorded page (a player overlay, a results page): Playwright's Chromium
  has **no H.264** — use the take's `.webm` or a VP9 transcode (`ffmpeg -i x.mp4 -c:v libvpx-vp9
  -deadline realtime -cpu-used 8 -b:v 3M -an x.webm`). Seek it with `-chapters.json` (final-video
  seconds of every caption, callout, fast-forward, still) — don't re-derive them from raw times.

### Recording a CLI / TUI (incl. Claude Code itself)

Serve the terminal in the browser with [ttyd](https://github.com/tsl0922/ttyd) and record that page
(`$S/../../examples/terminal.spec.ts` runs against a plain shell). A `start[]` entry:
`for v in $(env | grep -oE "^CLAUDE[A-Z_]*"); do unset $v; done; exec ttyd -p 7681 -W -t rendererType=dom -t fontSize=19 -t disableLeaveAlert=true zsh -l`
- `-t rendererType=dom`: the default canvas/webgl renderer has no DOM text — `getByText`/callouts
  see nothing. `fontSize=19` reads in the video. Set `PAGER=cat` (or `git --no-pager`) — a pager
  blocks the flow.
- Recording Claude Code from inside Claude Code: the launcher must unset the inherited `CLAUDE*`
  env (the `unset` above), else the inner one says "Transcript saving is off — inherited
  CLAUDE_CODE_CHILD_SESSION marker".
- Type with `demo.moveTo(page, page.locator('.xterm-cursor'))` + `page.keyboard.type(…)` + Enter —
  not `slowType` (it selects-all first: right for inputs, wrong for a shell).
- Rows are `.xterm-rows > div` → `.filter({ hasText: /…/ }).last()`.
- **Never `page.goto` away mid-flow** — closing the socket kills the shell and the agent in it, and
  ttyd starts a fresh shell per connection. Show other things in an overlay on the terminal page.
- Wait on screen text, never sleeps: a shell → the next empty prompt row; Claude Code → a **new**
  `<Verb>ed for 1m 42s · done` line (the previous turn's line stays on screen — compare against
  it) and no `…` spinner line. Wrap the turn in `fastForward`. Keep prompts asking for short replies
  or the rows you want to call out scroll off.

## 6. Record

```bash
"$S/record-demo.sh" $DEMO_HOME/projects/<p>/demos/<flow>.json [--speed 1.2] [--voice] [--headed] [--open]
```

An explicit "record …" request means **a new take** — mention a recent existing take if useful,
never hand it back instead.

URL/auth/viewport come from flags > manifest > project.json. `--voice` (or `config voice.enabled
true`) narrates every caption with Chatterbox TTS: first use installs it into `$DEMO_HOME/voice`
(several GB, minutes — tell the user before the first voiced take); `record-demo.sh voice "text"`
auditions the current `voice.*` settings (`energy`: `calm` · `warm` · `upbeat` default · `rainbow` =
max excitement; `ref` = a .wav to clone). **One line, its own voice**: a caption step takes
`"voice": { "energy": "rainbow", "exaggeration": 0.85, "say": "Rerun. That's it!" }` (any `voice.*` key; `say` =
other words or punctuation than the caption shows, for delivery) or `"voice": false` (silent); in a spec
`demo.narrate(page, text, hold, { voice: {…} })`. The video is saved even when a step
fails (exit code still reflects it). `--open` on the take you hand back, not while iterating.

## 7. Verify — always, before handing back

1. `-sheet.png` (3×3 across the video): no login page, caption bar readable, results rendered.
   Voiced take: `ffmpeg -i <mp4> -af silencedetect=n=-40dB:d=0.4 -f null -` — speech starts at each
   caption and no line is cut off by the next.
2. `-steps/` — one frame per caption/callout at the moment it was meant to show. Read them.
3. Every `-shots/` still — they ship to the PR.

A green run proves nothing about the screen. Fix and re-record until all three look right.

## 7½. Edits after a take — the smallest tool that does it

The user asks for a change to a take they've seen → change only that, fastest path first:

| ask | do | time |
|---|---|---|
| one line sounds wrong ("more energy on the last line", "it ends like a question", "say X instead") | `record-demo.sh revoice <take.mp4> --line "<caption words>" [--energy rainbow] [--exaggeration 0.85] [--say "…"] [--tries 8]` | seconds |
| the whole voice (other energy, a cloned `ref`, a silent take that should talk) | `config voice.<key> …`, then `record-demo.sh revoice <take.mp4> [--tries 8]` | ~1 min |
| what's on screen (a step, a caption's text, a zoom, a callout) | edit the manifest/spec, re-record | minutes |

`revoice` copies the video stream untouched and lays the new line(s) in at the caption's moment; with
`--line` the rest of the audio is kept as is. It writes `<take>-revoiced.mp4` + its own `-chapters.json`
(holding the overrides), and re-voicing a `-revoiced` take updates it in place — so edits stack. It warns
when a new line runs past the next caption: shorten `say`, or re-record. **Also put the override into the
manifest/spec step** (`"voice": {…}`), so the next full re-record keeps it. `--tries 8`: a line is re-read
until its ending falls (an "open", rising end is Chatterbox's usual flaw), keeping the lowest of 8.

## 8. Deliver, share, remember

- **Always share the result in the chat**: copy the script's `━━ RESULT ━━` block — plain `file://`
  URLs (markdown links/bare paths aren't clickable in some terminals) — plus one line per beat.
- **Sharing follows settings** (`record-demo.sh config`); `unset` → ask the user once (AskUserQuestion:
  *Always* / *Ask me each time* / *Never*) and save: `config share.pr ask` (also `share.jira`, and
  `share.video` = how the comment carries the video):

  | `share.video` | in the PR comment | needs |
  |---|---|---|
  | `attach` | **plays inline** (GitHub attachment), stills in a 2-col grid | a GitHub web session: `login github --from brave` (GitHub has no API for attachments — `gh`/tokens can't) |
  | `page` | ▶ poster → **one page per PR, one tab per recording**, masonry stills, seekable player | `config share.page.deploy '<cmd with {dir} {slug}>'` + `share.page.url '<https://host/{slug}/>'` (any static host — README → Hosting) |
  | `link` | download link | nothing |

  ```bash
  "$S/record-demo.sh" share <video.mp4> --repo <worktree> --title "…" [--pr N] [--edit <comment-id>] [--dry-run]
  ```
  `ask` → run `--dry-run` first, show the preview, wait for a yes. Stills/poster go to the repo's
  `demo-assets` branch (git plumbing — worktree untouched, no CI); mention it the first time. Page
  mode re-uses one slug per PR (choosing `page` is the standing OK to redeploy *that* slug) and
  remembers the PR comment so later takes edit it. GitHub strips `target=_blank` — say
  "cmd/ctrl-click opens a new tab". Attach videos must be < 10 MB.
- **Jira/issue tracker** (`share.jira`): find candidate keys (`[A-Z][A-Z0-9]+-\d+`) in branch / PR
  title / commits and **confirm each exists** with the tracker tool (`ADR-016`, `UTF-8` look like keys).
  Post the "Jira comment" block `share` printed (links only — never base64 a video through a tool).
- Update `project.json` (incl. `setup`) + `NOTES.md` (lessons, not takes). Servers you started stay up for re-takes (§2).
  Cleanup that deletes files (datastore copies, a worktree the user wants gone) runs detached so it
  never blocks handing back the result: `nohup rm -rf <dir> >/dev/null 2>&1 &`.

Files in this skill

  • SKILL.md21.5 KB
  • record-demo.sh22.2 KB
  • runner/check/callout-follow.spec.ts2.4 KB
  • runner/check/concurrent-runs.sh2.2 KB
  • runner/check/occlusion.spec.ts1.7 KB
  • runner/check/voice-endings.py1.7 KB
  • runner/config.defaults.json531 B
  • runner/config.mjs2.2 KB
  • runner/demo-recorder.ts46.8 KB
  • runner/gh-upload.spec.ts2.3 KB
  • runner/login.mjs6.1 KB
  • runner/manifest.spec.ts5.6 KB
  • runner/package.json118 B
  • runner/page.mjs7.5 KB
  • runner/playwright.config.ts1.1 KB
  • runner/poster.mjs1.8 KB
  • runner/probe.spec.ts538 B
  • runner/project.mjs4.4 KB
  • runner/revoice.py6.1 KB
  • runner/test-results/.last-run.json45 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.