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)
- Vocabulary.
vocab buildhas 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 undergaps, not guessed. The snapshot pins every source by SHA-256 and is never regenerated implicitly.--candidatepackages a vocabulary you wrote instead, with no model call. - Compile.
compilereplays 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. - Narrate.
analyzesends the trace as TSV, plus the vocabulary it touched. The recording itself is never sent.runmakes one call per visit. The model returns tasks with a goal, outcome, obstacle and friction, all citing refs. Outcomes aredone,workaround,gave_uporunclear. - 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,errorandslowfriction is dropped without a matching flag.- Timestamps, durations, paths and data changes are written from the trace, never by the model.
checklists 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 shpins a release.SPOILER_INSTALL_DIRpicks 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-darwinx86_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
runnarrates every visit with user gestures.--visit Npicks 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> --helplists 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,textandtitlealso take*_templateforms. surface: "*"marks app chrome and requiresapp.- Optional:
statuses,grid(row identity),telemetry(URLs to ignore),error_text,thresholds. - Defaults: English error patterns, common monitoring requests ignored,
slow_ms: 1000. vocab checkreports 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 listreturns one page. Pass itsnext_cursorback 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--hostfor 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-Afterfor up to--max-waitseconds (60), then exits75. --max-requestscaps 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.
coveragecounts 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 runpython3 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
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)
| File | Size | Uploaded | |
|---|---|---|---|
| spoiler-0.1.0.tar.gz | 176.7 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| 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 logRelease 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 logRelease 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 logRelease 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 logRelease 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 logRelease 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 logRelease 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