Skip to content
DocsDemo specs and CI
Docs / Agents / Demo specs and CI

Demo specs and CI

Save a take as demotake.json, commit it next to your code, and run it as an end-to-end test on every pull request or re-record only the scenes that changed on every release.

DemoTake AgentCLI and MCPGitHub Actions

A demo that lives only in a video goes stale the day your UI changes. A demotake.json spec is the demo as code: the scenes, the steps that perform them and the assertions that prove they worked. The same file runs as a test (no video) or records a fresh take, and on a release it can re-record only the scenes whose screens changed.

  1. Record a take
  2. take_to_specdemotake.json
  3. Commit
  4. demotake testevery PR
  5. demotake run --only-changedevery release
Record once, save it as code, then let CI keep it honest.

What a spec looks like

This is a trimmed version of the example that ships with DemoTake. The full example records TextEdit in three scenes; two are shown here, each ending with an assertion that proves it worked.

demotake.json
{
  "version": 1,
  "project": "TextEdit Demos",
  "target": { "bundle_id": "com.apple.TextEdit", "app_name": "TextEdit" },
  "outputs": ["16:9", "9:16", "guide"],
  "defaults": { "timeout": 10, "attempts": 2, "rehearse": false, "step_delay": 0.4 },
  "edit": { "target_duration": 30, "compress_waits": { "threshold": 1, "to": 0.4 } },
  "takes": [
    {
      "name": "Write and save a note",
      "goal": "Create a document in TextEdit, write a note and save it.",
      "setup": [{ "action": "key", "keys": "escape", "optional": true, "label": "Dismiss the open panel" }],
      "scenes": [
        {
          "title": "New document",
          "checkpoint": "An empty Untitled document is open",
          "steps": [
            { "action": "key", "keys": "cmd+n", "label": "Create a new document" },
            { "action": "wait", "element": { "role": "textarea" }, "timeout": 5 },
            { "action": "assert_state", "title": "Blank document open", "window_title": "Untitled" }
          ]
        },
        {
          "title": "Save",
          "checkpoint": "The document is saved",
          "steps": [
            { "action": "key", "keys": "cmd+s", "label": "Save" },
            { "action": "wait", "element": { "role": "button", "title": "Save" }, "timeout": 5 },
            { "action": "key", "keys": "cmd+a", "label": "Select the file name" },
            { "action": "type", "text": "DemoTake note" },
            { "action": "click", "element": { "role": "button", "title": "Save" }, "label": "Click Save" },
            { "action": "wait", "gone": { "role": "button", "title": "Save" }, "timeout": 5 },
            { "action": "assert_state", "title": "Document saved", "window_title": "DemoTake note" }
          ]
        }
      ]
    }
  ]
}

The parts you'll use most:

Field What it does
version, takes Required. version is 1; takes has at least one take.
target The app: a bundle id, an app name, or {bundle_id, app_name, window_title, url}.
outputs What a recorded run renders, for example 16:9, 9:16, gif, guide.
defaults timeout per step, attempts per scene, rehearse before recording, step_delay between steps.
style, edit Pointer, camera and caption style; edit presets such as target_duration, compress_waits, no_zoom, mask.
take setup Off-camera steps that reach the starting state, before rehearsal and before recording.
scene title Identifies the scene across runs, so diffs and only-changed runs can match it.
scene checkpoint The stable state the scene ends on.
scene setup Off-camera steps run before every attempt, so a retry starts clean.

Steps and element queries

Each step has an action: click, double_click, right_click, type, key, scroll, wait, assert_text, assert_element, assert_state, hold or note. Any step can also take a label (shown in the timeline and guides), a timeout and optional: true (a failing optional step is reported but doesn't fail the scene).

Steps find UI by element query, not by pixel position, so they survive layout changes. A plain string matches visible text. An object can combine role (button, textfield, checkbox, menuitem, row…), title, text, identifier (an exact accessibility identifier), selector and index. Queries search the target window. Items in the app's menu bar are found only when the query asks for one, with role: "menuitem" or a selector that starts with AXApplication/AXMenuBar, so looking for some text never presses a menu item with the same name. wait takes text, element, gone (wait for an element to disappear) or idle (seconds without UI changes).

Assertions

Assertions are what make a demo a test. assert_text checks that text is visible, assert_element that an element exists, and assert_state checks the observed UI: window_title, contains, not_contains or an element. Give each a title saying what it proves (assert_state requires one). Results appear in the take's proof manifest.

Secrets

Specs never contain secrets. A type step takes exactly one of text (never a secret), secret_env or keychain:

JSON
{ "action": "type", "secret_env": "DEMO_PASSWORD", "element": { "role": "securetextfield" } }

secret_env names an environment variable. The demotake CLI reads it and forwards the value to the app for that run only: it's never logged, stored in the take or shown in videos. {"keychain": {"service": "com.example.demo", "account": "ci"}} reads a generic password from the runner's login Keychain instead.

From a take to a spec

You don't have to write specs by hand. Record a take (or let your agent record it), then save it as code:

  • over MCP, take_to_spec returns the spec and can write it to a path;
  • in a terminal, demotake to-spec <take_id> --out demotake.json.

Steps come from what happened in the take, with element queries for the controls that were used and assert_* steps for its assertions. Secrets typed during the take become secret_env references. Check the file with spec_validate or demotake validate demotake.json, which needs no app at all.

Run it

Goal CLI MCP
Check the file demotake validate demotake.json spec_validate
Record a fresh take demotake run demotake.json spec_run
Test without video (DemoTest) demotake test demotake.json --junit out.xml spec_run with record: false
Re-record changed scenes only demotake run demotake.json --only-changed spec_run or take_replay with only_changed: true
Re-run a recorded take demotake replay <take_id> take_replay
See what changed demotake diff <take_id> take_diff

A run works scene by scene: optional rehearsal, then the take, a checkpoint at the end of each scene, and a retry of only the scene that failed, up to its attempts. demotake test reuses one "(DemoTest)" take per spec take, so pull-request runs don't flood your library. When a step fails, the evidence (a blurred snapshot and a UI excerpt) goes into the JUnit report.

spec_run and take_replay operate the app, so they're available in DemoTake Agent only.

Only the scenes that changed

After a release, most of your demo is still correct. --only-changed (or only_changed: true) compares each scene's checkpoint with the last recording of the same spec take: the accessibility state plus a perceptual diff of the checkpoint snapshot, ignoring redacted regions. Changed scenes are re-recorded; unchanged ones keep their footage and their edits. If nothing changed, no render runs and your existing exports stay current.

Terminal
$ demotake run demotake.json --only-changed --outputs 16:9,9:16,guide
Checkout Take: PASS · 8 scene(s) · 3 of 8 scene(s) changed · 3 regenerated, 5 kept
The run summary for an eight-scene take after a release.
Checkout Take8 scenes · 3 changed
  1. 1Sign inKept
  2. 2Open cartKept
  3. 3Apply couponchanged · re-recorded
  4. 4Shipping addressKept
  5. 5Payment methodchanged · re-recorded
  6. 6Review orderchanged · re-recorded
  7. 7Place orderKept
  8. 8ConfirmationKept
Kept scenes reuse their footage and edits. Only the three changed scenes were recorded again.

Run it in CI

demotake is a thin client for the DemoTake app on the same Mac. Your runner needs:

  • a self-hosted macOS runner with a logged-in GUI session, since DemoTake operates real apps;
  • DemoTake Agent installed and running (DemoTake Studio can't operate other apps, so it can't run specs);
  • Screen Recording and Accessibility granted to DemoTake once, interactively. demotake status shows what's missing.

Put the CLI on the runner's PATH once:

Terminal
mkdir -p ~/.local/bin
ln -sf /Applications/DemoTake.app/Contents/MacOS/demotake-cli ~/.local/bin/demotake
demotake status

This adds a link in your home folder only; no administrator password needed. If demotake isn't found afterwards, add export PATH="$HOME/.local/bin:$PATH" to ~/.zshrc and open a new terminal window. You can also run the CLI by its full path, /Applications/DemoTake.app/Contents/MacOS/demotake-cli.

The CLI finds DemoTake's endpoint and token by itself. Keep the runner's display awake and unlocked; minimized windows pause capture.

A shortened GitHub Actions workflow: DemoTest on pull requests, demo regeneration on release tags.

.github/workflows/demotake.yml
name: DemoTake
on:
  pull_request:
  push:
    tags: ["v*"]

jobs:
  demotest:
    if: github.event_name == 'pull_request'
    runs-on: [self-hosted, macOS, demotake]
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/build.sh && open -g build/MyApp.app   # your app
      - run: demotake validate demotake.json
      - name: DemoTest (no video)
        env:
          DEMO_PASSWORD: ${{ secrets.DEMO_PASSWORD }}
        run: demotake test demotake.json --junit reports/demotake.xml --commit "$GITHUB_SHA"

  regenerate-demos:
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: [self-hosted, macOS, demotake]
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/build.sh --release && open -g build/MyApp.app
      - name: Re-record changed scenes
        env:
          DEMO_PASSWORD: ${{ secrets.DEMO_PASSWORD }}
        run: |
          demotake run demotake.json --only-changed \
            --outputs 16:9,9:16,guide --commit "$GITHUB_SHA" \
            --json > reports/demotake-run.json

The --json result lists every export's output path, so a later step can collect the files and upload them as build artifacts.

Exit codes

Code Meaning
0 Everything passed
1 A scene or assertion failed, or the spec is invalid
2 Usage error (unknown command or option)
3 DemoTake isn't reachable
4 DemoTake reported an error
Tip:

Give scenes that may be retried a setup that brings the app back to the scene's starting state. Retries and rehearsals then start clean instead of failing on leftovers from the last attempt.

Where to go next

Was this page helpful?