> ## Documentation Index
> Fetch the complete documentation index at: https://docs.outerlayer.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Record a demo

> Record your running app with a Playwright script, and attach a narrated demo with one chapter for each acceptance criterion.

A demo is one narrated video of your app. It has one chapter for each [acceptance criterion](https://docs.outerlayer.ai/concepts.md#how-the-work-is-judged). A spoken line describes each step while it happens on screen. The item's **Criteria** tab plays the demo, and each chapter is the video proof of its criterion.

You record a demo in three steps:

1. Write a demo script: a Playwright test that imports `test` and `expect` from `@outerlayer/playwright`.
2. Run `outerlayer demo record` on the script. It writes a video and a manifest.
3. Run `outerlayer emit demo` on the manifest. It builds the narrated video and stores it on the item.

In a runner build, the agent does these steps. The starter [evidence skill](https://docs.outerlayer.ai/starter-skills.md) tells it to record a demo for the criteria whose behavior shows on screen. Use this page to write or change a demo script, or to record a demo on your own machine.

## Before you start

Demos cover browser apps. The recorder and its Playwright release ship inside the `outerlayer` CLI, so your repository installs nothing.

A runner build image holds all that the commands need: the browser, `ffmpeg`, `espeak-ng` and the voice pack. A build records a demo with no network. A repository with its own devcontainer file adds nothing to its image.

On your own machine, do these steps first:

1. Install the CLI globally with `npm install --global @outerlayer/cli`. `outerlayer demo record` does not run through `npx`.
2. Install `ffmpeg` and `espeak-ng`. See [Install the programs](#install-the-programs).
3. Install the browser with `npx playwright@1.64.1 install chromium`. That version is the Playwright release the CLI runs.
4. Allow for a one-time download of about 400 MB. See [Download the voice pack](#download-the-voice-pack).

To record with no speech, see [Skip the narration](#skip-the-narration). You then need only `ffmpeg`, and no voice pack.

### Install the programs

The commands run `ffmpeg` and `espeak-ng` as separate programs. `ffprobe` comes with `ffmpeg`.

> [tab] Linux
>
> Use your package manager.
>
> ```bash theme={"system"}
> # Debian and Ubuntu
> sudo apt-get install ffmpeg espeak-ng
>
> # Fedora
> sudo dnf install ffmpeg espeak-ng
>
> # Alpine
> sudo apk add ffmpeg espeak-ng
> ```
>
> On Fedora, `ffmpeg` comes from the RPM Fusion repository. See the Fedora documentation to enable it.

> [tab] macOS
>
> Use Homebrew.
>
> ```bash theme={"system"}
> brew install ffmpeg espeak-ng
> ```

> [tab] Windows
>
> Install `ffmpeg` with `winget`.
>
> ```powershell theme={"system"}
> winget install Gyan.FFmpeg
> ```
>
> Then install eSpeak NG with the installer from the [eSpeak NG releases page](https://github.com/espeak-ng/espeak-ng/releases). Open a new terminal after both installs, and check that each program is on `PATH`.
>
> ```powershell theme={"system"}
> ffmpeg -version
> espeak-ng --version
> ```
>
> If a command is not found, add its folder to `PATH`. The `ffmpeg` folder is the `bin` folder of the winget package. The eSpeak NG folder is `C:\Program Files\eSpeak NG`.

### Download the voice pack

The first narrated run downloads the voice pack to `~/.outerlayer/voice/`. The pack is about 400 MB. The command shows progress, and it resumes a broken download. After that run, narration needs no network, no account and no key.

The command checks every file of the pack on each narrated run. If a file is damaged, the command names it. Delete that file, then run the command again.

To download from a server of your own, set `OUTERLAYER_VOICE_MIRROR` to the address of a server that holds the pack files. The command checks every file the mirror serves in the same way.

## Write the demo script

A demo script is a Playwright test that uses the `demo` fixture. Name the file `*.demo.ts`. Playwright's default test pattern leaves that name out, so your own test runs do not pick it up. Commit the script in your pull request, so a later session can [record the demo again](#record-the-demo-again).

> [!WARNING]
>
> OuterLayer does not remove secrets from a demo. The video, the screenshots and the narration lines upload as you recorded them. Anything the script types or the app shows is in the video, such as a password, a token or a customer email. Sign in with a test account and seed fake data. See [What leaves your machine](https://docs.outerlayer.ai/what-leaves-your-machine.md#what-is-scrubbed-before-upload).

Import `test` and `expect` from `@outerlayer/playwright`, not from `@playwright/test`. Your repository does not install that package: `outerlayer demo record` supplies it. A TypeScript check of the script cannot resolve the import, so leave `**/*.demo.ts` out of that check.

Add one `demo.chapter` for each criterion, and tell it in beats with `demo.say`.

```ts theme={"system"}
import { expect, test } from "@outerlayer/playwright";

test("a member signs in", async ({ page, demo }) => {
  await page.goto("/login");

  await demo.chapter("LOGIN-01", "Sign in", async () => {
    await demo.say("Here's the sign-in page. You start with your email.", async () => {
      await page.getByLabel("Email").fill("member@example.com");
    });
    await demo.say("Sign in, and you're on the dashboard.", async () => {
      await page.getByRole("button", { name: "Sign in" }).click();
      await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
    });
  });
});
```

`demo.chapter(criterionId, title, body)` takes these arguments:

| Argument | Meaning |
| - | - |
| `criterionId` | The id of a [recorded criterion](https://docs.outerlayer.ai/reference/cli-emit.md#outerlayer-emit-criteria). Use `null` for an opening scene. |
| `title` | A short name for the chapter. |
| `body` | The steps of the chapter. |

Each `expect` that runs inside `body` is a check of the chapter. A failed `expect` fails the test, as it does in any Playwright test. A chapter proves its criterion only when it ran at least one check and every check passed.

`demo.chapter(criterionId, title, narration, body)` also works. It speaks one `narration` line over the whole chapter, and the demo shows the whole chapter.

Follow these rules for chapters:

* Give each criterion one chapter.
* Put a chapter with no criterion first. Only the first chapter may have none.
* Use the smallest viewport that shows the result. The stored demo is one video of at most 8 MiB. See [What the command stores](#what-the-command-stores).

## Tell a chapter in beats

`demo.say(line, steps)` is one beat: a spoken line, and the steps on screen that it describes. The steps are ordinary Playwright calls. A beat lasts as long as its line or its steps, whichever is longer.

The demo shows only beats. A step outside a beat is setup, and the demo leaves it out. Use setup to sign in, seed data, wait for a cache or reload a list.

Inside a beat, the video shows a pointer that moves to each element a step clicks or hovers. A `fill` types its text a key at a time, and the page scrolls smoothly to an element out of view.

Follow these rules for beats:

* Sign in and seed data once, before the first chapter. Do not sign out and in between chapters.
* Write each line about what the viewer sees in the steps of its beat. See [Write the lines](#write-the-lines).
* Go from page to page inside a beat, with the app's own links. A reload or a `page.goto` outside a beat stays out of the demo.

## Write the lines

The viewer hears each line. Write it as you say it to a teammate at your screen.

* Say what the viewer sees change, and why it matters to them. The viewer sees a grey number, not "the secondary text color".
* Name each thing by its label on the screen.
* Talk to the viewer. Use "you", "we", "here" and contractions such as "it's".
* Say each step once, in one or two short sentences.
* Read each line aloud before you commit it. Write it again when it sounds like a test assertion or an advert.

| A line read off a spec | A line a person says |
| - | - |
| Eight stats sit in one bordered strip with sentence-case labels. | All eight numbers now sit together in one strip. |
| The visitor selects the Back to top link. | Click Back to top, and you're at the start again. |

`outerlayer emit demo` refuses a line that holds one of these:

* A sales word or a stock phrase, such as `seamless`, `powerful`, `effortless`, `dive in` or `not just … but`.
* A dash or an exclamation mark.
* A criterion id, such as `AC-7-02`.
* More than 30 words, for the line of a beat.

The refusal names the chapter, the beat, the line and the reason. The check catches words, not tone, so a stiff line in plain words passes. Listen to the rendered demo to find those.

## Show a step outside the browser

`demo.terminal(command, run)` shows a step that runs outside the browser, such as a CLI command. It draws a terminal panel over the page, types `command`, runs `run`, and prints what `run` returns. It returns that output, so the test can check it. Call it inside a beat. The panel closes when the next beat starts.

```ts theme={"system"}
import { execFileSync } from "node:child_process";

await demo.say("The member asks for a build of issue 41.", async () => {
  const printed = await demo.terminal("outerlayer work build --issue 41", () =>
    execFileSync("outerlayer", ["work", "build", "--issue", "41"], { encoding: "utf8" }),
  );
  expect(printed).toContain("build requested");
});
```

The panel shows only what `run` returns, so return the command's real output.

`demo.http(request, run)` shows a call to an API in the same kind of panel. It types the request's method and URL, and prints its `body` when it has one. Then it runs `run`, and prints the status and body that `run` returns. It returns that response. The panel prints at most 14 lines of the body, and says how many lines it left out.

Use `demo.http` when a criterion's proof is an API response, or when no page shows the step. In this example, `request` is Playwright's own fixture. Add it to the test's arguments, as in `async ({ page, demo, request }) => { … }`.

```ts theme={"system"}
await demo.say("The API lists the item with its new state.", async () => {
  const response = await demo.http({ method: "GET", url: "/v1/work-items/41" }, async () => {
    const answer = await request.get("/v1/work-items/41");
    return { status: answer.status(), body: await answer.json() };
  });
  expect(response.status).toBe(200);
});
```

## Point to an element

Inside a beat, the first passed `expect` on a locator dims the page around the element it checks and draws a ring around it. The viewer then sees what the line talks about. So check first the one element that the line names, such as a status in a list:

```ts theme={"system"}
await demo.say("The item now waits for a host.", async () => {
  await expect(page.getByText("Waiting for a host")).toBeVisible();
});
```

A beat has one ring, and later checks in the beat draw no other. The ring clears when the pointer moves to another element or the beat ends. These checks draw no ring, and leave the ring to the next check:

* a check with `not`, such as `expect(locator).not.toBeVisible()`
* a check on a locator that matches several elements
* a check on a large element, such as a whole card or section
* a check of a computed value, such as a style the test reads with `evaluate`

`demo.highlight(locator)` rings an element that no check looks at. After it, checks in that beat draw no ring.

## Mark a screenshot

A criterion that declares the proof `screenshot` is a chapter of the demo too. Call `demo.screenshot()` inside the chapter, after the `expect` that shows the result.

```ts theme={"system"}
await demo.chapter("LOGIN-02", "Dashboard", "The dashboard lists the member's projects.", async () => {
  await expect(page.getByRole("heading", { name: "Projects" })).toBeVisible();
  await demo.screenshot();
});
```

The call takes one frame of the page, with no ring on it. `outerlayer emit demo` stores the frame as the criterion's screenshot.

`demo.screenshot(options)` takes these options:

| Option | Default | Meaning |
| - | - | - |
| `fullPage` | `false` | Take the whole page, not only the viewport. Use it when the result is below the visible area. |
| `masks` | `false` | Cover the chapter's [`demo.mask`](#hide-a-region-that-varies) regions in grey. By default the frame shows them. |

Follow these rules:

* Call `demo.screenshot()` inside a chapter, after at least one check. The caption of the stored frame quotes the check before the call.
* Put the call where the result shows. The command never picks a frame for you.
* Call it once in a chapter. A second call replaces the first frame.

The command stores the frame only when the criterion declares `screenshot` and every check of the chapter passed. Otherwise it stores no frame and says why.

## Record the demo

Make sure the app runs, or that your Playwright config starts it. Then run the script with `outerlayer demo record`.

```bash theme={"system"}
# A repository with no Playwright config
outerlayer demo record e2e/save-variable.demo.ts --base-url http://localhost:3000

# A repository with its own Playwright config
outerlayer demo record e2e/save-variable.demo.ts --config e2e/playwright.config.ts --project chromium
```

* Without `--config`, the script runs in one Chromium project. `--base-url` sets where relative `page.goto()` paths start.
* With `--config`, the demo uses that config's setup, base URL and projects. Pass `--project` when the config has more than one project. A project that the named project depends on, such as one that signs in, runs as usual.

The command speaks each line with the CLI's voice while it records, so each beat lasts as long as its spoken line.

Each run empties its output folder first. The folder is `test-results/outerlayer-demo/` unless `--output-dir` names another. To keep an earlier recording, give each run its own `--output-dir`.

When the script ends, the command prints the path of the manifest:

```text theme={"system"}
✓ recorded test-results/outerlayer-demo/<test folder>/demo/manifest.json
```

Use that path in the next steps. The recording, `video.webm`, is in the same folder. If the test fails, the command still writes the manifest, and it exits 1. All the flags are in [`outerlayer demo record`](https://docs.outerlayer.ai/reference/cli-emit#outerlayer-demo-record).

## Render the demo on your machine

Watch the demo before you store it. Pass `--output` to render the demo to a file and stop. The command needs no item and uploads nothing.

```bash theme={"system"}
outerlayer emit demo <manifest path> --output demo.webm
```

`demo.webm` is the demo as a reviewer sees it. Next to it, `demo.webm.json` lists each chapter's time range and the time the command left out. The command exits 1 when a check failed, or when the demo holds a frozen, silent stretch over 2 seconds.

## Emit the demo

Run `outerlayer emit demo` on the manifest, with the item's number.

```bash theme={"system"}
outerlayer emit demo test-results/outerlayer-demo/<test folder>/demo/manifest.json --item 412
```

The command checks the manifest, the video and the programs it needs before it uploads anything. When it refuses, it stores nothing. See [Fix a refused demo](#fix-a-refused-demo).

The stored demo shows each chapter's beats, or the whole chapter when it has one line. It leaves out the setup between beats. It also leaves out each stretch of 1.6 seconds or more with no motion and no speech.

When a chapter has a failed check, the command stores the demo, names the chapter and the check, and exits 1. That chapter proves no criterion. Fix the app, record again and run the command again.

If an upload fails, run the same command again with the same manifest, video, item and pull request. It stores only what is missing.

Inside a recorded session, the demo uploads on the next `outerlayer sync`. Outside a session it uploads at once. All the flags and refusals are in [`outerlayer emit demo`](https://docs.outerlayer.ai/reference/cli-emit#outerlayer-emit-demo).

### What the command stores

The command stores the demo as one video of at most 8 MiB, with one time range for each chapter. A chapter that ran checks, and passed each one, is the proof of its criterion. The opening chapter, and a chapter with a failed check, prove no criterion.

If the video is over 8 MiB, the command encodes it again at a lower quality, up to two times. If it is still too large, the command stores one clip for each chapter and no whole video. The command output says so. The **Criteria** tab still plays the clips as one video. To keep one video, record a smaller viewport or fewer chapters.

## Skip the narration

To record and store a demo with no speech, and download nothing, pass a flag to each command:

```bash theme={"system"}
outerlayer demo record e2e/save-variable.demo.ts --base-url http://localhost:3000 --no-voice
outerlayer emit demo <manifest path> --item 412 --no-narration
```

`--no-voice` times each beat by an estimate of its line. `--no-narration` stores the demo with no audio. With both flags you need only `ffmpeg`.

With `--no-voice` alone, `outerlayer emit demo` speaks each line later. See [Fix a refused demo](#fix-a-refused-demo) for a line that then runs too long.

## Record the demo again

A push moves the head of the pull request. A demo recorded before the push is then out of date, and its player shows the *Out of date* tag.

`outerlayer work proofs --record` records the demo again at the new head. It finds the script by the criterion ids in its `demo.chapter` calls, such as `demo.chapter("AC-12", ...)`. It runs the script and replaces the item's earlier demo. For this, the repository's [proofs file](https://docs.outerlayer.ai/reference/cli-work.md#the-proofs-file) needs a runner whose glob matches the script:

```yaml theme={"system"}
runners:
  - files: "e2e/**/*.demo.ts"
    command: outerlayer demo record {files} --base-url http://localhost:3000
```

Who runs the command depends on who pushed:

* **After a build or an amend**, the runner runs it at the final commit.
* **After any other push to an item that a runner built**, the built-in automation **Record proofs again** starts an amend that runs it. See [When the proofs are recorded again](https://docs.outerlayer.ai/reference/build-reference.md#when-the-proofs-are-recorded-again).
* **In a session of your own**, run it yourself after each push.

An amend that cannot record the demo again posts one comment on the item's general thread. The comment gives the cause:

* **No test names the criterion.** No script at HEAD holds the criterion's id.
* **A chapter fails its checks.** The new chapter is stored, but it proves no criterion. The comment names the criterion and the failed check.
* **The script cannot run.** The comment says why.

To replace a demo by hand, record the script again. Then emit it with the id of the item's newest demo record, which `outerlayer work demo` prints:

```bash theme={"system"}
outerlayer work demo --item 412 --json
outerlayer emit demo <manifest path> --item 412 --pr 77 --replaces <record id>
```

The new demo retires the old video, its chapters and its screenshots. A screenshot that you emitted with `outerlayer emit artifact` is not part of the demo, so recording the demo again does not replace it.

## Fixed settings

Two recordings of an unchanged page can still differ. The clock moves, the timezone changes a date format, and an animation is caught at another point. The `demo` fixture fixes each setting that the browser controls:

| Varies between runs | The fixture sets |
| - | - |
| The clock | Each test starts at 2026-01-15 10:00:00 UTC. The clock then runs normally. |
| Timezone | `UTC` |
| Language and formats | `en-US` |
| Window size | A 1280 × 800 viewport and a device pixel ratio of 1. |
| Animations | A reduced-motion preference, and animation and transition times of 0. |
| Fonts | Each still waits for web fonts to load. |
| The text cursor | The cursor is transparent. |
| Random values | `Math.random` returns the same sequence on every page load. |

The fixture cannot fix what your app's server controls, such as its clock, its random ids and its data. [Compare two runs](#compare-two-runs) finds that variation.

### Turn a setting off

Every fixed setting is on by default. Turn one off with the `demoOptions` option. Each key is `true` unless you set it to `false`.

```ts theme={"system"}
test.use({ demoOptions: { clock: false } });
```

| Key | Setting it controls |
| - | - |
| `clock` | The start time of the page clock. |
| `random` | The fixed `Math.random` sequence. |
| `animations` | Reduced motion and zero animation times in the video. Turn it off to keep the app's own transitions in the video. Each still and screenshot stops motion either way. |
| `caret` | The transparent text cursor. |
| `fonts` | The wait for web fonts before each still. |

To change the viewport, locale, timezone or pixel ratio, set that Playwright option yourself. Your value replaces the fixed one.

The video records at 1280 × 800. When the page is smaller than the video, the recording fills the rest of each frame in grey. So when you set a viewport, set the video to the same size at the top of the script:

```ts theme={"system"}
test.use({
  viewport: { width: 640, height: 360 },
  video: { mode: "on", size: { width: 640, height: 360 } },
});
```

Playwright sets the video size once for each worker. A viewport that one test sets cannot change it.

### Hide a region that varies

Use `demo.mask` to hide an element that varies for reasons of its own, such as a timestamp from your server.

```ts theme={"system"}
await demo.chapter("ACTIVITY-01", "Activity feed", "The feed lists the latest event.", async () => {
  await demo.mask(page.getByTestId("last-updated"));
  await expect(page.getByRole("list")).toBeVisible();
});
```

The mask covers the element with a solid grey box until the chapter ends. The box shows in the video and in every still, and it follows the element when the element moves. It is the same in every run, so it never causes a difference.

## Compare two runs

To find what varies in your app between two recordings, record the script twice and compare the runs. Give each run its own output folder, because a run empties its folder first.

```bash theme={"system"}
outerlayer demo record e2e/save-variable.demo.ts --base-url http://localhost:3000 --output-dir run-1
outerlayer demo record e2e/save-variable.demo.ts --base-url http://localhost:3000 --output-dir run-2
outerlayer emit demo run-1/<test folder>/demo/manifest.json --compare run-2/<test folder>/demo/manifest.json --item 412
```

The recorder takes a still of the page after each passed check. The command compares each still of the first run with the still at the same step of the second run. It stores the first run's video, and it does not read the second run's video.

The command output and the stored demo record give a result for each chapter. The item page does not show it.

| `comparable` | Meaning |
| - | - |
| `yes` | Every still of the chapter matches the second run. |
| `no` | A still differs. The output names the chapter, the check and the smallest rectangle that holds every differing pixel. |
| `not checked` | You gave one manifest, the chapter has no stills, or a passed check has no still. |

For a `no` chapter, fix what varies in your app, or hide it with [`demo.mask`](#hide-a-region-that-varies).

* The command refuses when the runs differ in chapters, titles or checks. It names the first difference.
* A still follows an `await expect(...)` check at once. A synchronous matcher, such as `expect(1).toBe(1)`, does not wait, so its still can show a later page. Use an async matcher, such as `await expect(locator).toBeVisible()`, for a check whose still must match.
* Comparing never changes what a chapter proves. A chapter is the proof of its criterion when its checks pass, whatever the result here.

## Fix a refused demo

`outerlayer emit demo` refuses before it uploads anything, and the message names the chapter. These refusals need a change to the script:

| The command says | What to do |
| - | - |
| A line `cannot be spoken` | Rewrite the line. See [Write the lines](#write-the-lines). |
| A line `runs … too long` for its beat or chapter | Shorten the line, or make the beat or the chapter longer. The command never stretches the video. |
| `the demo holds dead air` | The demo holds a frozen, silent stretch over 2 seconds. Tell each step as a beat with `demo.say()`. |
| A chapter names a criterion that `is not on the item's recorded criteria` | Fix the id, or record the criterion with [`outerlayer emit criteria`](https://docs.outerlayer.ai/reference/cli-emit#outerlayer-emit-criteria). |
| A clip is `over the 8 MiB limit` | Shorten the chapter, or record a smaller viewport. |

A line runs too long only when the command speaks it after the recording. That happens for a one-line chapter, and for a beat recorded with `--no-voice`. The command first speeds the speech up, to at most 1.3 times. It refuses when the line is still too long.

## Manifest format

The recorder writes `manifest.json` for you. You need this section only when you write a recorder of your own. A recorder that writes the same format gets narration and comparison from `outerlayer emit demo`.

The recorder writes version 5 when a chapter has beats, and version 4 otherwise.

| Field | Meaning |
| - | - |
| `schemaVersion` | `4` or `5`. |
| `test` | The test file that recorded the demo. A path relative to the repository's root, with forward slashes. It never starts with `/` and has no `..`. |
| `video` | The video file, relative to the manifest's folder. |
| `chapters` | 1 to 200 chapters, in order. |
| `chapters[].criterionId` | The id of a recorded criterion, or `null` for an opening scene. |
| `chapters[].start`, `chapters[].end` | Seconds from the start of the video. |
| `chapters[].title` | A name for the chapter. |
| `chapters[].narration` | The line spoken over the chapter. Empty for no speech. |
| `chapters[].checks` | The checks of the chapter, each with `text` and `passed`. |
| `chapters[].stills` | The stills of the chapter. |
| `chapters[].screenshot` | Optional. The frame the test marked with `demo.screenshot()`. |
| `chapters[].beats` | Version 5, optional. The chapter's beats, in order. |
| `viewport` | Version 5, optional. The page's `width` and `height` in pixels, when the page is smaller than the video. |

A beat has these fields:

| Field | Meaning |
| - | - |
| `start`, `end` | Seconds from the start of the video. A beat lies inside its chapter. |
| `narration` | The line spoken from the beat's start. |
| `voice` | Optional. The speech the recorder made for the line: its WAV `file`, relative to the manifest's folder, and its length in `seconds`. |

A still and a screenshot frame have these fields:

| Field | Meaning |
| - | - |
| `check` | The index, starting at 0, of the check in `checks` that the still or frame follows. |
| `file` | The PNG file, relative to the manifest's folder. |
| `sha256` | The SHA-256 of the PNG file's bytes, in lower-case hex. |
| `fullPage` | Screenshot frame only. `true` when the frame is the whole page. |
| `masked` | Screenshot frame only. `true` when the frame covers the chapter's masks. |

A still follows a check that passed. The `check` values rise from one still to the next. Do not change a still file after the run: `outerlayer emit demo --compare` checks each file against its `sha256`.

See also: [Prove the work](https://docs.outerlayer.ai/attach-evidence.md), [`outerlayer emit demo`](https://docs.outerlayer.ai/reference/cli-emit#outerlayer-emit-demo).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.