Creating Demo Videos the Agentic Way
Every developer tool needs a demo video, and making one is the same chore every time: script it, rehearse it, screen-record it, fumble a click in take seven, re-record, then fight the audio levels in some editor. I just built one a completely different way — as code an agent writes, runs, and re-runs. No screen recorder. No video editor. Re-rendering the whole thing after feedback was one command. Here's the method.
The product I was demoing was Metatron — a self-hosted system that extracts a codebase's decisions and serves them to coding agents over MCP. But this post isn't about Metatron. It's about the method, and it transfers to any product with a UI: creating demo videos the agentic way.
A demo is three layers, not one recording
The decision that makes everything else work is refusing to bake the video as one monolithic recording. Split it into three stages, each producing a separate, swappable artifact:
gen-audio.mjs → audio/*.mp3 (ElevenLabs: voiceover + music)
record-demo.mjs → video/raw.webm + timing.json (Playwright: drive UI + capture)
mix-demo.mjs → final/demo.mp4 (ffmpeg: overlay VO + duck music)
Because the layers are independent, "make the music quieter" is a re-run of stage three, not a re-shoot. "Rewrite scene four's narration" is stage one plus stage three. "The UI changed" is stage two plus three. Nothing is ever locked. The glue between them is a tiny timing.json the recorder emits — the measured start offset of every scene — so the mixer knows exactly where to drop each line.
The mental model
Stop thinking "recording," start thinking "build." A demo video is a build artifact with three inputs — narration, screen capture, and a mix recipe — and the agent owns the whole Makefile.
Narration is just data
Voiceover is the part everyone assumes needs a human. It doesn't. The ElevenLabs API turns a scene-by-scene script into broadcast-quality audio:
const el = new ElevenLabsClient({ apiKey: process.env.ELEVENLABS_API_KEY });
const audio = await el.textToSpeech.convert(VOICE_ID, {
text: 'This is Metatron. Right now, it knows nothing about your codebase.',
modelId: 'eleven_multilingual_v2',
outputFormat: 'mp3_44100_128',
}); // collect the stream → audio/s1.mp3
Two things worth knowing. First, on the free tier the API blocks the premade "library" voices — you need a paid plan and a key scoped for text_to_speech + voices_read to use them programmatically. Second, and this is the move for a founder demo: ElevenLabs can clone your own voice. Record a minute of yourself, and every line renders in your voice from the same convert() call. The agent writes and produces the narration; it still sounds like you.
The same SDK composes the background music from a prompt (el.music.compose({ prompt, musicLengthMs })) — generate the bed longer than the video so it never runs dry. And make the generator selective, so you only re-bill the lines you actually changed:
node gen-audio.mjs s4 s5 music # regenerate just these; leave the rest
After generating, ffprobe each clip's duration. Those numbers drive the pacing in the next stage.
Drive the product, don't film it
This is where "agentic" earns its name. Playwright launches a real browser, performs the exact clicks and hovers a human would, and records the session to video natively — no OS screen recorder in the loop:
const ctx = await browser.newContext({
viewport: { width: 1920, height: 1080 },
deviceScaleFactor: 2, // retina-crisp
recordVideo: { dir: 'video', size: { width: 1920, height: 1080 } },
});
The demo is a sequence of scene functions, each paced to its narration length and timestamped live so audio and visuals stay locked:
const t0 = Date.now();
const el = () => (Date.now() - t0) / 1000;
const timing = [];
async function scene(id, fn) {
const start = el();
await fn(); // the clicks + hovers
const need = DUR[id] + TAIL; // VO length + a little tail
if (el() - start < need) await sleep(need - (el() - start));
timing.push({ id, voOffset: +(start + HEAD).toFixed(2) });
}
Two patterns made this robust. The first is seeding the backend in lockstep. Running live agents inside the demo would be slow, non-deterministic, and expensive. Instead, the recorder shells out to a small script that inserts real database rows at the exact moment a scene needs them — a query here, a piece of feedback there, a wave of new users. The UI reacts to genuine data; only the timing is choreographed.
The second is a live poll so seeded state actually shows up. The view has to notice the new rows without a reload — but a naïve poll re-renders constantly and makes the UI flicker. The fix: poll every few seconds, but only update when a substantive signature of the data changes, ignoring volatile fields like timestamps.
const sig = (d) => d.agents.map(a => `${a.id}:${a.served}:${a.feedback}`).join('|')
+ '#' + d.traces.map(t => `${t.from}>${t.to}`).join('|');
// poll every 4s; setState only when sig changes → rock-steady, animates in on a beat
The rule
Choreograph the timing, not the data. Seed real records through the real code path; never fake the UI. The result is a demo that's both perfectly paced and genuinely true — and re-takeable, because you snapshot the database and restore it for a clean run every time.
Because nothing is live, the same two-minute take records identically fifty times. The recorder finishes by writing the silent raw.webm and the timing.json map of where every line belongs.
Mix it like a producer
Now combine the silent video, the voiceover clips (each at its measured offset), and the music bed. It's one ffmpeg filtergraph, built programmatically from timing.json. Three touches separate "screen recording with audio" from "a video":
[1:a]adelay=3320:all=1[a0]; [2:a]adelay=11690:all=1[a1]; ... # VO at scene offsets
[a0][a1]...amix=inputs=9:normalize=0[vo]; [vo]asplit=2[vokey][vomix];
[10:a]aloop=loop=-1:size=8820000,volume=0.16[mus]; # loop the bed
[mus][vokey]sidechaincompress=threshold=0.04:ratio=5:attack=20:release=400[musd];
[vomix][musd]amix=inputs=2:normalize=0[mixout]
- Sidechain ducking. The
sidechaincompressfilter, keyed off a split copy of the voice, makes the music dip ~5 dB whenever the narrator speaks and lift back in the gaps. It's the difference between music that fights the voice and music that supports it. - Loop in the filtergraph with
aloop, not-stream_loop— the latter can drop out underamix. - Trim the dead air. Browser capture starts before the page paints, so the video opens on a second of black. Detect it with
blackdetect, cut it, and shift every VO offset by the same amount so sync survives.
One command, and you have demo.mp4: 1080p, narrated, scored, opening on the product instead of a black screen.
Thumbnails and channel art: render HTML, not drawtext
YouTube wants a 1280×720 thumbnail and a 2048×1152 banner. The lazy path is ffmpeg drawtext, and it looks like it. The agentic path reuses the browser you already have: lay the thumbnail out in HTML and CSS — real fonts, gradients, a darkened product frame behind a bold headline — and screenshot it with Playwright at 2× for crisp supersampling. Parameterize the headline so you can spin variants instantly. For the banner, design around YouTube's center safe area (~1235×338): wordmark and tagline in the middle, atmosphere in the edges that get cropped on mobile and TV. (This very post's social card was generated the same way.)
Quarantine the heavy stuff
A demo pipeline produces large binaries — the raw .webm, the final .mp4, audio stems — and, importantly, staging scripts that reveal the demo is choreographed. None of that belongs in your public product repo: it bloats history and quietly undercuts the "this is real" impression. So the whole pipeline lives in a separate, private marketing repo, with a .gitignore that versions the reproducible parts (scripts, docs, audio stems) and excludes the regenerable heavyweight (node_modules, the raw capture).
For the finished public video, cut a GitHub Release and attach the MP4 — that gives you a stable URL to embed in a README without committing the binary. Then derive every social format from the one master with a few ffmpeg lines: a silent autoplay loop of the climax, an animated GIF/WebP, and 1:1 and 9:16 crops for X, LinkedIn, and Shorts.
Why this is the agentic way
Step back and look at what the agent actually did, end to end: wrote the scene script and the narration; generated the voiceover and music from text; drove a real browser through the product, seeding the backend in time with the clicks; recorded it, measured the timing, and mixed audio and video with broadcast-style ducking; rendered the thumbnail and channel banner; and organized it all into a private repo with the finished cut shipped as a Release.
And because every step is a script, feedback is cheap. "Quieter music," "use these names," "trim the intro," "different headline" — each is a one-line change and a re-run, not a re-shoot. The first cut and the fifth cut cost the same. That's the unlock: a demo video stops being a dreaded one-off production and becomes what it should have been all along — code you can re-run.
- Separate audio, video, and mix. Swappability is the whole game.
- Generate narration with an API — and clone your own voice if you want it to sound like you.
- Drive the UI with Playwright and seed real data on cue; choreograph timing, not fakery.
- Measure scene offsets at record time and let the mixer place narration from that map.
- Duck the music with sidechain compression and trim the leading black — small touches, big polish gap.
- Render thumbnails and banners as HTML, screenshot at 2×.
- Quarantine media and staging scripts in a private repo; ship the finished cut via a Release.
None of this replaces taste — you still decide the story, the voice, the framing. It just makes the production reproducible, so taste is the only expensive part.