---
name: deja
version: 1.1.0
description: Install deja session-replay recording into a website or app. Triggers on "install deja", "add session recording", "add session replay", "deja setup", "switch from clarity/hotjar to deja", "uninstall clarity/hotjar and use deja".
---

# deja — session replay installer

deja records real user sessions (rrweb) and ships them to a Convex backend so a team can replay
them later, plus per-page heatmaps, friction signals and insights ("Find out why users leave while
they're still here."). This skill installs the tracker into the CURRENT repo the way that never
hurts performance: gated behind first interaction or idle, never as a blocking `<script>` tag.

This file is self-contained. Do not go looking for other deja-repo docs — you may not have access
to them from a customer repo.

## Step 0 — make sure this skill is current

This file is a build artifact served from the deja deployment. The copy on disk can be stale, and a
stale copy has shipped wrong instructions before. Update it before you follow it:

```bash
$DEJA update --json     # $DEJA is resolved in Step 1 — do that first, then come back here
```

The response looks like `{"cli":{...},"skill":{"version":"1.1.0","updated":true,"path":"..."}}`.

**If `skill.updated` is `true`, stop and re-read the SKILL.md at `skill.path` before doing anything
else** — the instructions you are currently holding are the old ones. If `skill.action` is
`"up-to-date"`, carry on with this file.

(The CLI also self-checks once a day on its own and prints a line to stderr when it updates
something. `--no-update-check`, or `DEJA_NO_UPDATE_CHECK=1`, turns that off for a run.)

## Step 1 — locate the CLI and confirm login

Try, in order:

```bash
deja whoami --json
# if `deja` is not on PATH:
node ~/.deja/bin/deja.mjs whoami --json
node ~/Documents/GitHub/deja/cli/bin/deja.mjs whoami --json
```

Whichever form works, use it consistently for the rest of this session. Call it `$DEJA` below.

If none of them exist, install the managed CLI (it then keeps itself updated):

```bash
mkdir -p ~/.deja/bin && \
  curl -fsSL https://clever-mole-378.convex.site/cli.mjs -o ~/.deja/bin/deja.mjs && \
  chmod +x ~/.deja/bin/deja.mjs
```

If `$DEJA whoami --json` fails (not logged in / no config), ask the user for their deja API key
(`deja_sk_...`) and run:

```bash
$DEJA login --key deja_sk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

Then re-run `whoami --json` to confirm `{ orgId, userId, label }` comes back before continuing.
Now go back and run **Step 0** if you haven't.

## Step 2 — create the project

There is no website-vs-app choice: a project is a project. deja labels each recorded person from
their own data — named if the site calls `identify()`, anonymous otherwise — so nothing needs
classifying up front.

```bash
$DEJA projects create --name "Acme Marketing Site" --domain acme.com --json
```

Parse the JSON response: `.project.publicKey` (starts `deja_pk_`) and `.project.id`. You need both
for the rest of this install — the public key goes in the snippet, the id is used for verification.

If a project for this site may already exist, check first with `$DEJA projects list --json` and
reuse its `publicKey`/`id` instead of creating a duplicate.

**If the site has routes carrying an id or a tenant slug, do Step 2b before moving on.** Page
identity is decided the moment the first session lands, and fixing it later means re-reading every
stored recording.

## Step 2b — map the routes (skip only if every URL is a fixed path)

deja groups sessions by *page*, and it works out what a page is from the URL. Segments that
obviously look like ids collapse on their own — `/orders/1042` → `/orders/:id`, and likewise
`:uuid`, `:date`, and `:hash` (≥12 lowercase alphanumeric chars with no word-like structure, which
is what most database ids look like).

Two very common cases are **not** caught, because nothing about the string gives them away:

- **Slug routes** — `/acme/billing`, `/acme/settings`. A tenant slug is shaped exactly like content
  (`/blog/my-post`), so it is left alone, and you get one page per customer instead of one page.
- **Prefixed ids** — `user_3F0CV…`, `cus_NffrFeUf…`, `acct_1032D8…`. The underscore and mixed case
  fail the `:hash` test, so Stripe/Clerk-style ids are left alone too.

Read the app's router (`app/`, `pages/`, route table, whatever it uses) and write one rule per
route shape. `{name}` matches a single segment and is displayed as `{name}`; a trailing `*` matches
any deeper path and hands the rest back to the automatic rules.

```bash
$DEJA projects create --name "Acme App" --domain app.acme.com --json \
  --path-rule "/settings/*" \
  --path-rule "/{organization}/{project}/users/{user}" \
  --path-rule "/{organization}/{project}" \
  --path-rule "/{organization}/{project}/*"
```

**Order is the thing to get right.** First match wins, and a bare `/{a}/{b}/*` matches *every* path
with two or more segments — so any route that is not tenant-scoped must be listed above it. A
literal prefix rule like `/settings/*` is how you protect one: it matches, keeps `settings`
literal, and lets the automatic rules handle the rest.

Rules can be changed later on an existing project:

```bash
$DEJA projects path-rules --project <projectId> \
  --path-rule "/{organization}/{project}/*" --json
```

...but that only re-keys already-recorded pages after a purge + backfill from the project's
Settings screen, which on a busy project re-reads every stored recording. Get them right here.

Sanity-check the result after the first sessions arrive: open **Pages** and look for rows that are
clearly the same screen repeated with a different id or slug in them. That is a missing rule.

## Step 3 — the snippet (never install this as a static tag)

```html
<script async src="https://clever-mole-378.convex.site/tracker.js" data-deja-key="PK"></script>
```

Replace `PK` with the real `publicKey`. **Do not** drop this tag directly into `<head>` or
`<body>` on a perf-sensitive site — even `async` scripts cost a network request and parse time on
the critical path. Always inject it via one of the two gates below, chosen per Step 4.

Every recipe below also installs a **queue stub** before injecting:

```js
window.deja = window.deja || function () { (window.deja.q = window.deja.q || []).push(arguments); };
```

Keep it. It is what makes `deja("identify", …)` (Step 6) safe to call at any moment: without it,
app code that identifies a user before the bundle has downloaded — or before the gate has even
fired — throws `deja is not a function` and the call is lost. The bundle drains `deja.q` on load.

## Step 4 — choose a gate

**(a) lighthouse-strict** — fires ONLY on first user interaction. Zero cost until a human touches
the page — invisible to Lighthouse/PageSpeed lab audits by construction. Use for perf-100 marketing
sites, or any page where Lighthouse score is a hard requirement. Trade-off: sessions where the
visitor bounces without any interaction are never recorded.

```js
function installDejaLighthouseStrict(key) {
  window.deja = window.deja || function () { (window.deja.q = window.deja.q || []).push(arguments); };
  const events = ["pointerdown", "keydown", "touchstart", "scroll", "mousemove"];
  let injected = false;
  function inject() {
    if (injected) return;
    injected = true;
    const s = document.createElement("script");
    s.async = true;
    s.src = "https://clever-mole-378.convex.site/tracker.js";
    s.dataset.dejaKey = key;
    document.body.appendChild(s);
  }
  events.forEach((ev) => window.addEventListener(ev, inject, { once: true, passive: true }));
}
```

**(b) balanced (default)** — races the same first-interaction listeners against
`requestIdleCallback` fired after `window.load`, with a timeout so it always eventually fires even
if the tab stays idle. Captures bounce sessions too, at a small theoretical lab-audit exposure
(the tracker still yields via its own internal idle callback before `record()` starts, so it never
absorbs the triggering interaction's INP sample). Use this unless the user asks for
lighthouse-strict.

```js
function installDejaBalanced(key) {
  window.deja = window.deja || function () { (window.deja.q = window.deja.q || []).push(arguments); };
  const events = ["pointerdown", "keydown", "touchstart", "scroll", "mousemove"];
  let injected = false;
  function inject() {
    if (injected) return;
    injected = true;
    const s = document.createElement("script");
    s.async = true;
    s.src = "https://clever-mole-378.convex.site/tracker.js";
    s.dataset.dejaKey = key;
    document.body.appendChild(s);
  }
  events.forEach((ev) => window.addEventListener(ev, inject, { once: true, passive: true }));
  window.addEventListener("load", () => {
    if ("requestIdleCallback" in window) {
      requestIdleCallback(inject, { timeout: 4000 });
    } else {
      setTimeout(inject, 4000);
    }
  });
}
```

## Step 5 — framework recipes (verbatim, adapt only the config consts)

### Astro (`src/components/Deja.astro`)

```astro
---
// Deja.astro — session recording, gated on first interaction. Zero cost until a user acts.
const DEJA_KEY = "PK_HERE";
const IS_PROD = import.meta.env.PROD; // drop this check if the site has no prod/dev split
---
{IS_PROD && DEJA_KEY && (
  <script define:vars={{ key: DEJA_KEY }} is:inline>
    (function installDeja(key) {
      window.deja = window.deja || function () { (window.deja.q = window.deja.q || []).push(arguments); };
      const events = ["pointerdown", "keydown", "touchstart", "scroll", "mousemove"];
      let injected = false;
      function inject() {
        if (injected) return;
        injected = true;
        const s = document.createElement("script");
        s.async = true;
        s.src = "https://clever-mole-378.convex.site/tracker.js";
        s.dataset.dejaKey = key;
        document.body.appendChild(s);
      }
      events.forEach((ev) => window.addEventListener(ev, inject, { once: true, passive: true }));
      window.addEventListener("load", () => {
        if ("requestIdleCallback" in window) requestIdleCallback(inject, { timeout: 4000 });
        else setTimeout(inject, 4000);
      });
    })(key);
  </script>
)}
```

Import it once in the base layout: `import Deja from "../components/Deja.astro";` then `<Deja />`
near the end of `<body>`.

### Next.js (`next/script`, `strategy="lazyOnload"`)

A server component (no `"use client"`) — render it near the end of `<body>` in the root layout:

```tsx
import Script from "next/script";

const DEJA_KEY = "PK_HERE";

// The stub is a plain inline <script>, not <Script strategy="beforeInteractive">:
// beforeInteractive is documented as root-layout-only, and an inline script
// rendered by the server already runs before any hydration, with no network cost.
const DEJA_STUB = `window.deja=window.deja||function(){(window.deja.q=window.deja.q||[]).push(arguments)};`;

export function Deja() {
  return (
    <>
      <script dangerouslySetInnerHTML={{ __html: DEJA_STUB }} />
      <Script
        src="https://clever-mole-378.convex.site/tracker.js"
        data-deja-key={DEJA_KEY}
        strategy="lazyOnload"
      />
    </>
  );
}
```

`lazyOnload` already defers to browser idle time after load, which is equivalent to the balanced
gate's idle half. If the user explicitly wants lighthouse-strict in Next.js, use a client component
with a `useEffect` running the same interaction-listener gate from Step 4(a) instead, injecting a
plain `<script>` element — keep the stub where it is either way.

### Plain HTML (inline IIFE, placed right before `</body>`)

```html
<script>
  (function installDeja(key) {
    window.deja = window.deja || function () { (window.deja.q = window.deja.q || []).push(arguments); };
    var events = ["pointerdown", "keydown", "touchstart", "scroll", "mousemove"];
    var injected = false;
    function inject() {
      if (injected) return;
      injected = true;
      var s = document.createElement("script");
      s.async = true;
      s.src = "https://clever-mole-378.convex.site/tracker.js";
      s.dataset.dejaKey = key;
      document.body.appendChild(s);
    }
    events.forEach(function (ev) {
      window.addEventListener(ev, inject, { once: true, passive: true });
    });
    window.addEventListener("load", function () {
      if ("requestIdleCallback" in window) requestIdleCallback(inject, { timeout: 4000 });
      else setTimeout(inject, 4000);
    });
  })("PK_HERE");
</script>
</body>
```

## Step 6 — identify the user (skip if the site has no sign-in)

Without this every session is anonymous: the **People** screen shows only `Anonymous · a4f21c` rows,
and nobody can answer "show me what *this* customer hit before they churned". The snippet alone does
not do it — the app has to say who is signed in.

Do this for anything with accounts, including a marketing site that has a logged-in area. It is not
tied to how the project was created.

```js
deja("identify", uid, { email, name, accountId });
```

- **`uid` (required)** — the app's own stable user id (`user.id`, the database primary key). NOT an
  email and NOT a session id: it is the join key across every session that person ever records, so
  it has to survive email changes and re-logins.
- **`email` / `name`** — display traits, optional, purely so the dashboard shows a human.
- **`accountId`** — the org/workspace/tenant id in a B2B product. This is what groups several
  people's sessions under one customer; if the product has accounts, send it.
- Traits are capped at 256 characters each and the call is a no-op without a non-empty `uid`.

**Where to call it.** As soon as auth resolves, on every page load — not only at the moment of
login. It is idempotent, and calling it again with changed traits just updates them. The identity
persists in `localStorage`, so anonymous visits made *before* signup stitch onto the user once they
identify on the same browser.

Wire it into whatever already knows the current user (auth context/provider/hook), for example:

```tsx
useEffect(() => {
  if (!user) return;
  window.deja?.("identify", user.id, {
    email: user.primaryEmailAddress?.emailAddress,
    name: user.fullName ?? undefined,
    accountId: organization?.id,
  });
}, [user, organization]);
```

**Logout is not optional.** On sign-out call:

```js
deja("reset");
```

That flushes the buffered events under the user who is leaving, drops the stored identity, rotates
the anonymous visitor id and starts a brand-new session. Skip it and the next person to use a shared
machine gets their session attributed to the previous user.

Both calls are safe before the tracker bundle loads, as long as the Step 3 queue stub is in place.

## Step 7 — removing competing trackers (only if asked)

Grep for existing session-recording tools before touching anything:

```bash
grep -rli "clarity.ms\|clarity_id\|hotjar\|hjid\|posthog" --include="*.astro" --include="*.tsx" \
  --include="*.jsx" --include="*.html" --include="*.ts" --include="*.js" .
```

For each hit: remove the loader component/snippet, its config constant (API key / site id), and
any imports/references to it. Do NOT touch GA4/Google Ads tags (`data-ga*`, `gtag`, conversion
tracking) unless the user explicitly asks — those are a separate, load-bearing contract.

Remind the user to update the privacy policy. Sample PT-BR sentence (adapt to the site's tone,
LGPD-compliant — mentions session recording, what is captured/masked, anonymous ids):

> Utilizamos gravação de sessão (deja) para entender como visitantes usam o site. A gravação inclui
> o conteúdo digitado em campos de formulário; **senhas nunca são gravadas** e campos marcados como
> sensíveis são mascarados. O identificador é anônimo (não vinculado a dados pessoais).

**Protecting sensitive fields.** As of 2026-07-22 deja **captures the content typed into ordinary
form fields** so replays show what users did. Two things are always safe, and the site owner
controls the rest:

- **Passwords are never captured** — any `input[type=password]` (or a field that was ever a
  password) is automatically masked. No action needed.
- **To protect ANY other field**, mark it in the site's HTML:
  - `class="deja-mask"` or `data-deja-mask` → the field stays visible in replay but its **value is
    starred** (never recorded). Also masks any text node it wraps.
  - `class="deja-block"` or `data-deja-block` → the whole element is **omitted** from the recording
    (replayed as a blank placeholder). Use for anything you don't want to appear at all.
  - `class="deja-ignore"` → input events on the field are not recorded at all.

  Apply these to fields holding PII/financial data (CPF, card number, health info, etc.) if the
  site collects them. When removing competing trackers / editing the privacy policy, flag to the
  user that **typed form content is now recorded** so they can add these markers where needed.

## Step 8 — verify

```bash
curl -s https://clever-mole-378.convex.site/health
# expect: {"ok":true,"service":"deja","version":"..."} — check `ok`, not the version number
```

Run the site (dev or prod), open it in a browser, interact with the page (click, scroll, type) so
the gate fires, wait ~10–30s for the tracker's flush interval, then:

```bash
$DEJA sessions count --project <projectId> --json
```

Run it once before interacting and once after — the count must have increased. If it hasn't after
30s, check the browser network tab for a request to `/tracker.js` and `/ingest?pk=...`; a 404 on
either means the wrong domain, key, or a gate that never fired.

Then open the dashboard and confirm the analysis layer, not just the recording:

- **Pages** — the page you visited is listed, with views and a friction score. Page analysis is on
  by default; nothing to enable.
- **Page report** (click the row) — the heatmap renders over a screenshot-like backdrop of the page.
  A backdrop only exists once a chunk carrying a full DOM snapshot has landed, which happens on the
  first flush of a page load and after each SPA route change — so if it is blank, interact and wait
  for one more flush rather than debugging it.
- If you wrote Step 2b rules, confirm they landed: navigate between a few routes that carry an id or
  a tenant slug — each screen should be ONE row with `{name}` or `:id` in it, not one row per record.
  If you see the same screen repeated, add the missing rule with `$DEJA projects path-rules` and
  re-check before handing over.
- If you wired Step 6: sign in, then check the **People** screen shows that user by name or email
  rather than as an anonymous row. Still anonymous after a signed-in session means `identify` never
  fired — check the stub is present and that the call runs after auth resolves.

Optional Playwright verification recipe:

```js
const { chromium } = require("playwright");

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto("http://localhost:3000");
  await page.mouse.move(100, 100);
  await page.mouse.move(200, 300);
  await page.waitForTimeout(10000);
  await browser.close();
})();
```

Then re-run `sessions count` and confirm the increase.

## Safety rails

- Never touch existing analytics (GA4, Google Ads, other non-competing tags) unless explicitly
  asked to.
- Never commit the API key (`deja_sk_...`) anywhere in the customer repo — it lives only in
  `~/.deja/config.json` on the machine running the CLI. The `data-deja-key` (`deja_pk_...`) in the
  snippet is a **public** key by design and is safe to commit.
- If unsure whether a site is perf-sensitive (Lighthouse score matters), default to **balanced**
  and ask; only go lighthouse-strict when the user says perf/Lighthouse score is a hard
  requirement.
- Do not hand-edit this file where it is installed (`~/.claude/skills/deja/SKILL.md`) — it is
  overwritten by `deja update`. It is authored at `skill/deja/SKILL.md` in the deja repo and
  published by `npm run build:cli`.
