Product demo
Hand a deployed URL and a list of user stories in prose; the `product-demo` run attaches Browser Run over CDP with native rrweb session recording enabled, drives the site through each story sequentially over ONE recorded session, captures key screenshots, and writes a holistic markdown summary a reviewer pastes into the PR. The returned `replayUri` opens the rrweb player so reviewers can scrub the walkthrough inline. Fires per-PR via `ci.yml` (Action mode), and ALSO carries an optional `schedules: [{ cron, inputs }]` block for a daily stakeholder-facing run against staging — same run code, both trigger paths.
Fires on Per PR · daily — wire it any of these ways:
- Action recommended A ci.yml step dispatches the run — when it must interleave with other CI jobs or gate the PR.
- Schedule A Cloudflare Cron Trigger fires it on a wall-clock cadence — no GitHub event, no workflow file. (daily stakeholder run against staging)
- Webhook pattern The GitHub App webhook fires the run directly — no .github/workflows file, no GHA minutes. (needs a deployed URL from config)
Recipe: AI-driven product demo
Hand a list of user stories in prose and a deployed URL; get back one continuous rrweb-based replay with chapter markers per story, a key screenshot per story, and a holistic markdown summary. When the dispatch carries the PR number (the Action-mode default), every completed demo also posts a PR comment with the summary and an animated GIF of the walkthrough embedded inline — the reviewer sees the demo in the review thread without leaving GitHub.
▶ Watch the log-viewer walkthrough GIF —
.github/workflows/product-demo-logviewer.ymlpointsproduct-demoat FlareDispatch’s per-execution log viewer and walks it through five stories: the D1 step flame chart, the run-summary verdict, and the full untruncated R2 logs with line filter + stderr-only. Captured by driving the shipped viewer (apps/dispatcher/src/routes/logs.ts) against a representativeproduct-demoexecution.
Files
product-demo.run.ts— the typed Run: attach CDP (with?recording=true) → record start (set viewport, capture sessionId) → for each story drive the agent → record stop (close session, pull rrweb events from Browser Run’s REST API, upload JSON to R2) → write the holistic summary. The canonical, registered copy lives atruns/product-demo.ts(this file is the teaching illustration the doc site renders).ci.yml— the GitHub Actions workflow that dispatches the run (Action mode, fire-and-forget). Triggers onpull_requestagainstapps/**and on manualworkflow_dispatchwith an optional inline script override.demo-stories.md— the worked story scriptci.ymlreads by default (each##heading = one chapter). Edit it like docs; see § “Authoring stories”.Dockerfile.example— drop-in layers that add thedemo-agentbinary to your ownDockerfile.sandbox. FlareDispatch ships no hosted image for the agent; the operator’s sandbox image IS the integration point.
Authoring stories
The run accepts the story script in either of two shapes — pass exactly one (stories wins if both are present):
storiesMarkdown— a markdown document where each level-2 heading (##) is one story: the heading text is the storyname, everything down to the next##is theprose. Content before the first heading (a# Title, a preamble) is ignored. Lets you keep the demo script as a readable.mdand edit it like documentation instead of hand-maintaining JSON. This is whatci.ymlships by default — it reads the trackeddemo-stories.mdinto the dispatch input.stories— the structured array,[{ "name": "...", "prose": "..." }]. Drop in for a programmatically-generated script, or what aschedules[].inputsblock returns.
# Checkout demo
## sign-in
Open the site, click Sign in, log in with the demo account, and land on the dashboard.
## create-project
From the dashboard, create a new project called Demo and confirm the empty-state CTA appears.
## invite-member
Open the new project, invite a teammate by email, and confirm the pending-invite chip shows.
Both formats resolve to the same { name, prose } list before the agent runs, so nothing downstream is markdown-aware. Story names must be unique — they become chapter markers on the rrweb replay timeline; the run dies loudly on duplicates or an empty list.
Modes
The recipe runs in either of two trigger modes — pick the one that fits your use case, or wire up both:
- Action mode (per-PR).
ci.ymlPOSTs to the Dispatcher when a PR opens/updates againstapps/**, handing it the deployed preview URL + story list + the PR number (pr: ${{ github.event.pull_request.number }}). On completion the reviewer gets a PR comment with the GIF + summary, plus the replay link on the check-run. This is the default the recipe is tuned for. - Schedule mode (daily stakeholder demo).
runs/product-demo.tscarries aschedules: [{ cron: "0 14 * * *", inputs: () => ({ … }) }]block. The Dispatcher’sscheduled()handler fires the same run once a day against a pre-baked deployed URL + story list (no PR trigger required). Edit theinputsplaceholders (OWNER/REPO,https://staging.example.com, the default story array) to point at your staging tier.
Use both at once if you want per-PR demos AND a daily stakeholder-facing run against staging — they share the same run code and Browser Rendering pool. The Schedule-mode firing is independent of ci.yml; the Action-mode dispatches inherit their inputs from the workflow_dispatch payload and ignore the schedules[].inputs defaults.
How it works
The agent is a demo-agent CLI baked into the operator’s own sandbox image (paste the Dockerfile.example layers into your Dockerfile.sandbox). No registry pull, no separate FlareDispatch-hosted image — FlareDispatch ships exactly one container per Worker and demo-agent is just another binary on PATH inside it. The run owns orchestration (CDP attach, sequencing, uploads, summary stitching); the agent owns the model loop and applying CDP commands from prose. Recording itself is a platform capability — Browser Run captures rrweb DOM events when the CDP connect URL carries ?recording=true, and the dispatcher’s newCDPSession primitive does that automatically for runs with requiresBrowser: true.
flowchart LR
PR[PR push / dispatch] --> A[ci.yml]
A -->|HMAC POST| DSP[Dispatcher]
DSP --> CDP["attach CDP<br/>(?recording=true)"]
CDP --> REC["record start<br/>(viewport + sessionId)"]
REC --> S1[demo-agent play<br/>story 1]
S1 --> S2[demo-agent play<br/>story 2]
S2 --> SN[demo-agent play<br/>story N]
SN --> STOP["record stop<br/>(close, pull rrweb)"]
STOP --> GIF["render gif<br/>(captured frames, ≤10 MB)"]
GIF --> R2[upload replay.json<br/>+ demo.gif + screenshots → R2]
R2 --> SUM[demo-agent summarize<br/>holistic markdown]
SUM --> CHK[check-run<br/>replay link + summary]
SUM -->|pr number present| CMT["PR comment<br/>summary + inline GIF"]Each story’s play captures a pixel frame after every action into a shared frames dir — the GIF’s source, since the rrweb stream is DOM events, not pixels. The render gif step is the bundled demo-agent gif subcommand (pure-JS pngjs + gifenc, no ffmpeg in the image); it downscales to ≤ 800 px and drops frames evenly to stay under GitHub’s ~10 MB image-proxy limit. The comment is best-effort: a GIF or comment failure logs but never fails the run, and a firing with no PR (Schedule mode, or a workflow_dispatch with no PR) skips it entirely. See specs/02-runs.md § PR comment on completion for the full contract, and .github/workflows/product-demo-logviewer.yml for the worked dogfood that demos this repo’s own log viewer.
Failure signals (self-heal input)
A product-demo failure is a richer diagnostic than an OTel alert — it carries the journey that broke, the replay, and the screenshot. The run turns the assertion-failed chapters into signals/v1 (the same vendor-blind contract a Datadog/SigNoz collector prints, packages/core/src/signals.ts), so they can be folded into ci-triage-pr today and a self-heal later — the same path an e2e or OTel finding takes.
Two rules make the output heal-worthy by construction (enforced by the pure, unit-tested storyResultsToSignals):
- Only assertion failures emit a signal. Each chapter result now carries a
failureKind(assertion|timeout|infra|unparseable). A demo verdict is an LLM-driven, non-deterministic browser loop, so a single red chapter is not ground truth — onlyassertion(the agent played the journey and the app misbehaved) becomes a signal.infra/timeout/unparseableare flake/environment and are dropped before emission, so the triage PR never drowns in flake. - The narrative is untrusted. The demo drives a deployed app that may render attacker-influenced content, and the chapter
narrativeis an LLM summary of what it saw on-page — a carrier, not a sanitizer. It ridessignals/v1’s already-fenceddetailfield; the signal’s fingerprint (source + title) keys off the operator-authored chapter name, never the narrative, so a reworded flake can’t mint a fresh incident identity and defeat downstream dedup.
The run exposes these two ways, so both the green and the red path keep them:
Output.signals— the derivedsignals/v1array, returned on the success path.artifacts/<execId>/signals.jsonandartifacts/<execId>/stories.json— persisted to R2 on both paths. The dispatcher discards the Output (summary_json) on a failed Exit — exactly the run that most warrants triage — so a consumer reads the structured per-chapter results (status,failureKind, replay URIs) and the signals from R2 even on a fully-red demo.
Auto-dispatch self-heal (opt-in, OFF by default)
When enabled, the run escalates confirmed assertion failures to self-heal-pr as a demo-class incident. Because a demo verdict is LLM-driven, a single red chapter isn’t ground truth: the run re-plays each assertion failure k-of-n times and escalates only those that fail deterministically. The agent then fixes the bug and verifies by writing a regression test (run as test-command) — never by re-running the browser demo (the agent sandbox has no Browser Run; an LLM re-judging an LLM is circular).
Cost is bounded by design: OFF unless enabled=true; each confirm re-play is a full browser+model loop, so confirm-runs is clamped (≤5) and only up to max-chapters (≤10) failures are confirmed per run; per-heal model spend is capped by the AgentBudget DO; the child self-heal-pr dedups on a deterministic instance id (incident + head SHA).
wrangler kv key put --binding=CONFIG_KV self-heal.demo.enabled true
wrangler kv key put --binding=CONFIG_KV self-heal.demo.test-command "pnpm test" # REQUIRED — the deterministic verify oracle; without it the run won't dispatch
wrangler kv key put --binding=CONFIG_KV self-heal.demo.confirm-runs 3 # default 3, clamp 1..5 — total plays per failed chapter
wrangler kv key put --binding=CONFIG_KV self-heal.demo.confirm-threshold 2 # default 2, ≤ confirm-runs — failures needed to escalate
wrangler kv key put --binding=CONFIG_KV self-heal.demo.max-chapters 3 # default 3, clamp 1..10 — chapters confirmed per run
Self-heal itself (the agent tier, model proxy, AgentBudget, the self-heal-pr run + writeback) must already be set up per specs/08-self-healing.md § 11. This switch only wires product-demo failures into it.
Why this lives on FlareDispatch
The structural advantages over the plain-GHA baseline (baseline.yml):
- One continuous replay across all stories. Playwright’s
recordVideoon GHA is per-BrowserContext— one story = one .webm, with no clean way to stitch them. FlareDispatch shares ONE Browser Run session across every story, and Browser Run’s native recording emits ONE rrweb event stream that covers the whole walkthrough; the run records per-storychapterStartMs/chapterEndMsoffsets so a reviewer can scrub straight to a chapter. - rrweb DOM fidelity, not pixels. Browser Run records DOM mutations + events via rrweb, not a video file. The replay is searchable, copy-pasteable, and orders of magnitude smaller than a webm — and the same recording supports console-error inspection, network-call observation, and per-element timing on the replay page. GHA + Playwright can only produce a flat video.
- Warm browser pool — no
playwright install. A cold GHA runner pays ~30–45 s ofcheckout+setup-node+pnpm install+ ~60–90 s ofplaywright install --with-deps chromiumbefore the first frame. FlareDispatch attaches to Browser Run over CDP — there’s no chromium install on the path at all. - Agent CLI inside the image, model provider chosen at deploy — zero credentials on the runner.
demo-agentlives in the operator’s sandbox image; it talks to any OpenAI-compatible endpoint (Cloudflare AI Gateway with BYOK, OpenAI direct, Workers AI, Bedrock-via-compat, Ollama, …) via@effect/ai’s provider-agnosticLanguageModel. The container only sees a gateway URL; the upstream API key lives in the gateway. On the GHA baseline, model API keys are exposed to every step in the job, every postinstall script, and every third-party action you use. - Scale-to-zero between deploys. Stories run sequentially because the browser is shared; the model round-trip per story is a wall-clock wait. On GHA you pay runner minutes while the model thinks. FlareDispatch only bills CPU actually used.
- Signed R2 URLs for PR embedding — and a GIF where video can’t go. GitHub PR comments cannot embed video — the GHA artifact UI hands you a download link that requires unzip-and-watch-locally. But comments do render animated GIFs: the run encodes one from the frames it captured during the session and posts it inline on the PR (via the stable artifact URL, so it keeps rendering after the R2 presign rotates). The full-fidelity path stays too:
artifact.uploadreturns a 30-day signed R2 URL to the rrweb event JSON, and the run returns areplayUrithat opens the rrweb player on the docs site so reviewers can scrub the recording. - Live model routing via
config. The summary model resolves throughconfig.get("product-demo.model.summary")(03-dsl § config) — flip to a fallback provider in seconds, no redeploy. Mirrors thepr-reviewpattern in recipes/ai-code-review. - Incremental on re-runs via
io.priorExecution. Keyed on(repo, deployedUrl), so a re-demo after a fix can call out what’s new / regressed since the previous replay — same pattern aspr-review’s re-review continuity, durable instead of leaning onactions/cache.
Install
-
Deploy FlareDispatch and install the GitHub App — see specs/05-byoc.md.
-
Add
product-demo.run.tsto your repo’sruns/directory. -
Copy
ci.ymlinto.github/workflows/anddemo-stories.mdalongside your repo (the workflow readsrecipes/product-demo/demo-stories.md— adjust the path to wherever you keep it). Edit the story script to match your app’s journey. Setvars.PREVIEW_URL(or adjust the inline URL convention) so thepull_requesttrigger knows where to drive the demo. Keep thepr: ${{ github.event.pull_request.number }}input on the dispatch — that’s what enables the completion comment; drop it and the run silently reverts to check-run-only reporting. For the GIF to render inline, the deploy’s artifact route (/v1/artifacts/...) must be set public — GitHub’s image proxy fetches it server-side; on a private deploy the comment carries a plain link instead. -
Bake the
demo-agentbinary into your sandbox image. OpenDockerfile.example— it’s a two-stage layer pair (builddemo-agentfrom a pinnedflare-dispatchref, copy the bundle into a stockcloudflare/sandboxruntime). Paste the two stages into your ownDockerfile.sandbox(the one referenced bywrangler.jsonccontainers[].image);wrangler deploydoes the rest. No registry credentials, noflare-dispatch-demopull — your sandbox image IS the integration. PinFD_REFto a tag for reproducible builds. -
Point at a Cloudflare AI Gateway. The agent speaks the OpenAI wire protocol via
@effect/ai’s provider-agnosticLanguageModeland always routes through a Cloudflare AI Gateway. You don’t paste a full URL — the agent derives the/v1/<account>/<gateway>/compatendpoint fromCLOUDFLARE_ACCOUNT_ID+ the gateway slugCF_AI_GATEWAY_ID. The gateway fans out to the upstream provider (OpenAI, Workers AI, Anthropic-via-compat, Bedrock, …). Run the gateway in BYOK mode so the container never holds an upstream key — the gateway holds it. Two optional auth knobs layer on top, each on its own axis:MODEL_API_KEY— the upstream provider key (Authorization: Bearer). Leave unset under BYOK; set it only to bypass BYOK with a direct provider credential.CF_AI_GATEWAY_TOKEN— the gateway’s own auth token (cf-aig-authorization: Bearer), for a gateway with Authenticated Gateway turned on. Orthogonal toMODEL_API_KEY— it gates access to the gateway, not to the upstream.
-
Configure the run’s runtime credentials. Two Worker Secrets + a small set of
CONFIG_KVentries (the agent reads zero ambient env vars; every credential flows in throughloadSecrets):# Worker Secrets — read by the live `browser` Layer for the CDP attach. # CONNECT_URL is the dispatcher's OWN binding-mediated CDP proxy # (`GET /v1/browser/cdp`), NOT an external Cloudflare connect URL: CF Browser # Rendering only exposes CDP to a Worker holding the `BROWSER` binding, so the # container dials the dispatcher, which re-dials Browser Rendering over the # binding (the binding IS the auth). The old external `…/connect` URL hung. wrangler secret put BROWSER_CDP_CONNECT_URL # wss://<your-dispatcher-host>/v1/browser/cdp # API_TOKEN is a shared bearer secret the container presents to that proxy # route (constant-time compared Worker-side) — any high-entropy string; it is # NOT the Cloudflare API token (the `BROWSER` binding authenticates CF-side). wrangler secret put BROWSER_CDP_API_TOKEN # any high-entropy shared secret # CONFIG_KV transport secrets — read by `loadSecrets`, passed as env to every demo-agent exec. # The model endpoint is DERIVED from CLOUDFLARE_ACCOUNT_ID + CF_AI_GATEWAY_ID # (no MODEL_BASE_URL). The two model auth knobs are optional and independent: # MODEL_API_KEY = upstream provider key (Authorization), unset under BYOK; # CF_AI_GATEWAY_TOKEN = the gateway's own auth (cf-aig-authorization), set # only for an Authenticated Gateway. wrangler kv key put --binding=CONFIG_KV product-demo.secret/CF_AI_GATEWAY_ID <ai-gateway-slug> wrangler kv key put --binding=CONFIG_KV product-demo.secret/MODEL_API_KEY <optional: upstream provider key; unset under gateway BYOK> wrangler kv key put --binding=CONFIG_KV product-demo.secret/CF_AI_GATEWAY_TOKEN <optional: gateway auth token; set only for an Authenticated Gateway> wrangler kv key put --binding=CONFIG_KV product-demo.secret/CLOUDFLARE_ACCOUNT_ID <account-id> wrangler kv key put --binding=CONFIG_KV product-demo.secret/CLOUDFLARE_API_TOKEN <token-with-browser-rendering-read> # CONFIG_KV model ids — resolved per-execution by the run via `config.get`, # so you can repoint models in seconds (no redeploy). REQUIRED — there is no # provider-neutral default. The id flows VERBATIM as the OpenAI `model` field # to the gateway's `/compat` endpoint, which requires the `{provider}/{model}` # form: `openai/gpt-4o-mini`, `anthropic/claude-haiku-4-5-20251001`, # `workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast`. A bare `@cf/…` (no # `workers-ai/` prefix) is rejected with "Invalid provider". The `play` model # MUST support forced tool-calling (`tool_choice:required`) — not all do. # For Workers AI via /compat, set MODEL_API_KEY to a CF token with Workers AI # Run (it is the `Authorization: Bearer`); under provider BYOK leave it unset. wrangler kv key put --binding=CONFIG_KV product-demo.model.play workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast # or openai/gpt-4o-mini / anthropic/claude-haiku-4-5-20251001 / ... wrangler kv key put --binding=CONFIG_KV product-demo.model.summary workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast # or openai/gpt-4o / anthropic/claude-opus-4-7 / ...Verify with
scripts/check-product-demo-secrets.sh.When the demo TARGET is behind Cloudflare Access (e.g. FlareDispatch demoing its own gated
/logsviewer), the agent’s headless browser is 302’d to the SSO login unless it carries an Access identity.demo-agentauthenticates with an Access service token: givenCF_ACCESS_CLIENT_ID/CF_ACCESS_CLIENT_SECRETit exchanges them for a host-scopedCF_Authorizationcookie (access-scope.ts), keeping the wall closed to humans. One-time: create a service token in Zero Trust and attach a Service Auth policy to the target’s Access app (a plain Allow policy won’t accept a service token). Then wire the creds + a tokened demo URL withscripts/setup-viewer-demo.sh— it writesstaging/CF_ACCESS_CLIENT_ID/staging/CF_ACCESS_CLIENT_SECRETto CONFIG_KV (thestaging/prefixruns/product-demo.tsloads from) and setsLOG_VIEWER_DEMO_URL. -
(Optional) Wire Bedrock through the same gateway. AWS Bedrock isn’t reachable on the gateway’s
/compatendpoint (CF docs — Bedrock isprovider endpoint only), so abedrock/<modelId>model id takes a separate path: SigV4-signed POST to/v1/<acct>/<gw>/aws-bedrock/.... The trust path is the same OIDC-federated AssumeRole thatpr-review’sbedrockbackend uses; share the role and widen its trust policysubto also acceptproduct-demo:*. To enable:- In
ci.yml(or yourworkflow_dispatchpayload), passbedrockRoleArnand optionallybedrockRegion(defaults tous-east-1) on the dispatch input. The run will mint short-lived STS creds viaawsAssumeRoleand thread them into the agent throughagentEnv. - Set
product-demo.model.playto abedrock/<modelId>id — e.g.bedrock/us.anthropic.claude-opus-4-7-v1. The agent’s model client routes that prefix through the Bedrock forwarder; everything without the prefix keeps using the OpenAI-compat path. - The
MODEL_API_KEY/ gateway BYOK setup above is unused on the Bedrock path (auth IS the SigV4 signature).CF_AI_GATEWAY_TOKENstill applies if the gateway has Authenticated Gateway turned on.
- In
-
Require the
flare-dispatch/product-democheck-run in branch protection (NOT the GHA job — the GHA step succeeds at dispatch time; the actual demo result lives on the check-run). -
Open a PR; when the demo completes, a PR comment lands with the walkthrough GIF + holistic summary, and the check-run summary carries the rrweb replay URL (
replayUri) and the per-story chapter markers (chapterStartMs/chapterEndMson the rrweb timeline).
Source
The recommended FlareDispatch shape is shown first. Toggle to Without FlareDispatch to see the full GitHub Actions workflow a team would maintain to do the same job without it.