# Install Hoot Lens

Add this one tag to every page, as high in the document as your platform allows:

```html
<script async src="https://hootlens.com/t.js" data-project="PROJECT_ID"></script>
```

Find `PROJECT_ID` in the dashboard under Installation. It is public. Privacy, consent mode and sampling come from your project settings in the dashboard, not from the install.

## Fastest install

1. Sign up, add your site address in the dashboard, and copy the tag from Installation. This takes about a minute. The new project starts with balanced privacy, recording on load and your plan's retention, and none of it needs deciding now.
2. Put the tag where your platform says (table below), or give your coding agent the prompt from Installation ("Let your AI agent install it"). With the Hoot Lens connector an agent can call `get_install_snippet` for the exact code, and `check_install` to verify.
3. Publish, or run locally: to test on your own computer, choose "Allow localhost for testing" in Installation. It allows one exact `http://localhost:PORT` or `http://127.0.0.1:PORT` and nothing else.
4. Visit the site. Installation shows "First visit recorded" and links to the replay. In the browser console, `Hootlens.status()` should return `state: 'recording'`, and `?hootlens=debug` on the address shows a panel with the reason when it does not.

| Platform | Where the tag goes |
| --- | --- |
| Plain HTML | The `<head>` of every page |
| Next.js | `app/layout.tsx` with `<Script>` (App Router), or `pages/_document.tsx` inside `<Head>` (Pages Router) |
| Vite (React, Vue) | `index.html`, inside `<head>` |
| Nuxt | `nuxt.config.ts`, under `app.head.script` |
| SvelteKit | `src/app.html`, inside `<head>` |
| Angular | `src/index.html`, inside `<head>` |
| Astro | The layout's `<head>`, with `is:inline` |
| Remix and React Router | `app/root.tsx`, inside `<head>` |
| Gatsby | `gatsby-ssr.js`, through `onRenderBody` |
| WordPress | A header scripts plugin, or the Hoot Lens plugin |
| Shopify | `layout/theme.liquid`, just before `</head>`, never on checkout |
| Google Tag Manager | A Custom HTML tag on All Pages |
| Webflow, Squarespace, Wix | The site-wide head code area |

Keep the tag off signed-in, admin and private pages. The exact code for each row is below, and in the dashboard.

Optional attributes:

| Attribute | Purpose |
| --- | --- |
| `data-endpoint` | Override the API origin. |
| `data-release` | Set the release instead of automatic detection. Automatic detection uses the Next.js or Nuxt build id, or the main entry files, so it is the same on every page of a deploy ([details](TRACKER.md)). |
| `data-consent` | `granted` or `denied`, the starting consent state when your server already knows it. Not stored. |
| `data-consent-source` | `onetrust`, `cookiebot`, `gcm` or `hootlens`. Follows that consent platform, or shows Hoot Lens's own "Record my visit" control. |
| `data-consent-category` | The platform category that means "record". Defaults: OneTrust `C0002`, Cookiebot `statistics`, Google Consent Mode `analytics_storage`. |
| `data-path-template` | Records this route label, such as `/pages/:slug`, instead of the real path. Letters, digits and `_ / : . -` only. |
| `data-notice` | `corner` (bottom-left) or `corner-right` shows a small recording notice. Off by default. |
| `data-notice-text` | Replaces the notice wording. Plain text, up to 120 characters. |

## Consent modes and regions

Set the mode in Settings, Consent.

- **Implied**: records on load. Your page can stop it for a visitor.
- **Required**: records only after your page reports consent.
- **Record by default, ask first in these regions**: visitors in a listed region are treated as Required, everyone else as Implied. The default list is the EEA, UK, Switzerland, Washington, Nevada and Connecticut. If a visitor's region can't be told, they are asked first.

Region detection runs on the Hoot Lens API, so your CSP does not change. Global Privacy Control and Do Not Track stop recording in every mode.

## Consent

Hoot Lens owns consent persistence, so your site does not write or maintain a consent bridge.

- **A consent platform.** Add `data-consent-source` to the tag. Recording follows one category, read again on every load, and the platform keeps its own record.
- **Your own banner.** Push `['consent', true]` or `['consent', false]` once, when the visitor chooses. The tracker stores that explicit choice in `localStorage` as `hootlens:consent:<projectId>` with a timestamp and applies it on every later load. Pushing it again on every page still works. `['consent', 'reset']` clears it.
- **Never a consent change:** `['stop']`, `['start']` and navigation, including a client-side language switch, are transient and never change the stored choice. A chat tap that pushes `['stop']` pauses the page and nothing more.
- **No platform at all:** `data-consent-source="hootlens"` adds a small "Record my visit" control, and the recording notice's "Opt out" turns into it.

`Hootlens.status().consentSource` is `visitor` (a stored or reported choice), `cmp` (a platform adapter) or `project` (no decision yet, so the mode's default applies).

### Supported platforms

| Source | What it reads | Notes |
| --- | --- | --- |
| `onetrust` | `OnetrustActiveGroups`, a comma separated string of active category IDs, on load and whenever `OptanonWrapper` runs | Hoot Lens chains any `OptanonWrapper` you already have and checks the string once a second as a backstop if a later script replaces the wrapper. The category defaults to `C0002`; check your tenant, because IDs are configured per account. |
| `cookiebot` | `CookiebotOnAccept`, `CookiebotOnDecline` and `CookiebotOnConsentReady`, then `Cookiebot.consent.<category>` | Reads the category, so Accept with statistics switched off under Customize is not consent. Ignored until `Cookiebot.hasResponse` is true. |
| `gcm` | `gtag('consent', 'default' \| 'update', ...)` entries in `dataLayer`, for `analytics_storage` | Google gives no API to read the state back, so only commands your page pushes to `dataLayer` are seen, existing and later. Region-scoped defaults are skipped, an update beats a default, and changes made only through Tag Manager's own consent API may be invisible. In that case push `['consent', ...]` yourself. |
| `hootlens` | Its own control | Only the visitor's click. |

Official references: [OneTrust client-side cookie management](https://my.onetrust.com/articles/en_US/Knowledge/UUID-518074a1-a6da-81c3-be52-bae7685d9c94) and [Google Tag Manager with OneTrust](https://developer.onetrust.com/onetrust/docs/google-tag-manager), [Cookiebot developer events and properties](https://www.cookiebot.com/en/developer/), and [Google consent mode setup](https://developers.google.com/tag-platform/security/guides/consent).

## Test this browser

Open any page with `?hootlens=debug` on the address, or run `localStorage.setItem('hootlens:debug', '1')`, to show a small panel with the state and reason, consent mode and source, project, whether the page is targeted, the privacy preset and the collector's last answer with its refusal wording. "Copy diagnostics" copies JSON with no page content and no page path. The panel is in a closed shadow root and is left out of replays. Agents can call `['diagnostics', fn]`, or `Hootlens.diagnostics()`, for the same object. The dashboard's Install page describes the same steps.

## Recording notice

`data-notice` shows a small pill in the corner: "This page notes clicks to improve the site." with "Opt out" and a dismiss button. It appears once, only for visitors recorded by default who haven't chosen, and never on Required visitors. Opt out withdraws consent and is remembered in `localStorage` (as the stored consent choice and under `hootlens:notice`). With `data-consent-source="hootlens"` the pill is also the opt-in: after an opt out, and on Required projects before a choice, it shows "Record my visit". You can also turn it on later with `hootlens.push(['notice', true])`. The notice is not recorded.

## Install from the dashboard

Add a site by its address (for example `www.example.com`). Hoot Lens derives the allowed origin from it, and the name defaults to the host. Installation then opens with three steps:

1. **Add the tag.** The exact tag is shown first with a Copy button, with a prompt to give a coding agent and a "Copy as Markdown" guide. Hoot Lens fetches your site's first allowed origin with the page checker and reads the platform from the HTML, then shows only that platform's steps. Detected: Next.js (`/_next/`), Nuxt (`/_nuxt/`), SvelteKit (`/_app/immutable`), Astro (`astro-island`, `/_astro/`), Angular (`ng-version`), Gatsby, Remix and React Router, Vite (React and others), WordPress (`wp-content`), Shopify (`cdn.shopify.com` and theme markers), Webflow, Squarespace, Wix, and Google Tag Manager (`googletagmanager.com/gtm.js`, reported as the platform only when nothing else identifies the site). The API returns `platform: {id, confidence}` (`high`, `medium` or `low`) with the check. A weak or doubtful match is labeled as a guess, and "Different platform?" switches to any other guide. Google Tag Manager, WordPress and Shopify come first, with the source and README of their packaged installs (in `integrations/`) and the manual steps beside them. "Any other site" is the raw tag.
2. **Publish, or run locally.** Hoot Lens looks at your live page again every 30 seconds (up to 10 minutes) and when you choose "Check my site". "Allow localhost for testing" (owner only) adds one exact `http://localhost:PORT` or `http://127.0.0.1:PORT` origin, prefilled with your framework's usual dev port.
3. **Waiting for your first visit.** "Visit your site in a new tab" and "Check my page" help it along, and the live status turns this into "First visit recorded", with a link to the replay.

Problems appear inline only when they occur: a site that is not an allowed origin (with "Add this origin"), required consent, a homepage or page left out of the recorded pages, a Content Security Policy that blocks the script or collector, and a tag with another project's ID.

**Send to a developer.** "Create install link" makes a link to `/install/<token>` that lasts 7 days and can be revoked. The page shows the tag, the steps for the platform you chose and whether the tag has loaded and the first data has arrived. It shows no recordings, settings or owner details. "Copy instructions" copies the same steps as plain text, and "Email instructions" opens a prefilled email.

The tag has no `data-endpoint` when your workspace uses the default collector. Advanced attributes: `data-release`, `data-endpoint`, `data-consent` and `data-notice`.

## Check your install

Open Installation in the dashboard after pasting the tag. Two tools show whether it works.

**Live checklist.** While the install panel is open (and the browser tab is visible) the dashboard asks the API for the project's install status every 5 seconds, backing off up to 60 seconds after errors. It shows:

- Tag loaded: the tracker fetched the project's settings.
- Data received: the collector accepted a batch. A first recording is linked from the panel.
- Origin matches: when a site sends data from an origin that is not registered, the panel names the observed origin and offers "Add this origin", after a confirmation.
- Why nothing is arriving: projects that require consent record only after the page calls the consent command, a sample rate below 100% skips some visits, and Global Privacy Control or Do Not Track stop recording. The API cannot see these decisions because they are made in the visitor's browser, so the panel explains them and does not report them.
- Recent problems: the last 20 refused batches by reason (origin not registered, privacy validation failed (with the rule), privacy settings changed, unreadable payload, time outside the accepted window, recording deleted, recording conflict, page not in your recorded pages, capacity) with a fix for each. No payload content is stored. Batches rejected for size before the project is known are not attributed to a project.

`GET /v1/projects/:projectId/install-status` returns `lastConfigFetchAt`, `lastConfigOrigin`, `lastCollectAt`, `lastCollectOrigin`, `trackerVersion` (only if the tracker sends an `X-Hootlens-Version` header, which it does not yet) and `recentRejections`. It needs the owner or a token with read scope. The API writes these at most once a minute per project and field per API instance, to one small document, never on the intake hot path.

**Page checker.** Enter a page address under "Check a page", or call `POST /v1/projects/:projectId/install-check` with `{"url": "https://www.example.com/"}` (owner only, no access tokens, 10 checks per minute). The API fetches the page and reports whether a `t.js` tag with this project's `data-project` is in the HTML, whether `async` is present, whether the page's Content Security Policy (header or meta tag) allows the script and the API in `script-src` and `connect-src`, and whether the page's origin is registered. A tag in the body of a single-page app shell is fine. A commented-out tag does not count. `POST /v1/projects/:projectId/install-verify` (read scope, so a personal access token or the hosted connector can call it) runs the same check, but only against a page on one of the project's own allowed sites, and adds `tagLoaded`, `dataReceived`, the recent refusals and a `next` step. `GET /v1/projects/:projectId/install-snippet?platform=nextjs` (read scope) returns the exact tag, steps and code for a platform. They are the MCP tools `check_install` and `get_install_snippet`. Tags added by Google Tag Manager are injected by JavaScript and cannot appear in the HTML, so the checker says so and points you to the live checklist.

The checker fetches customer-supplied addresses from the server, so it is restricted: HTTPS on the standard port only (loopback over HTTP or HTTPS in local mode), no credentials in the address, DNS resolved by the API and every returned address checked against private, link-local, loopback, carrier-grade NAT, multicast, documentation and metadata ranges (the connection is pinned to the checked address), redirects followed only within the same host, a 5 second limit, 1 MB read limit, no cookies sent or kept, and no response content returned beyond the findings. A page that needs a sign-in, or blocks automated requests, cannot be checked.

## Choose which pages are recorded

By default every page of your site is recorded, including pages a single-page app opens without a reload. To record only some pages, open Settings, Recording, Pages to record and add rules, one per line:

- **Record only these pages**: when this list has rules, only matching pages are recorded. For a site that should record its public homepage only, enter `/`.
- **Never record these pages**: matching pages are never recorded, and this wins over the list above. For example `/portal/**` and `/account`.

`*` matches within one path segment (`/blog/*`), `**` matches any number of segments (`/app/**` covers `/app` and everything under it but not `/application`), letter case matters and a trailing slash does not. Type any path under "Check a page" to see whether it would be recorded and which rule decided it, before you save.

When a visitor moves to a page that is not recorded, the tracker stops capturing before that page is drawn, sends what it had from the previous recorded page, sends nothing for the excluded page, and starts a new recording when they return to a recorded page. Rules choose pages, not links: a recorded page still shows the addresses of links on it.

Changes reach a visitor on their next page load, usually within about 90 seconds (30 seconds server cache, 60 seconds response cache). In the worst case, when the browser serves a stale copy while it refreshes in the background, it can take about 6.5 minutes. The tracker asks for fresh settings on every page load and starts from its stored copy only until the answer arrives. A change that makes capture stricter, such as excluding a page, applies to the page that is loading. A change that loosens capture applies from the following page load. A page that stays open keeps the settings it loaded with, and the collector refuses and does not store batches for a page you just excluded. Check the Pages to record rules with a test visit after saving.

## Rotating and live content

Hoot Lens starts a new page version when a page's structure or headings change, not when a deploy happens. Parts of a page that look different on every load (a rotating hero or testimonial, a "latest news" block, a personalised greeting, a live count) should not count as changes. Live regions (`aria-live`), `<time>` and elements named carousel, slider or swiper are left out already. For anything else, add `data-hoot-dynamic` to the smallest region that rotates:

```html
<section data-hoot-dynamic>...</section>
```

The attribute carries no value, sends nothing and does not hide the region from replays (use `data-hoot-private` for that). See the [page fingerprint](TRACKER.md#page-fingerprint) for what is read.

## Integration pitfalls

Check these before you call an install done. Each one comes from a real install.

- [ ] **Don't stop recording on language switches.** A language switcher needs no Hoot Lens call. Do not call `Hootlens.stop()` or `setConsent(false)` from it. The tracker reads `<html lang>` when a page view starts, so set the attribute (or navigate to `/es`) when the language changes.
- [ ] **Don't persist automatic stops as opt-outs.** `Hootlens.status()` can report `not-targeted`, `disabled` (`sampled-out`, `gpc`, `dnt`), `paused` or `error` without any choice by the visitor. Do not write those into your own consent storage as "declined". Store only what the visitor chose in your banner.
- [ ] **Group variants instead of hardcoding paths.** Do not list `/`, `/es`, `/ht` or `?experience=` pages one by one in your own code or labels. Leave variants and languages to Hoot Lens: open Settings, Pages, accept the suggested group and check which observed pages each group takes. Add a variant parameter name there if your tests use one we do not read.
- [ ] **Keep banner text consistent with project privacy settings.** Say what the project's preset records. Full page records text and styling with form inputs masked, balanced also removes emails and numbers, strict records layout and clicks only. Change the banner when you change the preset or turn on query strings.
- [ ] **Verify with `Hootlens.status()`.** In the browser console on a live page, run `Hootlens.status()` and expect `state: 'recording'` after consent. Read `reason` for anything else, load a second language and an experiment URL, then confirm the visits under Recordings, filtered by language and variant. See [Read what the tracker is doing](#read-what-the-tracker-is-doing).

## Verification status

Each platform below is built with its own tooling at pinned versions, with the tag copied from this page, and run in headless Chromium on every tracker change and nightly. The checks are that the tracker loads and records a visit with a full snapshot and clicks, that route changes give the right page views, that `consent`, `stop` and `start` behave, that it works under a strict Content Security Policy, and that the page logs no console errors or hydration warnings. [FRAMEWORKS.md](FRAMEWORKS.md) has the versions, dates and limits.

| Platform | Status |
| --- | --- |
| Plain HTML | Tested |
| React with Vite, Vue with Vite | Tested |
| Next.js App Router, Next.js Pages Router | Tested |
| Nuxt, SvelteKit, Angular | Tested |
| Astro, Astro with view transitions | Tested |
| WordPress | Tested in WordPress with the Hoot Lens plugin and with the tag printed in the head. Header and footer plugins are not tested by name. |
| Google Tag Manager | Supported. Automated tests inject the Custom HTML tag and the template the way Tag Manager does. |
| Shopify | Supported. Automated tests render the tag in a Shopify theme layout. |
| Gatsby, Remix and React Router | Supported |
| Webflow, Squarespace, Wix | Supported |
| Consent platform recipes | Documented. The consent commands themselves are tested on every platform above. |

## Command queue

The queue works before or after the script loads:

```js
(window.hootlens = window.hootlens || []).push(['consent', true]);
```

Commands: `consent` (boolean, or `'reset'`), `consentSource` (a source name and an optional category), `release`, `variant`, `pageVersion` (strings), `stop`, `start`, `flush`, `onStatus` and `diagnostics` (functions), and `init` (an options object with `projectId` and optionally `endpoint`, `buildId` and `consent`). `init` exists for the Tag Manager template, which cannot set `data-` attributes on the script it injects. A tag with `data-project` does not need it.

- `['stop']` stops capture until `['start']` or `['consent', true]` is pushed. What was already captured, including the click that triggered the stop, is delivered first.
- `['start']` resumes after a stop, in the same session with a new segment. It does not override a withdrawn consent, and a project that requires consent still waits for it.
- `['consent', false]` stops capture, discards unsent data, clears the tracker's `sessionStorage` entries and stays stopped until `['consent', true]`. It is the visitor's choice, so it is stored and applied on every later load.
- `['consent', 'reset']` forgets the stored choice, so the project's default applies again.
- `['consent', true]` starts or resumes capture, including after a `stop`. After a `consent` false the session is new, because withdrawal clears the stored session.
- `['onStatus', (status) => {...}]` calls the function now with the current status and again on every change. `window.Hootlens.status()` returns the same object synchronously.

Typing the queue: `@hootlens/types` declares `window.hootlens` as a union of these commands, so a mistyped command fails to compile in TypeScript. `eslint-plugin-hootlens` warns about the common mistakes around them, such as setting a page version from an effect. See [Lint and types](TRACKER.md#lint-and-types).

### Read what the tracker is doing

```js
{state, consentMode, consentReason, consent, consentSource, reason}
```

| `state` | Meaning |
| --- | --- |
| `loading` | The tag is loading the project's settings, or nothing has started yet. |
| `waiting-consent` | The project needs consent, or the visitor has not been allowed to record, and none was given. |
| `recording` | Capture is running. |
| `paused` | The project is not accepting recordings (`reason: 'capture-paused'`). |
| `not-targeted` | The current page is outside the pages you chose to record. Capture resumes on a recorded page. |
| `disabled` | The browser or the project turned capture off for this visit (`reason`: `gpc`, `dnt` or `sampled-out`). |
| `stopped` | The host stopped capture, or withdrew consent. |
| `error` | The collector refused the page (`reason: 'collector-rejected'`) or capture could not start. |

`consentMode` is the mode in force for this visitor, `'implied'` or `'required'` (a regional project is resolved from the visitor's region), once the project settings have loaded, otherwise `undefined`. `consentReason` is `'region'`, `'region-unknown'` or `'project'`. `consent` is the decision reported or stored (`true`, `false`) or `undefined` before there is one, and `consentSource` is `visitor`, `cmp` or `project`. `reason` is one of `gpc`, `dnt`, `sampled-out`, `capture-paused`, `not-targeted`, `collector-rejected`, `project-pending` (an agent asked for the project and its owner has not confirmed yet) or `config-unavailable` (the settings could not be loaded, so the strict fallback with required consent is in force). Nothing else about the project's policy is exposed.

### A consent toggle that shows the real state

Draw the toggle from the status, not from your own variable. The status tells the truth when a visitor has a browser privacy signal, when the page is not recorded, or when the project's consent mode makes the decision unnecessary.

```js
window.hootlens = window.hootlens || [];

const toggle = document.querySelector('#analytics-toggle');
const note = document.querySelector('#analytics-note');

toggle.addEventListener('change', () => {
  window.hootlens.push(['consent', toggle.checked]);
});

window.hootlens.push(['onStatus', (status) => {
  const on = status.state === 'recording';
  toggle.checked = on || (status.state === 'not-targeted' && status.consent !== false);
  toggle.disabled = status.state === 'loading' || status.state === 'disabled';
  note.textContent =
    status.state === 'disabled' && (status.reason === 'gpc' || status.reason === 'dnt') ? 'Your browser asks sites not to track, so nothing is recorded.' :
    status.state === 'not-targeted' ? 'Nothing is recorded on this page.' :
    status.state === 'error' ? 'Recording is unavailable.' :
    on ? 'Recording is on.' : 'Recording is off.';
}]);
```

The callback runs once immediately, so the toggle is correct on first paint, and again after every change, including a stop pushed from another script. For a project with `implied` consent the toggle starts on; for `required` consent it starts off until your consent platform pushes `['consent', true]`.

## Plain HTML (tested)

```html
<head>
  <script async src="https://hootlens.com/t.js" data-project="PROJECT_ID"></script>
</head>
```

## React with Vite (tested)

Put the tag in `index.html`. Client-side routes are detected automatically.

```html
<head>
  <script async src="https://hootlens.com/t.js" data-project="PROJECT_ID"></script>
</head>
```

## Vue with Vite (tested)

Same as above, in `index.html`. Vue Router changes are picked up automatically.

## Next.js

The App Router with `<Script>` in the root layout and the Pages Router with a script in `pages/_document.tsx` are both tested, and the App Router setup was also used in a real client migration. The root layout stays mounted while visitors move between routes, so the tag loads once and the tracker keeps recording across client-side navigation, reporting each route as its own page view. To record only some pages, use page targeting, not route gating: a rule such as `/portal/**` under Settings, Recording, Pages to record stops capture on those routes and resumes it when the visitor returns to a recorded one. Rendering the tag on only some routes does not stop a tag that has already loaded.

`app/layout.tsx`:

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

export default function RootLayout({children}: {children: React.ReactNode}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script src="https://hootlens.com/t.js" data-project="PROJECT_ID" strategy="afterInteractive" />
      </body>
    </html>
  );
}
```

Pages Router, `pages/_document.tsx` (tested):

```tsx
import {Html, Head, Main, NextScript} from 'next/document';

export default function Document() {
  return (
    <Html lang="en">
      <Head>
        <script async src="https://hootlens.com/t.js" data-project="PROJECT_ID" />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}
```

## Nuxt (tested)

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  app: {
    head: {
      script: [{src: 'https://hootlens.com/t.js', async: true, 'data-project': 'PROJECT_ID'}],
    },
  },
});
```

## SvelteKit (tested)

`src/app.html`, inside `<head>`:

```html
<script async src="https://hootlens.com/t.js" data-project="PROJECT_ID"></script>
```

## Angular (tested)

`src/index.html`, inside `<head>`:

```html
<script async src="https://hootlens.com/t.js" data-project="PROJECT_ID"></script>
```

## Astro (tested)

In your layout. Astro processes and bundles `<script>` tags, which strips or rewrites attributes, so keep the tag as-is with `is:inline`:

```astro
<head>
  <script is:inline async src="https://hootlens.com/t.js" data-project="PROJECT_ID"></script>
</head>
```

## Gatsby

`gatsby-ssr.js` in the project root:

```jsx
import React from 'react';

export const onRenderBody = ({setHeadComponents}) => {
  setHeadComponents([
    <script key="hootlens" async src="https://hootlens.com/t.js" data-project="PROJECT_ID" />,
  ]);
};
```

## Remix and React Router

`app/root.tsx`, inside `<head>` of the `Layout` component:

```tsx
<script async src="https://hootlens.com/t.js" data-project="PROJECT_ID" />
```

## Google Tag Manager

Use a Custom HTML tag on All Pages (or download the [template](https://hootlens.com/downloads/hootlens-gtm-template.tpl)):

```html
<script>
(function () {
  var s = document.createElement('script');
  s.async = true;
  s.src = 'https://hootlens.com/t.js';
  s.setAttribute('data-project', 'PROJECT_ID');
  document.head.appendChild(s);
})();
</script>
```

Fire it once, on All Pages or Initialization. Firing it again on History Change loads the script a second time, which the tracker ignores, but it is wasted work. A strict Content Security Policy blocks a Custom HTML tag unless it allows inline scripts or carries a nonce; the template injects the script without inline code.

This is tested against a simulated Tag Manager injection: a Custom HTML tag fired at the first page view, on DOM Ready, on Window Loaded and three seconds after load, and the template's own code run with each Tag Manager API it uses and the permissions it declares, including Consent Mode `analytics_storage`. See [FRAMEWORKS.md](FRAMEWORKS.md).

## WordPress (tested)

Install the Hoot Lens plugin ([download](https://hootlens.com/downloads/hootlens-wordpress.zip), then Plugins, Add New, Upload Plugin), then enter the project ID under Settings, Hoot Lens. Or paste the tag into a header-injection plugin; the tag printed from `wp_head` is tested, which is what those plugins do. The plugin is tested in WordPress itself, including its WP Consent API option with the real WP Consent API plugin.

## Shopify

Enable the Hoot Lens app embed under Online Store, Themes, Customize, App embeds. Or paste the tag before `</head>` in `layout/theme.liquid`. The app embed block can be downloaded as [hootlens-shopify-app-embed.zip](https://hootlens.com/downloads/hootlens-shopify-app-embed.zip). Do not add it to checkout.

The app embed block and the manual snippet are rendered with a Liquid engine inside a theme layout and run in a browser, with a stand-in for the Shopify Customer Privacy API. That checks the markup, the escaping, the settings and the consent wiring. It does not check a Shopify store, the theme editor or checkout. See [FRAMEWORKS.md](FRAMEWORKS.md).

## Webflow, Squarespace, Wix

Paste the tag into the site-wide head code area:

- Webflow: Site settings, Custom code, Head code.
- Squarespace: Settings, Advanced, Code Injection, Header.
- Wix: Settings, Custom code, Add code to the head, apply to all pages.

Some plans restrict custom code. Squarespace and Wix editors do not run it in preview, so check the published site.

## npm and ESM

The one-line tag is the supported install. The `@hootlens/tracker` package is not published.

## Consent recipes

Prefer `data-consent-source` (see Consent above). These recipes are only for a platform or banner it does not cover, and they only call the queue. With "required", nothing records until `['consent', true]`. Push when the visitor chooses; Hoot Lens remembers it.

Custom banner:

```js
acceptButton.addEventListener('click', function () {
  (window.hootlens = window.hootlens || []).push(['consent', true]);
});
declineButton.addEventListener('click', function () {
  (window.hootlens = window.hootlens || []).push(['consent', false]);
});
```

OneTrust, Cookiebot and Google Consent Mode:

```html
<script async src="https://hootlens.com/t.js" data-project="PROJECT_ID" data-consent-source="onetrust"></script>
<!-- or data-consent-source="cookiebot", or "gcm"; add data-consent-category to map another category -->
```

## Content Security Policy

Allow the script and the API origin:

```
script-src https://hootlens.com;
connect-src <API origin>;
```

Use your `data-endpoint` value as the API origin if you set one, otherwise the default API origin shown in the dashboard install panel.

Hoot Lens needs nothing else. The tested apps run under `script-src 'self' https://hootlens.com` with that `connect-src` and no console errors where the platform itself allows it (React, Vue, Angular and Astro). Next.js, Nuxt, SvelteKit, WordPress and Tag Manager write inline scripts of their own, so a site on them already needs `'unsafe-inline'` or a nonce policy for that code, with or without Hoot Lens.
