# 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](/docs#the-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:

```prompt
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](#4-build-the-feature-map).

## By hand

### 1. Install

```sh
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](/docs/reference#installing) for pinned
versions and release archives.

### 2. Set your keys

Spoiler reads keys only from the environment:

```sh
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`:

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

- `web` is an id you choose. Later commands take it as `--app web`.
- `project` is your PostHog project id, and `host` is where the app runs.
- `audience` tells 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`, an `aria-label` or 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.

```sh
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:

```sh
jq '.vocabulary | {surfaces: (.surfaces | length), features: (.features | length), terms: (.terms | length)}' spoiler/vocab.json
jq '.vocabulary.gaps' spoiler/vocab.json
```

- `gaps` lists what the sources didn't cover. If a gap matters, add the file that covers it and
  build again.
- `vocab check` lists `warnings`: 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:

```sh
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](/docs/reference#feature-map) lists every field.

### 5. Find recordings

```sh
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:

```sh
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:

```sh
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

```sh
# 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:

```sh
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:

```sh
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:

```sh
# 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 `analyze` refuses a trace compiled with a
  different one, so recompile old recordings after rebuilding.
- Commit `spoiler/product.json` and `spoiler/vocab.json`. Keep recordings and reports out of
  version control; they hold user data:

  ```sh
  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:

```sh
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.
