Recipes / Event-driven

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)
Use case
AI-driven walkthrough video of a deployed site, with a per-story summary
FlareDispatch shape
1 typed run + dispatch
Plain GHA shape
1 workflow · 1 job · Playwright + Anthropic SDK + per-context .webms

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.yml points product-demo at 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 representative product-demo execution.

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 at runs/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 on pull_request against apps/** and on manual workflow_dispatch with an optional inline script override.
  • demo-stories.md — the worked story script ci.yml reads by default (each ## heading = one chapter). Edit it like docs; see § “Authoring stories”.
  • Dockerfile.example — drop-in layers that add the demo-agent binary to your own Dockerfile.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 story name, everything down to the next ## is the prose. Content before the first heading (a # Title, a preamble) is ignored. Lets you keep the demo script as a readable .md and edit it like documentation instead of hand-maintaining JSON. This is what ci.yml ships by default — it reads the tracked demo-stories.md into the dispatch input.
  • stories — the structured array, [{ "name": "...", "prose": "..." }]. Drop in for a programmatically-generated script, or what a schedules[].inputs block 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.yml POSTs to the Dispatcher when a PR opens/updates against apps/**, 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.ts carries a schedules: [{ cron: "0 14 * * *", inputs: () => ({ … }) }] block. The Dispatcher’s scheduled() handler fires the same run once a day against a pre-baked deployed URL + story list (no PR trigger required). Edit the inputs placeholders (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 — only assertion (the agent played the journey and the app misbehaved) becomes a signal. infra/timeout/unparseable are 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 narrative is an LLM summary of what it saw on-page — a carrier, not a sanitizer. It rides signals/v1’s already-fenced detail field; 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 derived signals/v1 array, returned on the success path.
  • artifacts/<execId>/signals.json and artifacts/<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 recordVideo on 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-story chapterStartMs / chapterEndMs offsets 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 of checkout + setup-node + pnpm install + ~60–90 s of playwright install --with-deps chromium before 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-agent lives 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-agnostic LanguageModel. 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.upload returns a 30-day signed R2 URL to the rrweb event JSON, and the run returns a replayUri that opens the rrweb player on the docs site so reviewers can scrub the recording.
  • Live model routing via config. The summary model resolves through config.get("product-demo.model.summary") (03-dsl § config) — flip to a fallback provider in seconds, no redeploy. Mirrors the pr-review pattern 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 as pr-review’s re-review continuity, durable instead of leaning on actions/cache.

Install

  1. Deploy FlareDispatch and install the GitHub App — see specs/05-byoc.md.

  2. Add product-demo.run.ts to your repo’s runs/ directory.

  3. Copy ci.yml into .github/workflows/ and demo-stories.md alongside your repo (the workflow reads recipes/product-demo/demo-stories.md — adjust the path to wherever you keep it). Edit the story script to match your app’s journey. Set vars.PREVIEW_URL (or adjust the inline URL convention) so the pull_request trigger knows where to drive the demo. Keep the pr: ${{ 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.

  4. Bake the demo-agent binary into your sandbox image. Open Dockerfile.example — it’s a two-stage layer pair (build demo-agent from a pinned flare-dispatch ref, copy the bundle into a stock cloudflare/sandbox runtime). Paste the two stages into your own Dockerfile.sandbox (the one referenced by wrangler.jsonc containers[].image); wrangler deploy does the rest. No registry credentials, no flare-dispatch-demo pull — your sandbox image IS the integration. Pin FD_REF to a tag for reproducible builds.

  5. Point at a Cloudflare AI Gateway. The agent speaks the OpenAI wire protocol via @effect/ai’s provider-agnostic LanguageModel and always routes through a Cloudflare AI Gateway. You don’t paste a full URL — the agent derives the /v1/<account>/<gateway>/compat endpoint from CLOUDFLARE_ACCOUNT_ID + the gateway slug CF_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 to MODEL_API_KEY — it gates access to the gateway, not to the upstream.
  6. Configure the run’s runtime credentials. Two Worker Secrets + a small set of CONFIG_KV entries (the agent reads zero ambient env vars; every credential flows in through loadSecrets):

    # 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 /logs viewer), the agent’s headless browser is 302’d to the SSO login unless it carries an Access identity. demo-agent authenticates with an Access service token: given CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET it exchanges them for a host-scoped CF_Authorization cookie (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 with scripts/setup-viewer-demo.sh — it writes staging/CF_ACCESS_CLIENT_ID / staging/CF_ACCESS_CLIENT_SECRET to CONFIG_KV (the staging/ prefix runs/product-demo.ts loads from) and sets LOG_VIEWER_DEMO_URL.

  7. (Optional) Wire Bedrock through the same gateway. AWS Bedrock isn’t reachable on the gateway’s /compat endpoint (CF docs — Bedrock is provider endpoint only), so a bedrock/<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 that pr-review’s bedrock backend uses; share the role and widen its trust policy sub to also accept product-demo:*. To enable:

    • In ci.yml (or your workflow_dispatch payload), pass bedrockRoleArn and optionally bedrockRegion (defaults to us-east-1) on the dispatch input. The run will mint short-lived STS creds via awsAssumeRole and thread them into the agent through agentEnv.
    • Set product-demo.model.play to a bedrock/<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_TOKEN still applies if the gateway has Authenticated Gateway turned on.
  8. Require the flare-dispatch/product-demo check-run in branch protection (NOT the GHA job — the GHA step succeeds at dispatch time; the actual demo result lives on the check-run).

  9. 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 / chapterEndMs on 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.

Action mode — triggered by a GitHub Actions workflow that calls the shipped run.
recipes/product-demo/ci.yml
# Recipe: AI-driven product demo on Cloudflare
#
# Use case: produce a walkthrough video + per-story narrative of a deployed
# site (preview, staging, or prod) so a reviewer can SEE the change work
# without checking anything out locally. Offload to the `product-demo` run,
# which attaches Browser Rendering over CDP, records ONE master video while
# driving the site through each story, and writes a holistic summary.
#
# Mode: Action mode, fire-and-forget. The run posts the result through the
#       `flare-dispatch/product-demo` check-run — the video link + the
#       summary land in the check-run summary, which reviewers paste into
#       the PR. See specs/04-gha-integration.md.
# Run:  product-demo — see ./product-demo.run.ts.

name: product-demo
on:
  # On every PR touching the app, drive a fixed default story set against
  # the PR's preview deployment URL (a `vars.PREVIEW_URL_TEMPLATE` or a
  # deploy-env URL — configure per repo).
  pull_request:
    paths: ["apps/**"]
  # On-demand against any URL (preview, staging, prod). Optionally override the
  # tracked markdown script inline. Useful for QA, release sign-off, marketing.
  workflow_dispatch:
    inputs:
      deployed_url:
        description: "URL to drive (preview / staging / prod)"
        required: true
      stories_markdown:
        description: "Markdown story script (each `## ` heading = one chapter). Blank ⇒ the tracked demo-stories.md."
        required: false

jobs:
  product-demo:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # The story script lives as readable markdown (each `## ` heading = one
      # chapter; see the README § "Authoring stories"), not inline JSON. Read
      # the tracked demo-stories.md into a step output — unless the dispatch
      # passed an inline override. The run parses `storiesMarkdown` into the
      # same {name, prose} list the structured `stories` array produces.
      - name: Load demo stories
        id: stories
        run: |
          { echo 'md<<DEMO_STORIES_EOF'
            if [ -n "${OVERRIDE}" ]; then printf '%s\n' "${OVERRIDE}"; else cat recipes/product-demo/demo-stories.md; fi
            echo DEMO_STORIES_EOF
          } >> "$GITHUB_OUTPUT"
        env:
          OVERRIDE: ${{ github.event.inputs.stories_markdown }}

      - uses: openhackersclub/flare-dispatch-action@v1
        with:
          run: product-demo
          endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
          hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
          inputs: |
            {
              "repo": "${{ github.repository }}",
              "sha": "${{ github.event.pull_request.head.sha || github.sha }}",
              "deployedUrl": ${{ toJSON(github.event.inputs.deployed_url || vars.PREVIEW_URL) }},
              "storiesMarkdown": ${{ toJSON(steps.stories.outputs.md) }},
              "viewportPreset": "desktop"${{ github.event.pull_request.number && format(', "pr": {0}', github.event.pull_request.number) || '' }}
            }
          # `pr` is what enables the GIF-on-completion comment: on a `pull_request`
          # the run posts the holistic summary + an inline walkthrough GIF to the
          # PR. On `workflow_dispatch` there is no PR, so it is omitted and the
          # run reports via the `flare-dispatch/product-demo` check-run only. For
          # the GIF to render inline, the deploy's `/v1/artifacts/...` route must
          # be public (GitHub's image proxy fetches it server-side).
          #
          # Prefer the structured array? Swap the `"storiesMarkdown"` line for
          # `"stories": [{ "name": "...", "prose": "..." }]` and drop the "Load
          # demo stories" step — `stories` wins if both are supplied.

# Fire-and-forget: this GHA step succeeds as soon as the dispatch is
# accepted; the actual demo runs on Cloudflare and reports back through the
# `flare-dispatch/product-demo` check-run on the PR head SHA. Require that
# check (not this GHA job) in branch protection. The check-run summary
# embeds the signed video URL — reviewers drop it into the PR description.