Start
Set up
Install Spoiler in an existing project, map your product, read your first recordings, and query the results.
By the end of this page your repository has a spoiler/ folder holding a description of your
app, a reviewed feature map, and reports for your first PostHog
sessions. You’ll need a PostHog project with session replay turned on, and an OpenRouter
account.
With a coding agent
Give your coding agent (Claude Code, Codex, Cursor, or similar) this prompt, from your product’s repository:
Use curl to read https://spoiler.sh/agents.md, then follow it to set up Spoiler in this project.
The agent installs the CLI and works through the steps below. It finds what it can in your repository, such as your host name, routes and components, and asks you, in one message, for the rest: your PostHog project id and host, and which model to use. It doesn’t write your keys to disk, and it shows you what will be sent to the model before sending it.
The step that decides the quality of every report is the choice of source files for the feature map. When the agent is done, review that choice and the map itself, as described in step 4.
By hand
1. Install
curl -fsSL https://spoiler.sh/install | sh
spoiler --version
The script installs a checked binary for your OS and CPU to ~/.local/bin. You can also
install with uv tool install spoiler, pip install spoiler, or, with Rust 1.88 or later,
cargo install spoiler --locked. See installing for pinned
versions and release archives.
2. Set your keys
Spoiler reads keys only from the environment:
export POSTHOG_API_KEY=phx_… # personal API key: Query Read and Session recording Read
export OPENROUTER_API_KEY=sk-or-…
MODEL=… # the OpenRouter model id to use
Create the PostHog key under your account’s personal API keys, with read access to queries and session recordings. Any OpenRouter model id works; Spoiler never picks one for you.
3. Describe your app
Create spoiler/product.json:
{
"apps": {
"web": { "project": 12345, "host": "app.example.com", "audience": "workspace admins" }
}
}
webis an id you choose. Later commands take it as--app web.projectis your PostHog project id, andhostis where the app runs.audiencetells the model who uses the app, in a few words.
A native iOS or Android app recorded in PostHog gets its own entry.
4. Build the feature map
Choose the source files that define what users see and do. The model reads only these, so this choice matters more than any other:
- Routes: the route table or page entry points, so every page gets a name.
- Controls: the components holding your main buttons, forms, menus and dialogs. Controls
with a
data-testid, anaria-labelor visible text give the most reliable matches. - Rules: server code holding your limits, permissions, billing and validation, and the error messages users see.
Start with the files behind your most important flows. All of them go to the model in one request, so keep the set focused, and skip tests, styles and generated code. Sources must be UTF-8 text, and are cited by file name, so avoid two with the same name.
spoiler vocab build \
--config spoiler/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 spoiler/vocab.json
spoiler vocab check --vocab spoiler/vocab.json
Then review what was mapped and what was left out:
jq '.vocabulary | {surfaces: (.surfaces | length), features: (.features | length), terms: (.terms | length)}' spoiler/vocab.json
jq '.vocabulary.gaps' spoiler/vocab.json
gapslists what the sources didn’t cover. If a gap matters, add the file that covers it and build again.vocab checklistswarnings: entries that can never match. Fix or remove them.- What the build cost is in
.provenance.generator.usage.
To correct entries by hand, edit the map and package it again, with the same sources and no model call:
jq .vocabulary spoiler/vocab.json > spoiler/vocab.candidate.json
# edit spoiler/vocab.candidate.json, then:
spoiler vocab build \
--config spoiler/product.json \
--source app/routes.ts \
--source app/routes/members.tsx \
--source app/members.server.ts \
--candidate spoiler/vocab.candidate.json \
--out spoiler/vocab.json
The feature map reference lists every field.
5. Find recordings
spoiler recordings list \
--project 12345 \
--since 2026-09-20 \
--until 2026-09-27 \
--limit 20 \
--out spoiler/page.json
jq -r '.recordings[] | [.session_id, .start_time, .active_s, .clicks, .first_url] | @tsv' spoiler/page.json
Only recordings that started more than 24 hours ago are listed, so choose a window that has
settled. Pick a session with some clicks and active time. For US or self-hosted PostHog, add
--host https://us.posthog.com (or your URL) to every PostHog command.
6. Read one session
Fetch the recording, then prepare the model request without sending anything:
SESSION=… # a session_id from the list
mkdir -p spoiler/recordings spoiler/sessions
spoiler recordings fetch --project 12345 --session "$SESSION" --out "spoiler/recordings/$SESSION.json"
spoiler run \
--recording "spoiler/recordings/$SESSION.json" \
--vocab spoiler/vocab.json \
--app web \
--prepare-only \
--out "spoiler/sessions/$SESSION.request.json"
jq -r '.visits[].request.messages[] | "--- \(.role)\n\(.content)"' "spoiler/sessions/$SESSION.request.json"
That’s exactly what the model will receive: instructions, the trace, and the feature map entries it touched. When you’re happy with it, run it for real:
spoiler run \
--recording "spoiler/recordings/$SESSION.json" \
--vocab spoiler/vocab.json \
--app web \
--model "$MODEL" \
--out "spoiler/sessions/$SESSION.json"
run makes one model call for each visit with user actions; --visit N reads just one.
7. Read the report
# Each task: how it ended, what the user wanted, what stopped them.
jq -r '.visits[].analysis.summary.tasks[] | [.outcome, .goal, (.obstacle // "")] | @tsv' "spoiler/sessions/$SESSION.json"
# Each problem: its kind, severity, what happened, and the likely reason.
jq -r '.visits[].analysis.summary.friction[] | [.kind, .severity, .what, (.why // "")] | @tsv' "spoiler/sessions/$SESSION.json"
# What the check dropped or couldn't verify.
jq '.visits[].analysis.check' "spoiler/sessions/$SESSION.json"
Every task and problem cites trace references such as e3. To see the actions behind one, find
it in the trace, which the session file includes:
jq -r '.trace.tsv' "spoiler/sessions/$SESSION.json" | grep -E '^(ref|e3)\b'
Across many sessions
Each report is a JSON file, so a folder of them is a dataset. To read every session in a page of the list:
jq -r '.recordings[].session_id' spoiler/page.json | while read -r id; do
spoiler run --project 12345 --session "$id" \
--vocab spoiler/vocab.json --app web --model "$MODEL" \
--out "spoiler/sessions/$id.json" || echo "skipped $id" >&2
done
PostHog limits how fast recordings can be fetched: a paid personal key manages about 75 typical
recordings an hour. A command that hits a limit or timeout exits with code 75; wait, then run
it again. The next page of the list comes from passing its next_cursor back as --cursor.
Then ask questions across all of them:
# How tasks end.
jq -r '.visits[].analysis.summary.tasks[].outcome' spoiler/sessions/*.json | sort | uniq -c
# The tasks people finished only through a workaround.
jq -r '.visits[].analysis.summary.tasks[] | select(.outcome == "workaround") | .goal' spoiler/sessions/*.json
# The most common kinds of problem.
jq -r '.visits[].analysis.summary.friction[].kind' spoiler/sessions/*.json | sort | uniq -c | sort -rn
# Sessions where someone hit an error.
jq -r 'select(any(.visits[].analysis.summary.friction[]; .kind == "error")) | input_filename' spoiler/sessions/*.json
# Which features people use, from the feature map.
jq -r '.trace.actions[] | select(.kind == "click" or .kind == "input") | .feature // "(unmapped)"' spoiler/sessions/*.json | sort | uniq -c | sort -rn
# Which features raise trouble flags, and which ones.
jq -r '.trace.actions[] | select(.flags | length > 0) | [.feature // "(unmapped)", (.flags | join(","))] | @tsv' spoiler/sessions/*.json | sort | uniq -c | sort -rn
# What the model calls cost in total, in USD.
jq -s '[.[].visits[].analysis.model.cost_usd // 0] | add' spoiler/sessions/*.json
The feature-use queries read the traces, which come from code alone. A click that shows up as
(unmapped) means the feature map doesn’t cover that control yet.
Keep it current
-
Rebuild the feature map when the files it came from change, or when you ship new pages. A trace records the map it was compiled with, and
analyzerefuses a trace compiled with a different one, so recompile old recordings after rebuilding. -
Commit
spoiler/product.jsonandspoiler/vocab.json. Keep recordings and reports out of version control; they hold user data:printf 'spoiler/recordings/\nspoiler/sessions/\nspoiler/page.json\n' >> .gitignore
Try it without an account
To see each step on a sample recording first, with no PostHog or OpenRouter account, clone Spoiler’s repository, which ships a sample recording and a feature map:
git clone https://github.com/sahil-shubham/spoiler.git
cd spoiler
spoiler compile \
--recording corpus/click_changes_text.json \
--vocab corpus/vocabulary.yaml \
--app demo \
--out /tmp/trace.json
jq -r .tsv /tmp/trace.json
spoiler analyze \
--trace /tmp/trace.json \
--vocab corpus/vocabulary.yaml \
--prepare-only \
--out /tmp/request.json
The first command compiles the sample recording into a trace and prints it. The second writes
the exact request a model would receive, without sending it. To see the check at work,
--response examples/click_changes_text.response.json in place of --prepare-only runs a
saved answer through the same checks a live one gets.