name: demoize description: >- Drive polished desktop product demos: a deterministic driver script performs the API/browser/terminal beats while the human performs only what machines can't (record button, phone), gated by Enter cues. Use when the user wants to record a product demo, screen-record a UI + API + logs story, automate a demo runthrough for Screen Studio, or turn a manual test ladder into a repeatable on-camera take. Covers real-mouse browser control (screen recorders can't see synthetic clicks), driving DevTools and log panes on camera, macOS Spaces and window-focus choreography, CDP session/token extraction, injecting inbound traffic when an environment can't receive it, and rehearsal verification.
demoize
Turn a manual demo into a conducted performance: one driver script owns every
beat a machine can do — API calls, browser clicks, terminal commands, timing —
and prints a ◆ cue then waits for Enter at every beat only the human can do
(hit record, text the phone, approve the moment). The human performs; the
script conducts — and preferably the agent conducts the script: the human's
only jobs are the record button and the phone.
Rig layout that has worked: lib/ holds the drivers (browser eyes/hands,
desktop-app hands, a local signing/privilege relay, a log TUI) and
demos/<name>/demo.ts holds that demo's verbs, constants and clips beside its
own DEMO-STATE.md. A new demo is a new folder under demos/ — no registry,
no config DSL, and no shared verb layer until a second demo proves which verbs
generalize.
If you read one thing
Nearly every failure in this work is the same shape: a check that confirms "something happened" instead of "the right thing happened." A swallowed keystroke leaves the previous response on screen; a missed click leaves the previous row selected and the detail pane still rendering; a filter typed into the wrong element leaves rows listed, just unfiltered; an in-flight response record is briefly empty; a read helper that catches its own errors returns an empty list when the token expired. Each prints green — or prints red for a beat that actually succeeded — and both directions cost takes.
So: key every assertion to something only the correct outcome has (a new
response id, a selected class, a tab that exists solely for requests with a
body); never narrate a value in a caption you didn't read off the screen — a
hardcoded POST … · 200 OK caption will happily narrate a GET; make failures
print what was there (visible tabs, row count, filter contents); and prove
any fix off camera, from your own shell, before asking for the record button —
a red line on camera costs a take even when the product behaved perfectly.
The rest of this file is that rule applied to specific surfaces.
Architecture
- Beats are typed steps:
send(API call with expected status),cue(banner + wait for Enter),shell(echo + run, for on-camera terminals), browser actions. Printlabel → statuscolored by match — the terminal transcript doubles as proof for tickets. - Expected statuses come from the real contract, not intuition. Read the actual route definitions; a wrong 201-vs-200 guess paints false reds on camera. And know the framework split: contract/schema validation rejects with its own status (ts-rest+zod: 422) while service-thrown errors carry 400/404/409 — the rehearsal teaches you which layer answers each beat, and the distinction is itself demo material.
- Re-derive expectations from the source when the branch moves under you. A contract change mid-prep invalidates every beat asserting the old behavior and any take already recorded — mark those "recorded but stale, re-record" rather than trusting the earlier all-green. Read the changed schema and the repository's check order yourself: a sibling agent's "this beat is now X" is a claim, not evidence, and a right recommendation with the wrong mechanism produces the wrong fix.
- Prefer a beat whose response shows the change. An idempotent re-add that returns 200 is indistinguishable on camera from the previous idempotent beat; a fresh insert whose response body carries the normalized value demonstrates it.
- Before rebuilding a client for a contract change, verify the client consumes the changed symbol. A frontend can re-export a schema and never wire it to anything (its input validation living in local helpers instead), in which case a republish-and-redeploy on demo night is pure risk for zero behavior change. Grep the consumers first.
- Every mutating real-mouse action needs an out-of-band assert. Typing is per-character, so a swallowed keystroke yields a wrong value, not an error: the modal still closes and the take proceeds on bad data with no red anywhere. Waiting for the dialog to disappear only proves the dialog disappeared. Verify the committed value against the API — never the DOM, which renders stale.
- Retry only when nothing happened. No response at all means the request never left, so nothing mutated and re-firing is safe; a send that returned a real status must never be retried, or you double-mutate and paint green over a genuine failure. That keying absorbs swallowed clicks without faking a pass.
- Measure timeouts, don't guess them — one off-camera probe that fires a real event and times the response. And make a miss loud: a fallback that silently degrades to a blind sleep makes "never showed" look like "worked".
- A helper that swallows errors poisons every assert built on it. A
currentX()that doestry { parse } catch { return [] }cannot distinguish "nothing there" from "couldn't read", so an expired token (403) presents as an empty result — and the guard downstream blames a swallowed keystroke while the real action succeeded. Make read helpers fail loudly with the server's actual reply. - Session tokens expire mid-session, and it looks like an input bug. A long rig-building session outlives a ~1h token. Re-mint before each rehearsal, and make the expiry message name the refresh command.
- Rehearse hands-off by piping Enter into the cues:
yes '' | node demo.ts b. Always rehearse the full take with no camera before recording. - Verify rehearsals visually, not just by API state: screenshot the page
(
page.screenshot) and read the image. API-clean + screen-wrong is a real failure mode (stale client cache rendered ghost data).
Real mouse vs synthetic clicks — the Screen Studio gotcha
Cursor-tracking recorders (Screen Studio) record the real macOS cursor as its own stream; Playwright/CDP clicks are synthetic events inside the browser — the page reacts but the recorded pointer never moves and zoom-on-click follows nothing. Hybrid fix:
- Playwright is the eyes: locators,
boundingBox(),waitForon modals. - cliclick is the hands (
brew install cliclick, grant the terminal Accessibility on first use):m:x,ymove,c:x,yclick,t:Aper-char typing (onet:per char with-w 90for human pacing),kp:return. - Calibrate viewport→screen with a probe, never arithmetic on
outerHeight - innerHeight(DevTools docking breaks it): install a one-shotmousemovelistener in the page,cliclick m:to a guessed point over the viewport (try a few y-offsets), read the event'sclientX/Y, offset = screen − client. - Teleporty
cliclickmoves are fine — the recorder re-renders cursor motion smoothly. - Real clicks get eaten by overlays that synthetic clicks sail past (toasts covering a button seconds after a save). Wrap modal-opens in a short retry loop; wrap element-waits in a close-reopen-once fallback for refetch races.
- Probe the recorder for automation (a URL scheme in
CFBundleURLTypes, a bundled CLI, anything on PATH, an MCP) each run before assuming manual; until one exists, record start/stop is the one irreducible human beat.
Surface-specific detail
Read the one that matches your stage; skip the rest.
references/devtools-on-camera.md— driving Chrome DevTools as a recorded pane: picking the right frontend, the theme flip on CDP attach, calibrating a docked pane,:visibletabs, selecting the POST by its Payload tab, and what is safe to show on screen.references/no-cdp-apps.md— Tauri/webview/native apps with no locators: screenshot-as-eyes, window-relative offsets, macOS Spaces and raise verbs, frontmost verification, filtered search over row maps, mutate-out-of-band.references/inbound-injection.md— when the environment cannot receive real inbound traffic: signing a request at the platform's own ingress, and proving which environment actually handles a tenant's messages before you script a phone beat.references/cdp-runtime.md— connecting over CDP (Node not Bun, connect-hang triage, IPv6localhost), polling for a completed response, phone input injection, and extracting auth from a logged-in browser without printing tokens.
The agent as conductor
The strongest configuration: the agent runs the whole show from its own shell and the human never touches the keyboard mid-take.
- The agent arms the take (setup via invisible shell, take command typed into the visible terminal), verifies the stage with screenshots, and push-notifies the human when it's time to hit record.
- Every "armed" / "stage verified" message to the human names the evidence you
just gathered — the screenshot you read, the
frontmostcheck, the beat that printed green from your own shell. The human hits record on that claim; if something is not yet verified, say so instead of reporting it green. - Cue-gating moves from stdin-Enter to the chat channel: the human says "recording" / "texted it" in chat, and the agent advances the script (send Enter into the terminal, or run beats directly). The parked cue waits indefinitely, so a take can even be handed between agent sessions.
- Chat-gating steals focus — plan for it. The human types "recording" into
a terminal or chat window, which makes that app frontmost. So the go-signal
itself breaks the stage the agent just set. The working order is: agent
raises the target app and verifies the frame → human hits record on an
already-correct stage → human says "recording" (focus drifts, harmless) →
agent re-raises the target, re-verifies
frontmost, and sends Enter as one atomic step, aborting if the check fails rather than firing clicks into the wrong window. Raise the Space before the human records, so the switch animation stays off camera; once the Space is forward the later raise is instant. - This works because a multiplexer's socket API writes to the pty without
focus (herdr
pane send-keys/pane run, tmuxsend-keys) — the agent can resume a parked script while a different app stays frontmost. A tool that types viacliclickinstead would defeat the whole arrangement. - Take stock of which beats actually need the human before promising them a
job: grep every
cue(in the rig. If the only cues are "hit record", the human's entire role is the record button — inbound traffic that looks like it needs a thumb is usually injected (seereferences/inbound-injection.md), and the phone is a display surface raised withopen -a, not an input surface. - A clip must prepare its own starting state, off camera and idempotently. Never depend on the operator (or the agent) remembering to run a reset: seed the realistic fixtures, delete anything the clip is about to "add", and assert it is gone — otherwise a leftover from the previous take turns the hero beat into a no-op re-add that looks fine to the script and wrong on camera.
- Reload the SPA on driver connect and after any out-of-band prep. API resets and seed writes bypass the app's query cache, so the screen shows the previous take's data — and forms seed ghost values that get faithfully re-saved — while every API assert passes.
- Close overlays before acting, not only after. If a side panel is left open
by the previous beat, the next click lands on the panel instead of the target —
and nothing errors. Dismiss defensively at the start of the action, and use the
panel's real close control:
Escfrequently does nothing. - Check a human beat is physically possible before scripting it. A phone cue is worthless when inbound from the handset routes to a different environment — the device can receive but never reply into the target env. Trace the direction of every path a human beat depends on; a shared outbound endpoint does not imply a shared inbound one.
- Don't run checks that cannot catch the failure. Dry runs that only prove the file parses add ceremony and false confidence when every real failure is focus, click landing, selection, or expiry — and the live run fails on a syntax error just as fast. Spend the time on the hands-off rehearsal instead.
- Screenshots are invisible to screen recorders — the agent can verify
mid-take without photobombing. Mouse moves are not: between beats, the
agent's hands stay off
cliclickentirely. - Window-focus choreography replaces screen real estate: on an overlapped
stage the agent raises the relevant window per beat (CDP
bringToFrontfor browser windows,open -afor apps — but verify the app's window is on the recorded display first; activation raises windows where they already live, it does not move them). Scene cuts read better than six tiny windows. - Staging a demo across Spaces (one desktop per clip) works well and makes the Space switch a natural scene cut, but every clip must be armed while its own desktop is active — calibration probes and frame reads only work on the active Space. Assign the transcript terminal to All Desktops so it appears in both, and remember an app raised from another Space comes forward where it lives: an app parked on the wrong display stays off-camera no matter how often you activate it. Re-probe every window a clip touches after any re-layout; windows move between takes more than you expect.
- Observability close-ups: the agent can click open a log row (locator eyes on the observability page + real-mouse hands) for a beat's detail shot — but expanded log panels show raw attributes, including PII the summary line hides (msisdn/phone numbers). Decide blur-vs-skip before the take.
Driving the on-camera terminal
- Prefer a multiplexer you're inside (herdr:
pane list→pane run) — but probe first; a terminal window that isn't in the pane list gets the fallback: focus-click it, thencliclick t:the command (whole string as one argv token; per-char with-w 60when the typing itself is on camera) andkp:return. - Arm takes so the camera never sees setup: run prefill/reset via an invisible shell, type only the take command into the visible terminal, and let the script idle at its record cue — the banner waits indefinitely, so there is zero time pressure between "hit record" and Enter. Push-notify the human when the take is armed.
- A terminal needed on every Space: Dock icon → Options → Assign To → All Desktops (per-app, applies to all its windows).
Composition wisdom
- Pair panes so causality is visible in one frame: UI click → DevTools network row → server log line → device bubble. One action, four surfaces, no narration needed.
- Pick the surface by what is observable there, not by what looks nicest.
A well-built UI pre-validates: it normalizes input, blocks reserved words,
pre-checks conflicts against data it already holds, caps list lengths, and
disables Save while any rejection stands. Every one of those means no request
is sent, so a browser + DevTools framing shows an empty Network panel for
exactly the error cases a contract demo exists to prove. Read the client's
validation and the Save button's
disabledexpression before promising a surface. The honest split: the API client demos the contract (the status ladder), the product UI demos the UX (inline rejections, disabled Save) — and "our UI never lets a user hit a 409" is a good line to narrate, not a gap. - DevTools Network on camera: filtered rows only — opening a request shows the Authorization header in the recording. If a beat must click into a row, land on Payload/Response, never Headers, and pre-filter the panel to the relevant path so unrelated traffic stays out of frame.
- Scrub PII from any visible log pane (msisdn/phone numbers) before recording, not in post.
- Build the log pane on the live stream, not indexed search. Indexed search can lag 30–60s after an invocation — which reads as a broken pipeline and tempts you into filing a bug — while the vendor's live-tail view streams from a separate hot path (measured on Datadog Live Tail: 10.3s inject→routing line, same logs).
- Verify where logs actually land before building a log pane on them: fire one real event, then check each candidate sink. (Lambda CloudWatch groups can be fossils from an earlier deploy era while logs ship via a Datadog extension; a tail on a fossil group fails silently — looks like no traffic.)
- An unbroken take is its own timestamp proof — no clock needed, no cuts.
- Demo data in the product's own voice beats lorem: on-brand flow names, keywords, and copy make the take read as product, not test rig.
