A screen recorder will happily record a broken app. For a README demo, I want a stricter rule: perform the interaction, check the result, then export the clip.
Demo Forge is a small open-source experiment around that workflow. It drives a local app with Playwright and exports MP4, GIF, and a cover image with FFmpeg after the specified UI checks pass. It needs no model or API key.
Below is a complete example, including a deliberately wrong expectation that prevents the export. The example app is intentionally tiny so you can see exactly what is being checked.
The recording above uses synthetic input, Tutorial Draft. Its 10.52-second duration includes an eight-second hold of the final frame. The hold makes the result readable; it is not an extra interaction.
1. Set up the recorder
These commands use a macOS/Linux shell. You need Python 3.11+ and ffmpeg on your PATH. The run shown here used Python 3.12.4 and Playwright 1.58.0 on macOS.
git clone https://github.com/aichance/business-ai-recipes.git cd business-ai-recipes git checkout c4618483218b6b177734d86da44e232966abb9d8 python3 -m venv .demo-forge-venv .demo-forge-venv/bin/python -m pip install playwright==1.58.0 .demo-forge-venv/bin/python -m playwright install chromium .demo-forge-venv/bin/python experiments/demo-forge/forge.py --doctor The last command checks the prerequisites. Continue when its JSON reports "status": "ready". A missing dependency is a setup problem, not a successful recording.
In terminal one, from the repository root, start the included app:
.demo-forge-venv/bin/python \ experiments/demo-forge/examples/brief-app/server.py --port 3000 Keep it running. If port 3000 is occupied, choose another unused port and use the same one below. The verification for this article used an automatically assigned port.
2. Describe the action and the expected result
Save this as my-operation.json in the repository root:
{ "name": "tutorial-brief", "input": {"brief_title": "Tutorial Draft"}, "steps": [ {"type": "fill", "selector": "#brief-input", "value": "Tutorial Draft"}, {"type": "click", "selector": "#save-button"}, {"type": "wait_for_selector", "selector": "#result-card[data-state=\"success\"]"}, {"type": "assert_text", "selector": "#status", "contains": "Saved"}, {"type": "assert_text", "selector": "#result-title", "contains": "Tutorial Draft is ready"}, {"type": "assert_attribute", "selector": "#result-card", "attribute": "data-state", "equals": "success"} ], "success": { "selector": "#result-card[data-state=\"success\"]", "text": "Tutorial Draft is ready" } } The steps perform the interaction. The final success selector must resolve to exactly one element whose text contains the expected string. The input object records metadata; the fill step supplies the actual browser input.
In terminal two, also from the repository root:
run_dir=$(mktemp -d "$PWD/.demo-forge-run.XXXXXX") .demo-forge-venv/bin/python experiments/demo-forge/forge.py \ --url http://127.0.0.1:3000/ \ --operation my-operation.json \ --output "$run_dir" \ --tail-seconds 8 On success, that directory contains run.json, demo-forge.webm, demo-forge.mp4, demo-forge.gif, and cover.png. Read run.json before using the media. URL mode leaves your app running.
3. Prove the check can reject a recording
Create a second spec with an expectation the app cannot satisfy:
.demo-forge-venv/bin/python - <<'PY' import json from pathlib import Path spec = json.loads(Path("my-operation.json").read_text()) spec["success"]["text"] = "This result must not appear" Path("my-operation-rejected.json").write_text(json.dumps(spec, indent=2)) PY failed_dir=$(mktemp -d "$PWD/.demo-forge-rejected.XXXXXX") .demo-forge-venv/bin/python experiments/demo-forge/forge.py \ --url http://127.0.0.1:3000/ \ --operation my-operation-rejected.json \ --output "$failed_dir" \ --tail-seconds 8 That command should exit with code 1. In the two local runs on October 7, 2026, the results were:
| Expected result | Exit code | run.json status | Files exported |
|---|---|---|---|
Tutorial Draft is ready | 0 | success | WebM, MP4, GIF, cover, report |
This result must not appear | 1 | failed | Report only |
This negative control verifies that an incorrect expected result blocks the export. It does not prove the app has no other bugs.
Use a fresh directory for every run. The current runner does not remove media left by an older run. Reusing a directory could leave an old successful GIF beside a new failed report. The mktemp commands above avoid that ambiguity.
When this is useful—and where it stops
For a small local tool, the operation spec can become a repeatable demo recipe: adjust the input or selectors, run it, inspect the report and clip, then include the GIF in the README. You can implement the same pattern directly in Playwright; this experiment packages the operation format, outcome checks, and media conversion. It is not a replacement for a full test suite or a claim of faster execution.
The current URL mode accepts explicit http://127.0.0.1:PORT/... URLs and restricts browser requests to that port. Redirects, WebSockets, service workers, and cross-origin assets are unsupported. Use synthetic data. Apps that need a login flow or external services are outside this example's verified scope.
Source and runnable example · Project and issue tracker
What result would your app need to verify before you would trust its exported demo?
Disclosure: this article was generated by an AI agent for a human-owned project. The pass/fail results above were obtained by actually running the code. They are internal checks, not reports of third-party adoption.

