Install
openclaw skills install @conorbronsdon/demo-gif-skillRecord, render, optimize, and embed a demo GIF in a repo's README. Use when asked to add a demo gif, record a demo, show the tool in action, or make a README more visual.
openclaw skills install @conorbronsdon/demo-gif-skillTurns "add a demo GIF to this README" into a repeatable pipeline: pick the right recording method for the project, generate a reproducible recording script, render it, optimize the file size, and embed it in the README correctly.
Don't screen-record by hand and drop a 40 MB file in the repo. Every step here is scripted so the demo can be regenerated later when the tool changes.
Look at the repo before picking a method.
| Signal | Demo type | Method |
|---|---|---|
bin/, a CLI entry point, package.json with a bin field, argparse/click/cobra code, a Dockerfile that runs a command | CLI tool | Terminal recording (vhs) |
| A TUI framework (bubbletea, ratatui, textual, blessed, ink) | TUI | Terminal recording (vhs), larger window |
package.json with a frontend framework, a dev server script, index.html | Web app | Browser recording (Playwright + ffmpeg) |
| A library/SDK with no standalone entry point — README shows import + usage snippets | Library | REPL or a short example script, recorded as a terminal session |
If it's ambiguous, ask which surface the demo should show (the CLI, a specific screen, a code example) rather than guessing.
Terminal (CLI, TUI, library REPL) — preferred path: vhs
VHS by Charm renders a .tape file (a plain-text script of terminal actions) into a GIF/MP4/WebM deterministically — same input, same output, every time. That reproducibility is why it's the default here over screen-recording software.
Install:
brew install vhsscoop install vhs (pulls in ttyd and ffmpeg as dependencies automatically) or winget install charmbracelet.vhscurl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg && echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list && sudo apt update && sudo apt install vhs ffmpeg. Install ttyd separately from its GitHub releases — apt doesn't carry it.go install github.com/charmbracelet/vhs@latestdocker run --rm -v $PWD:/vhs ghcr.io/charmbracelet/vhs <file>.tapeWrite a .tape file. Minimum viable structure:
Output demo.gif
Set Shell "bash"
Set FontSize 18
Set Width 1200
Set Height 600
Set Theme "Dracula"
Type "your-cli --help"
Enter
Sleep 2s
See references/tape-cookbook.md for the full command reference, typing-cadence guidance, and patterns for hiding setup steps. examples/cli-demo.tape is a complete runnable example.
Render with:
vhs demo.tape
Terminal fallback — no vhs available
If vhs can't be installed (locked-down CI box, unsupported OS), record with asciinema and convert with agg:
asciinema rec demo.cast
agg demo.cast demo.gif --theme monokai --speed 1.5 --idle-time-limit 2
asciinema rec captures a live session (less reproducible than a .tape script — there's no re-runnable source of truth), so prefer vhs whenever it's available. Full flag notes in references/tape-cookbook.md.
Web app — Playwright + ffmpeg
Playwright can record a real browser session to video via the recordVideo context option. Write a short script that opens the app, drives the interaction you want to show, and closes cleanly (the video only finalizes on context close).
const context = await browser.newContext({
recordVideo: { dir: 'recordings', size: { width: 1280, height: 720 } },
});
const page = await context.newPage();
await page.goto('http://localhost:3000');
// ...drive the demo interaction...
await context.close(); // required — video is written on close
See references/web-capture.md for the full setup (including page.video().saveAs() to get a predictable filename) and examples/web-demo.spec.ts for a complete script. Convert the resulting .webm/.mp4 to GIF with the ffmpeg two-pass palette flow in Step 4 — don't skip straight to a naive single-pass GIF encode, it produces banding on anything but flat-color UI.
vhs demo.tape produces the GIF (or MP4/WebM — set the extension in the Output line) directly.Watch the raw output before optimizing. Re-record if:
Target: under 8 MB so it loads fast in a rendered README (GitHub hard-caps file display around 10 MB and truncates the render above that; 25 MB is the raw upload limit). Smaller is better for anyone on a slow connection.
From a video source (Playwright output) — ffmpeg two-pass palette:
ffmpeg -i input.mp4 -vf "fps=12,scale=800:-1:flags=lanczos,palettegen=max_colors=128" palette.png
ffmpeg -i input.mp4 -i palette.png -lavfi "fps=12,scale=800:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=sierra2_4a" output.gif
Two-pass beats a single-pass -vf ... format=gif encode because palettegen builds a palette tuned to the actual frames instead of using a fixed 256-color web-safe table — noticeably fewer banding artifacts on UI screenshots. Both filters and their options (max_colors, dither mode, etc.) come straight from ffmpeg -h filter=palettegen / filter=paletteuse — run those locally to confirm on your ffmpeg build if flags don't match.
If gifski is installed, it's a solid alternative for image-heavy web content: gifski -o output.gif --fps 12 --width 800 frame*.png (needs an image sequence, not a video file directly).
Already a GIF (vhs output, or after the ffmpeg pass above) — gifsicle:
gifsicle -O3 --lossy=30 --colors 128 output.gif -o output-optimized.gif
-O3 runs gifsicle's most aggressive frame-diffing optimization (slower, smaller output than -O1/-O2).--lossy=N trades fidelity for size; default lossiness is 20, 30-50 is usually still clean for terminal/UI captures. Push higher only if size is still over budget.--colors 128 caps the palette; drop to 64 for terminal recordings (few colors anyway) if still over budget.Frame rate and dimensions (apply at the fps=/scale= step above, or via Set Framerate/Set Width in vhs):
Verify the result:
ls -la output-optimized.gif # confirm it's under 8 MB
If it's still too big: drop fps first, then width, then push --lossy higher, then cut duration. In that order — cutting duration first loses the most information for the least size gain.
docs/demo.gif or .github/demo.gif — commit it to the repo, don't point at an external host (imgur, a CDN, a gist). External hosts rot: links break, hotlinking gets blocked, and there's no guarantee the asset still matches the code it's demonstrating.
<img> tag instead when you need to control display width (GitHub renders markdown images at native size, which is often too wide):
<img src="docs/demo.gif" width="800" alt="Demo of tool-name showing X" />
<picture> + prefers-color-scheme for this — the mechanism GitHub actually documents is a URL fragment on each image: #gh-dark-mode-only / #gh-light-mode-only, e.g.  next to . If the tool has both a dark and light terminal/UI theme and it matters, either pick the theme that reads better on a white README background (most repos are read on GitHub's light-mode-by-default web view) or commit two GIFs with the fragment suffixes above. Don't over-engineer this for a first pass — one well-chosen theme is usually enough.ls -la on the final file and confirm it's under the 8 MB target from Step 4. Also check it renders — GitHub's README preview (or a local markdown preview) — before committing, since a corrupt or truncated GIF is a common two-pass-ffmpeg failure mode.ttyd isn't installed or isn't on PATH — vhs shells out to it to actually run the terminal. vhs validate demo.tape only parses tape syntax — it does not check ttyd/ffmpeg on PATH or verify Required binaries (confirmed against vhs 0.11.0: a Required binary missing from PATH passes validate and only fails at render time). Check directly with ttyd --version and ffmpeg -version before rendering.await context.close().--colors set too low for a photo-real web capture (fine for terminal captures, bad for anything with gradients/photos).--lossy past 60-80 — at that point you're better off cutting the recording shorter.invalid Set Theme "X": did you mean "Y": vhs theme names are case-sensitive and don't always match the common name (confirmed on vhs 0.11.0: Set Theme "Nord" fails, the real entry is lowercase nord; Set Theme "Monokai" fails too, since only variants like Monokai Pro exist). vhs validate doesn't catch this — it only fails at render. Run vhs themes | grep -i <name> to get the exact string before setting it.gifsicle/agg not installed: this skill was hardened on a machine with vhs, ffmpeg, and Playwright but no gifsicle or agg. When gifsicle is missing, the fallback isn't a free win: adding an Output demo.mp4 line alongside Output demo.gif and running the ffmpeg two-pass palette flow (Step 4) on the .mp4 is worth trying, but verify it's actually smaller before using it — on the short, mostly-static terminal recording used for this repo's own docs/demo.gif, the mp4-round-trip result came out larger than vhs's direct GIF output (175KB vs. 126KB), because re-encoding through lossy video and rebuilding a palette from a video codec's compression artifacts loses more than it recovers for simple terminal captures. Compare both file sizes and keep the smaller one; don't assume the fallback helps.vhs (or any other tool not on the ambient shell's minimal PATH) inside the recorded terminal fails with command not found: vhs launches the shell it records with a minimal environment, not the caller's full PATH — confirmed on Windows/vhs 0.11.0, where vhs itself (installed via winget, on PATH in the invoking terminal) is not reachable from inside a tape's own Type "vhs ..." command. If a demo needs to show a tool running, confirm it's reachable inside the recorded shell first (e.g. Type "which <tool>" as a throwaway check) rather than assuming the outer environment carries in.vhs <file>.tape render inside another vhs recording crashes with panic: write tcp ...: use of closed network connection: this is a go-rod/chromedp browser-automation collision between the inner and outer vhs processes, confirmed reproducible on vhs 0.11.0. Don't record a tape whose captured commands include a real vhs render — pre-render the inner GIF as a separate, non-nested step (before the outer recording starts) and reference its real output (e.g. via cat/ls) in the tape that's actually being recorded.