Skip to main content

Spoiler

Spoiler reads session recordings and reports what users tried, how it ended, and what blocked them.

Code measures what happened. A model explains it. Code checks the explanation.

  • In: any rrweb recording, from a file or fetched from PostHog. Web, native iOS and Android.
  • Out: versioned JSON: a deterministic trace, and an analysis that cites it.
  • Model: any OpenRouter model, one call per visit. Compiling needs no model and no network.

Example

An admin invites a teammate, but every seat is taken. From the 22-second recording, Spoiler reports:

Task: Invite priya@example.com to the workspace. Outcome: workaround.

Send invite was refused three times because all 5 seats were in use. Deactivating Ben Ortiz did not free his seat. Removing him did.

Hypothesis: the message doesn't say that deactivated members still hold seats.

The why comes from the vocabulary term seat, drafted from members.server.ts:4. It reads: "Every member holds a seat until removed, deactivated members included."

Each claim cites refs into the trace, which code compiles from the recording with no model:

ref t_s target effect flags
e2 1.0 input[invite-email] "name@company.com" typed "priya@example.com"
e3 3.0 button[send-invite] "Send invite" net 402 /api/invites 170ms; +alert "Couldn't send invite: all 5 seats are i…" error_after,error_shown
e5 6.5 button[send-invite] "Send invite" net 402 /api/invites 170ms; +alert "Couldn't send invite: all 5 seats are i…" error_after,error_shown
e7 10.6 button[deactivate-member] "Deactivate" in cell[Actions] "Ben Ortiz" req /api/members/m-ben/deactivate 200 150ms; cell Status "Ben Ortiz": "Active" → "Deactivated"; cell Actions "Ben Ortiz": "Deactivate Remove" → "Remove"
e8 13.1 button[send-invite] "Send invite" net 402 /api/invites 170ms; +alert "Couldn't send invite: all 5 seats are i…" error_after,error_shown
e11 19.6 button[confirm-remove] "Remove" -dialog "Remove Ben Ortiz?"; req /api/members/m-ben 200 160ms; -row "Ben Ortiz"
e12 22.1 button[send-invite] "Send invite" req /api/invites 201 180ms; +status "Invitation sent to priya@example.com"

The answer also claimed "Rage-clicked Send invite after the first refusal", citing e3 and e5. Code dropped it: the clicks were 3.5 s apart, and neither carries a rage flag.

The session is synthetic and this narration is hand-written. With --model, a model writes it.

How it works

source files ─▶ vocab build ─▶ vocabulary ─┐
                (model, per release)       ├─▶ compile ─▶ trace ─▶ analyze ─▶ analysis
recording ─────────────────────────────────┘   (code only)         (model, then code)
  1. Vocabulary. vocab build has a model read source files you name. It drafts surfaces, controls, domain terms and rules, each citing its source line. What the sources don't cover is listed under gaps, not guessed. The snapshot pins every source by SHA-256 and is never regenerated implicitly. --candidate packages a vocabulary you wrote instead, with no model call.
  2. Compile. compile replays the recording's DOM log, one mirror per tab. Each click resolves to an element, a vocabulary feature, what changed, and how fast. Rules raise flags: dead, unresponsive, rage, slow, error_after, error_shown, thrash. The same recording, vocabulary and compiler version always give the same trace.
  3. Narrate. analyze sends the trace as TSV, plus the vocabulary it touched. The recording itself is never sent. run makes one call per visit. The model returns tasks with a goal, outcome, obstacle and friction, all citing refs. Outcomes are done, workaround, gave_up or unclear.
  4. Gate. Code checks every answer before accepting it:
    • Off-schema, or over 15% of cited refs missing: rejected. A live call gets one corrected retry.
    • dead_click, rage_click, error and slow friction is dropped without a matching flag.
    • Timestamps, durations, paths and data changes are written from the trace, never by the model.
    • check lists everything dropped, unexplained, or uncited.

Install

curl -fsSL https://spoiler.sh/install | sh
  • Installs the binary for your OS and CPU to ~/.local/bin, after checking its sha256.
  • Linux gets the static musl build, which runs on any distribution.
  • … | SPOILER_VERSION=v0.1.0 sh pins a release. SPOILER_INSTALL_DIR picks the directory.

From PyPI. The wheel only puts the spoiler binary on PATH; there is no Python API.

pip install spoiler

uv tool install spoiler works the same way. With Rust 1.88+, from crates.io:

cargo install spoiler --locked

Releases has an archive per target, each with a .sha256:

  • aarch64-apple-darwin, x86_64-apple-darwin
  • x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu (glibc 2.28+)
  • x86_64-unknown-linux-musl, aarch64-unknown-linux-musl (static; any Linux)

From a checkout: cargo install --path crates/cli --locked.

Quick start

Offline, from a checkout, using committed fixtures. No account or API key.

Build, and put the binary on PATH:

cargo build --release --locked
export PATH="$PWD/target/release:$PATH"
mkdir -p artifacts

Compile a recording into a trace. No model, no network:

spoiler compile \
  --recording corpus/click_changes_text.json \
  --vocab corpus/vocabulary.yaml \
  --app demo \
  --out artifacts/trace.json

Read the trace the way a model does:

jq -r .tsv artifacts/trace.json

Validate a stored answer against the trace:

spoiler analyze \
  --trace artifacts/trace.json \
  --vocab corpus/vocabulary.yaml \
  --response examples/click_changes_text.response.json \
  --out artifacts/analysis.json

Swap --response FILE for --prepare-only to write the exact model request instead. Neither sends anything.

On your product

Set credentials, which Spoiler reads only from the environment, and two variables used below:

export POSTHOG_API_KEY=phx_…
export OPENROUTER_API_KEY=sk-or-…
MODEL=…       # any OpenRouter model id
SESSION_ID=…  # a PostHog recording id

Describe each app in product.json. project is its PostHog project id:

{
  "apps": {
    "web": { "project": 123, "host": "app.example.com", "audience": "workspace admins" }
  }
}

Build a vocabulary from the source files that name your routes and controls:

spoiler vocab build \
  --config product.json \
  --source app/routes.ts \
  --source app/routes/members.tsx \
  --source app/members.server.ts \
  --source-revision "$(git rev-parse HEAD)" \
  --model "$MODEL" \
  --out vocab.json

Check it, and review the drafted matchers before relying on them:

spoiler vocab check --vocab vocab.json

Fetch, compile and narrate one PostHog session:

spoiler run \
  --project 123 \
  --session "$SESSION_ID" \
  --vocab vocab.json \
  --app web \
  --model "$MODEL" \
  --out session.json
  • run narrates every visit with user gestures. --visit N picks one.
  • Rebuild the vocabulary when you ship. Each trace records the digest it was compiled against.

Commands

Command Reads → writes Network
run recording or PostHog session → session (trace + per-visit analyses) PostHog with --session, OpenRouter with --model
compile recording + vocabulary → trace none
analyze trace → analysis_request or analysis OpenRouter with --model
vocab build product config + sources → vocabulary_snapshot OpenRouter, unless --candidate
vocab check vocabulary → vocabulary_check none
decode recording file → normalized recording none
recordings list, fetch PostHog project → recording_page, recording PostHog
  • One JSON artifact per command, to stdout or --out (written atomically).
  • Any input path accepts - for stdin, so commands pipe.
  • Configuration is flags only. Credentials come only from the environment.
  • No model is ever chosen for you.
  • spoiler <command> --help lists every flag.

Each stage also runs alone, and they pipe:

spoiler recordings fetch --project 123 --session "$SESSION_ID" \
  | spoiler compile --recording - --vocab vocab.json --app web \
  | spoiler analyze --trace - --vocab vocab.json --prepare-only

Failures are JSON on stderr: {"error": "…", "retryable": false}.

Exit Meaning
0 Success
1 Invalid input or configuration, or a permanent upstream failure
2 Invalid invocation
75 Transient upstream failure: 429, 5xx, timeout, or connection error

Reference

Vocabulary

version: 1
apps:
  workspace: { project: 1, host: app.test, audience: "workspace admins" }
surfaces:
  - { id: workspace.members, app: workspace, route: /settings/members, name: "Members" }
features:
  - id: members.invite.send
    surface: workspace.members
    name: "Send invite"
    matchers: { testid: [send-invite] }
    source: "members.tsx:11"
terms:
  - term: seat
    means: "Every member holds a seat until removed, deactivated members included."
    source: "members.server.ts:4"
statuses:
  - { kind: member, value: deactivated, label: Deactivated }
gaps: ["Billing page: billing.tsx is not among the sources, so seat purchases are unnamed."]
  • Matchers, most specific first: testid, data_attr, aria, title, placeholder, href, text, role, class_contains. aria, text and title also take *_template forms.
  • surface: "*" marks app chrome and requires app.
  • Optional: statuses, grid (row identity), telemetry (URLs to ignore), error_text, thresholds.
  • Defaults: English error patterns, common monitoring requests ignored, slow_ms: 1000.
  • vocab check reports invalid matchers and inert entries.

Recordings

  • Accepted: rrweb event arrays, JSONL, compressed input, and PostHog snapshot lines.
  • Native iOS and Android: taps resolve against PostHog wireframes. Native routes are screen names such as SettingsScreen, matched case-sensitively.
  • Screenshot-mode frames, including Flutter and React Native, are opaque images. A tap on one is reported as screen (x,y), with no invented control.
  • A visit ends after 30 minutes without actions (thresholds.visit_gap_ms).

PostHog

  • recordings list returns one page. Pass its next_cursor back as --cursor.
  • It lists recordings that started over 24 hours ago and ended by --until. Use settled windows, and overlap consecutive syncs.
  • Segments more than 7 days outside the window can make a recording look complete.
  • The default host is https://eu.posthog.com. Set --host for US or self-hosted.
  • Listings and snapshot ranges share PostHog's per-key throttle (paid: 60/min, 300/h). A paid key fetches about 75 typical recordings an hour.
  • On 429, fetch honours Retry-After for up to --max-wait seconds (60), then exits 75.
  • --max-requests caps requests per fetch (default 50, at most 59).

Data and security

  • Recordings are untrusted input. --max-input-mib (512) bounds decoded size. HogQL and model responses are capped at 32 MiB.
  • Recordings are sensitive. Review what you send to PostHog and OpenRouter.
  • Model calls request zero-data-retention routing. Each analysis records model, tokens and cost.
  • Credentials travel only over HTTPS, and redirects are disabled.
  • Report vulnerabilities privately through GitHub security advisories.

Limits

  • The DOM mirror can't see iframe documents, canvas pixels, or shadow-root internals. Events from unrecognized rrweb plugins are not interpreted. coverage counts all of these.
  • rrweb records no key presses, so a keyboard-shortcut change has no gesture.
  • Masked inputs reveal only their length.
  • Tests cover the compiler and the gate, not narration quality or live PostHog behaviour.

Development

cargo fmt --all --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
  • corpus/ holds synthetic recordings with golden traces. They define compiler behaviour.
  • Edit cases in scripts/corpus.py, then run python3 scripts/corpus.py.
  • Accept an intended output change with SPOILER_BLESS=1 cargo test --test corpus. Review every golden diff.
  • A compile-rule change bumps COMPILER_VERSION. Readers reject traces from other versions.
  • Fixtures stay synthetic: no real recordings, credentials, or customer data.
  • Releasing is documented at the top of .github/workflows/release.yml.

License

Apache-2.0

Metadata

Release files for spoiler 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for spoiler 0.1.0
File Size Uploaded
spoiler-0.1.0.tar.gz 176.7 kB Details

Built distributions (wheels)

Table of built distributions (wheels) for spoiler 0.1.0
File
spoiler-0.1.0-py3-none-musllinux_1_2_x86_64.whl Python 3 none Linux musl 1.2+ x86-64 Details
spoiler-0.1.0-py3-none-musllinux_1_2_aarch64.whl Python 3 none Linux musl 1.2+ ARM64 Details
spoiler-0.1.0-py3-none-manylinux_2_28_x86_64.whl Python 3 none Linux glibc 2.28+ x86-64 Details
spoiler-0.1.0-py3-none-manylinux_2_28_aarch64.whl Python 3 none Linux glibc 2.28+ ARM64 Details
spoiler-0.1.0-py3-none-macosx_11_0_arm64.whl Python 3 none macOS 11.0+ ARM64 Details
spoiler-0.1.0-py3-none-macosx_10_12_x86_64.whl Python 3 none macOS 10.12+ x86-64 Details

Total release size: 20.6 MB

Release files / spoiler-0.1.0.tar.gz

Download URL spoiler-0.1.0.tar.gz
Size 176.7 kB
Tags Source
SHA-256 checksum
How to use checksums
42da1025f09f1af866e2b8ad1c4cb5da95cb7d7e14bd86f9628d6be473f1e602
BLAKE2b-256 checksum
How to use checksums
0afb23d340547b610a2399da05568dfed9a6fca084b9899e8df3f5c1f379b44b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release files / spoiler-0.1.0-py3-none-musllinux_1_2_x86_64.whl

Download URL spoiler-0.1.0-py3-none-musllinux_1_2_x86_64.whl
Size 3.6 MB
Tags Linux musl 1.2+ x86-64 Python 3
SHA-256 checksum
How to use checksums
ee09bcc64eb4e3a1eb2d8eae3d96fddd3f53e6c1c000be1d6f388dbc176ec45a
BLAKE2b-256 checksum
How to use checksums
5e133ea1995bff0d4dbdcb58b57918ced4f8ef71c3e80070f6b363af2a4bdd19
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release files / spoiler-0.1.0-py3-none-musllinux_1_2_aarch64.whl

Download URL spoiler-0.1.0-py3-none-musllinux_1_2_aarch64.whl
Size 3.3 MB
Tags Linux musl 1.2+ ARM64 Python 3
SHA-256 checksum
How to use checksums
f6c2dec397e3931f194000b6e7c01ff67679ba1992604dd77fb75cdcce854313
BLAKE2b-256 checksum
How to use checksums
de0e2cad63be2278c3493e834fcec679b5f89b201756e81061bac0e6ae2c6a47
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release files / spoiler-0.1.0-py3-none-manylinux_2_28_x86_64.whl

Download URL spoiler-0.1.0-py3-none-manylinux_2_28_x86_64.whl
Size 3.5 MB
Tags Linux glibc 2.28+ x86-64 Python 3
SHA-256 checksum
How to use checksums
979ef263d8de8d27240c28e2bdd00e0fe3044655a3c3a49b716d06d101f188e0
BLAKE2b-256 checksum
How to use checksums
f2eb51f5e9eda48dbf04b0657dae7fb9935a93104ae9089c51f4688258543503
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release files / spoiler-0.1.0-py3-none-manylinux_2_28_aarch64.whl

Download URL spoiler-0.1.0-py3-none-manylinux_2_28_aarch64.whl
Size 3.3 MB
Tags Linux glibc 2.28+ ARM64 Python 3
SHA-256 checksum
How to use checksums
bf40108136e692a1ccd10b2ddb9631cb925db8fb0458dd971ce37071f1c692f8
BLAKE2b-256 checksum
How to use checksums
230fd72ee3061550af1216cb7f7b90444b8ab259945e2db3e0ac97188585b002
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release files / spoiler-0.1.0-py3-none-macosx_11_0_arm64.whl

Download URL spoiler-0.1.0-py3-none-macosx_11_0_arm64.whl
Size 3.2 MB
Tags Python 3 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
edd530e322ec1d9af53d652c0c6d1c4969ba28be9c399cb33762ece67d9f5a0d
BLAKE2b-256 checksum
How to use checksums
02cdd9e4306631c28520374459bf31e9bd7f39dfdf265236cd17144e40d7d8f7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release files / spoiler-0.1.0-py3-none-macosx_10_12_x86_64.whl

Download URL spoiler-0.1.0-py3-none-macosx_10_12_x86_64.whl
Size 3.4 MB
Tags Python 3 macOS 10.12+ x86-64
SHA-256 checksum
How to use checksums
dc3386549b32332446d3b9ab55ff90928bd9f04f6efd8e9f1ab2481bf1560637
BLAKE2b-256 checksum
How to use checksums
a9c834aece1b7a0cb21315f866464e9afc28450910888645881a1cf8072497b8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release history Release notifications | RSS feed

0.1.3

7 release files

0.1.2

7 release files

0.1.1

7 release files

This release

0.1.0 This release

7 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page