# Script tag

Source: https://docs.mirafive.io/sdks/script-tag

> Add MIRA FIVE analytics, feature flags and page experiments to any website by pasting two lines into the page head.

`@mirafive/tracker` is the hosted script: two lines in the `<head>` of every page, no build step. Use it for WordPress, Webflow, Shopify themes, static HTML and server-rendered templates. For an app you bundle yourself, use the [browser SDK](https://docs.mirafive.io/sdks/browser) or a framework package from the [SDK overview](https://docs.mirafive.io/sdks) instead. On Laravel and Symfony, the server package renders this tag for you: see [Laravel](https://docs.mirafive.io/sdks/laravel) and [Symfony](https://docs.mirafive.io/sdks/symfony).

## Install

Paste this into the `<head>` of every page, usually the shared layout or theme header:

```html
<script>window.mirafive=window.mirafive||function(){(mirafive.q=mirafive.q||[]).push(arguments)}</script>
<script defer src="https://cdn.mirafive.io/mira.js" data-key="mf_…"></script>
```

- The first line is the command queue. It defines `mirafive(…)` at once, so calls made before the script arrives wait in `mirafive.q` and run, in order, when it has loaded. Leave it out only if nothing on the page calls `mirafive(…)` before the script loads.
- The second line loads the loader, `mira.js` (4.31 kB min + gzip). It records a pageview for the landing page and for every client-side navigation.

Load `mira.js` by `src`. It finds its feature chunks from its own URL, so it does not work inlined, as `type="module"` or through `eval`.

The loader is ES2020 (Safari 14 and later). The feature-flag code uses `Object.hasOwn` (Safari 15.4 and Chrome 93 and later). The script has no dependencies.

## Set up

You need the **website key** of a website source (`mf_…`). See [Keys](https://docs.mirafive.io/keys) for where to find it. Put it in `data-key`. The key is public and safe in page source.

If your templates read configuration from the environment, keep the key in `MIRAFIVE_WEBSITE_KEY` and print it into the tag. A literal key in the template works too.

Turn on more features with attributes on the same tag:

```html
<script>window.mirafive=window.mirafive||function(){(mirafive.q=mirafive.q||[]).push(arguments)}</script>
<script defer src="https://cdn.mirafive.io/mira.js" data-key="mf_…" data-autocapture data-flags></script>
```

On/off attributes are on when present and off when absent or set to `off` or `false` (any case). A template can write `data-autocapture="{{ autocapture }}"`. Every attribute is listed in the [API reference](#attributes).

The script starts in [consentless mode](https://docs.mirafive.io/guides/consent#consentless-mode): it sets no cookies, stores nothing on the device, never reads the browser's language, time zone or screen size, and needs no consent banner.

> **Warning:** Never put a secret key in a page. MIRA FIVE refuses a secret key in the batch URL with `403 secret_key_in_path` and marks the key as exposed.

## Verify

1. Open the site on its real domain. Pages on `localhost`, `127.*`, `[::1]`, `*.local` and `file:` send nothing unless you add `data-track-localhost`.
2. In the browser's network tab, check that `mira.js` loads with `200`.
3. Run `mirafive('flush')` in the console. Batches otherwise leave 5 seconds after the first event, after 20 events, or when the tab is hidden.
4. Look for `POST https://events.mirafive.io/v1/batch/mf_…`. It answers `202`:

```json
{ "batch": "…", "accepted": 1, "dropped": 0 }
```

5. Open the source's live view in MIRA FIVE and find the pageview.

Nothing arriving? See [Troubleshooting](#troubleshooting).

## Track events

Call `mirafive('track', name, properties)` anywhere on the page, also before the script has loaded:

```html
<script>
  document.querySelector('#signup').addEventListener('submit', () => {
    mirafive('track', 'signup', { plan: 'pro' })
  })
</script>
```

Names are 1 to 128 characters and cannot start with `$`: those are reserved for events MIRA FIVE sends itself. Property limits and revenue are covered in [Track events](https://docs.mirafive.io/guides/track-events).

To record clicks, form submits and field changes without code, add `data-autocapture`. Each becomes an `$autocapture` event that describes the element (tag, a short selector, id and stable classes, link or button text, the cleaned `href`, `name`, `type`, and `data-testid`, `data-test`, `data-cy`, `data-qa` and `data-track`). It never records what a visitor typed. It skips password, email and hidden inputs and everything inside an element with `data-mira-no-capture`. Autocapture works in both modes.

## Consent

Consentless mode needs no consent call. To collect ids, identify users, run page experiments or target segments, switch the tag to [full mode](https://docs.mirafive.io/guides/consent#full-mode) with `data-mode="full"` and put it behind your consent management platform (CMP).

In full mode, nothing is sent and nothing is stored until the visitor answers. The identity code is not even downloaded. Pass the answer with `mirafive('consent', …)`:

| Answer | Effect |
| --- | --- |
| `true` | Grants statistics only. |
| `{ statistics, experiments, targeting }` | Answers by scope. A scope you leave out keeps its last answer. |
| `false` | Forgets the ids and the user, and clears the queue. |

The landing pageview is sent when consent is first granted, so a visitor who accepts on the first page still counts that page. Wire your CMP's callback to the verb. `statistics` is the usual "analytics" category, `targeting` the "marketing" one:

```html
<script>
  // Cookiebot
  window.addEventListener('CookiebotOnConsentReady', () => {
    const { statistics, preferences, marketing } = Cookiebot.consent
    mirafive('consent', { statistics, experiments: preferences, targeting: marketing })
  })

  // OneTrust (default group ids: C0002 performance, C0003 functional, C0004 targeting)
  window.OptanonWrapper = () => {
    const groups = window.OnetrustActiveGroups || ''
    mirafive('consent', {
      statistics: groups.includes(',C0002,'),
      experiments: groups.includes(',C0003,'),
      targeting: groups.includes(',C0004,'),
    })
  }

  // Your own banner
  document.querySelector('#accept').addEventListener('click', () => mirafive('consent', true))
  document.querySelector('#decline').addEventListener('click', () => mirafive('consent', false))
</script>
```

If the answer is known before the script runs (your CMP stored it, or the server knows it), set it first. The identity code then loads at once and the landing pageview counts as answered on arrival (`$boot: 1`):

```html
<script>window.__mirafive_consent = { statistics: true, experiments: true, targeting: false }</script>
<script>window.mirafive=window.mirafive||function(){(mirafive.q=mirafive.q||[]).push(arguments)}</script>
<script defer src="https://cdn.mirafive.io/mira.js" data-key="mf_…" data-mode="full"></script>
```

Set `window.__mirafive_consent = false` for a stored decline. A `consent` call queued with the snippet before the script ran counts the same way as the pre-set answer.

Full mode stores, in `localStorage` only and never in cookies, `mirafive:{namespace}:aid` (the anonymous id, kept 365 days since last seen), `:sid` (the session, 30 minutes idle) and `:uid` (a hash of the user id). The namespace is the key without its last `_…` part.

In consentless mode, `consent`, `identify` and `reset` do nothing, silently, and `anonymousId` answers `undefined`, so your CMP wiring can stay in place if you switch modes. Do Not Track, Global Privacy Control, `window.__mirafive_ignore = true` (set it for your own visits) and prerendered pages send nothing in either mode. See [Consent](https://docs.mirafive.io/guides/consent).

## Identify users

In full mode, after the visitor granted statistics consent, identify the signed-in user and forget them on logout:

```html
<script>
  // After login
  mirafive('identify', 'user_123', { plan: 'pro' })

  // After logout
  mirafive('reset')
</script>
```

`identify` sends `$identify` and puts the user id on every later event. Ids are 1 to 256 characters. When a different user signs in on the same browser, the anonymous and session ids start fresh.

To link server-side events to the visitor, read the anonymous id. The callback gets `undefined` without statistics consent:

```html
<script>
  mirafive('anonymousId', (id) => {
    document.querySelector('#checkout-form input[name=mira_aid]').value = id ?? ''
  })
</script>
```

In full mode, `identify`, `reset` and `anonymousId` wait until the first consent grant has loaded the identity code, then run in call order. See [Identify users](https://docs.mirafive.io/guides/identify-users).

## Feature flags

Read flags inside a `flags` listener. It runs once flags have loaded, and again whenever they change:

```html
<script>
  mirafive('flags', () => {
    const newCheckout = mirafive('flag', 'new-checkout', false)
    const limits = mirafive('config', 'checkout-limits', { maxItems: 10 })

    document.body.classList.toggle('new-checkout', newCheckout === true)
    document.querySelector('#max-items').textContent = String(limits.maxItems)
  })
</script>
```

- `flag` returns the variant key, or `true`/`false` for an on/off flag.
- `config` returns the remote-config value of the variant.
- `flagProperties` passes facts for targeting rules. They stay in memory and are never sent: `mirafive('flagProperties', { plan: 'pro' })`.

Read outside a listener, `flag` and `config` answer `undefined` before `mira.js` has run, and the fallback until flags have loaded. These reads are not replayed later, because a late read would count an [exposure](https://docs.mirafive.io/guides/experiments) for a value the page never showed.

The flag code loads on the first `flag`, `config`, `flags` or `flagProperties` call. Add `data-flags` to start loading at once. A `<script type="application/json" id="mirafive-flags">` block rendered by a server SDK also loads it, and gives the first paint its answers: see [Feature flags](https://docs.mirafive.io/guides/feature-flags). To preview a variant, open the page with `?mirafive-preview=new-checkout:b`.

## Experiments

Code experiments are flags: read them as above. In full mode, with `statistics` and `experiments` consent, the script counts an exposure for experiments measured in the browser, once per flag and page load.

A page experiment is drawn before the page renders by a head snippet that MIRA FIVE generates for the experiment (full website sources only). Place that snippet in `<head>` above the two lines. Mark the variants in your HTML with `data-mirafive-experiment` and `data-mirafive-variant`, and the snippet's style shows only the drawn one. When the snippet has drawn a variant, the script loads the flag code, and in full mode also the experiments code, which sends the exposure once the visitor grants `experiments` consent. See [Experiments](https://docs.mirafive.io/guides/experiments).

## Single-page apps

Automatic pageviews follow every same-document navigation: the Navigation API where the browser has it, else `pushState`, `replaceState` and `popstate`. The title is read one task after the URL changes, so a router has time to set it. A navigation that leaves the cleaned URL unchanged is not a new view: the cleaned URL keeps only `utm_*`, `ref`, `source` and click-id parameters.

- Hash routing (`/#/pricing`): add `data-hash`. Hash changes become pageviews and the fragment is kept.
- Full control: add `data-manual` and call `mirafive('pageview')` after each route change. Pass `{ url, title, referrer }` to override the current page's values.

For an app you bundle, prefer the [browser SDK](https://docs.mirafive.io/sdks/browser) or a framework package: you get types and ship only what you import.

## What loads when

The loader holds the core, automatic pageviews and the command queue. Everything else is a feature chunk it downloads only when the page needs it:

| File | min + gzip | Loaded when |
| --- | --- | --- |
| `mira.js` | 4.31 kB | Always. |
| `chunks/identity.<hash>.js` | 1.19 kB | `data-mode="full"`, on the first consent grant or a pre-set `window.__mirafive_consent`. A decline loads it only when ids from an earlier visit must be forgotten. |
| `chunks/autocapture.<hash>.js` | 0.92 kB | `data-autocapture`. |
| `chunks/search.<hash>.js` | 0.47 kB | `data-site-search` in full mode, on the first consent grant. |
| `chunks/flags.<hash>.js` | 2.98 kB | `data-flags`, the first `flag`, `config`, `flags` or `flagProperties` call, a `#mirafive-flags` bootstrap block, or a page experiment. |
| `chunks/experiments.<hash>.js` | 0.54 kB | Full mode, when a page experiment snippet has drawn a variant (`window.__mirafive_experiments` is not empty). |

What a page downloads in total:

| Page | Size |
| --- | --- |
| Default: consentless, automatic pageviews | 4.31 kB |
| With `data-autocapture` | 5.23 kB |
| `data-mode="full"`, visitor has not answered or declined | 4.31 kB |
| `data-mode="full"`, visitor consented | 5.50 kB |
| Full mode, consented, with `data-site-search` | 5.97 kB |
| Any page reading flags | + 2.98 kB |
| A full-mode page running a page experiment | + 3.52 kB |

Calls whose chunk has not arrived yet wait and run in call order once it has. Between a consent grant and the identity chunk's arrival, events (pageviews, autocapture, `track`) are held and sent with their original times and the new ids. A `flush` in that window waits for identity too.

## Self-hosting

The same files are on npm. The package contains only `dist/`:

```bash
npm install @mirafive/tracker
```

```text
dist/mira.js                     stable name, changes with every release
dist/mira.<hash>.js              pinned copy of that loader, never changes
dist/chunks/<feature>.<hash>.js  identity, flags, experiments, search, autocapture
dist/manifest.json               { version, loader: { file, integrity }, chunks: { <feature>: { file, integrity } } }
```

Serve `dist/` from any static host, keeping its layout, and point the tag at your copy:

```sh
cp -R node_modules/@mirafive/tracker/dist public/mirafive
```

```html
<script>window.mirafive=window.mirafive||function(){(mirafive.q=mirafive.q||[]).push(arguments)}</script>
<script defer src="/mirafive/mira.js" data-key="mf_…"></script>
```

The loader requests chunks from `chunks/` next to its own URL, with `integrity` and `crossorigin="anonymous"`. Send these headers:

| File | Headers |
| --- | --- |
| `mira.js` | `Cache-Control: public, max-age=3600` |
| `mira.<hash>.js`, `chunks/*` | `Cache-Control: public, max-age=31536000, immutable` |
| Every `.js` file | `Content-Type: text/javascript; charset=utf-8`. From another origin also `Access-Control-Allow-Origin: *`, because chunks are requested in CORS mode for SRI. |
| `manifest.json` | `Cache-Control: no-cache` |

When you update, add the new files and keep the old chunks. A browser that cached the previous `mira.js` for up to an hour still asks for the chunks it names, and pinned loaders ask for theirs forever. Serve the files unchanged: a proxy that rewrites them, or a folder that mixes files of two builds, breaks the integrity check.

To send events through your own domain, point `data-host` at a proxy of `https://events.mirafive.io`, with scheme: `data-host="https://stats.example.com"`.

## Content Security Policy

Allow the script origin and the events host:

```text
script-src  https://cdn.mirafive.io
connect-src https://events.mirafive.io
```

When self-hosting, use your own origin in `script-src`. With `data-host`, use that host in `connect-src`. `connect-src` covers both batches and flags.

- The inline queue snippet needs the page's `nonce` or its hash in `script-src`.
- Under `'strict-dynamic'` the host allowlist is ignored. Give the `mira.js` tag the page's `nonce` too. The chunks it inserts are then allowed through it.

### Subresource Integrity

`mira.js` cannot carry an `integrity` attribute: it changes with every release. For a loader that never changes under you, use the pinned copy. Take `loader.file` and `loader.integrity` (sha384) from `dist/manifest.json` of the version you want:

```html
<script defer src="https://cdn.mirafive.io/mira.<hash>.js" integrity="sha384-…" crossorigin="anonymous" data-key="mf_…"></script>
```

Each loader carries the sha256 digests of its chunks and checks every chunk it loads, so a pinned loader only ever runs the chunks it was built with.

## API reference

### Attributes

The loader reads these attributes of its own `<script>` tag and no others:

| Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| `data-key` | string | none | **Required.** The source's website key (`mf_…`). |
| `data-host` | URL | `https://events.mirafive.io` | Events host, with scheme. For a first-party proxy. |
| `data-mode` | `"full"` | consentless | `full` adds ids and device context after consent, and loads identity on the first grant. |
| `data-hash` | on/off | off | The fragment is the route (`#/pricing`): hash changes are pageviews and the fragment is kept. |
| `data-manual` | on/off | off | No automatic pageviews. Send them with `mirafive('pageview')`. |
| `data-autocapture` | on/off | off | Clicks, submits and changes as `$autocapture`. |
| `data-site-search` | on/off or list | off | Full mode only: `$search` from the `q`, `s`, `search` or `query` URL parameter. A comma list names your own parameters: `data-site-search="term, k"`. |
| `data-flags` | on/off | off | Load the flag code at once instead of on the first flag call. |
| `data-track-localhost` | on/off | off | Also send from `localhost`, `127.*`, `[::1]`, `*.local` and `file:`. |

On/off attributes are on when present and off when absent or set to `off` or `false`, in any case.

### Verbs

Every call is `mirafive(verb, ...args)`:

| Verb | Arguments | Needs | Description |
| --- | --- | --- | --- |
| `track` | `name, properties?` | | Queues an event. Names starting with `$` are reserved. |
| `pageview` | `{ url?, title?, referrer? }?` | | Queues `$pageview` for the current page, or the one given. |
| `flush` | | | Sends the queue now. |
| `consent` | `true \| false \| { statistics?, experiments?, targeting? }` | full mode | `true` grants statistics only; scopes by name; `false` forgets ids and the user and clears the queue. |
| `identify` | `userId, traits?` | full mode | Sends `$identify`, then `userId` on later events. |
| `reset` | | full mode | Forgets ids, user and session. |
| `anonymousId` | `callback` | full mode | Calls `callback(id)`; `id` is `undefined` without statistics consent. |
| `search` | `query` | full mode, `data-site-search` | Queues `$search`. |
| `flag` | `key, fallback` | | Returns the variant, or `true`/`false` for an on/off flag. |
| `config` | `key, fallback` | | Returns the variant's remote-config value. |
| `flags` | `listener` | | Runs `listener` when flags load or change, at once if loaded. Returns an unsubscribe function once the flag code has arrived; a listener queued before that gets none. |
| `flagProperties` | `properties` | | Facts for targeting rules, held in memory and never sent. |

An unknown verb warns in development (on a local host) and does nothing. A call that throws, for example a callback that throws, is skipped with a development warning; the calls after it still run.

### Globals

Set these before the tag:

| Global | Description |
| --- | --- |
| `window.__mirafive_consent` | A consent answer known before the script runs: `{ statistics, experiments, targeting }` or `false`. Full mode only. |
| `window.__mirafive_ignore` | `true` sends nothing from this browser. Set it for your own visits. |

Batches are `text/plain` POSTs to `{host}/v1/batch/{key}`, so they need no CORS preflight. They leave after 20 events or 5 seconds, and by `navigator.sendBeacon` when the page is hidden or closed. They report `mirafive-tracker/1.0.0` as the SDK.

## Troubleshooting

| Symptom | Cause and fix |
| --- | --- |
| Nothing arrives | On a local host, add `data-track-localhost`. Do Not Track, Global Privacy Control or `__mirafive_ignore` is on. With `data-mode="full"`, `mirafive('consent', …)` has not run. Batches wait up to 5 seconds: run `mirafive('flush')`. |
| `[mirafive] no data-key` | The tag has no `data-key`. |
| `[mirafive] bad key` | `data-key` is not a website key: a secret key or a typo. The page still sends. |
| `[mirafive] no script tag` | The loader was inlined, loaded as `type="module"` or run through `eval`. Load it by `src`. |
| `[mirafive] <chunk> chunk failed` | A chunk was blocked by the network, a CSP without the script origin (or without the loader's `nonce` under `'strict-dynamic'`), or an integrity mismatch. The waiting calls are dropped and the chunk is requested again when next needed. |
| "Failed to find a valid digest in the 'integrity' attribute" | A proxy or CDN rewrote a file, or a self-hosted folder mixes files of two builds. Serve `dist/` unchanged. |
| `403 secret_key_in_path` | You pasted a secret key. Use the website key of a website source. |
| `403 origin_not_allowed` | Add the site's origin to the source's allowed origins in MIRA FIVE. |
| `400 collection_mode_not_allowed` | `data-mode="full"` on a consentless source. Switch the source to full mode or remove the attribute. |
| A flag always returns its fallback | It was read before flags loaded: read it inside `mirafive('flags', …)`. Otherwise the flag is not enabled for this source, a segment rule lacks `targeting` consent, or a user flag was read before `identify`. |

The four `[mirafive]` setup messages above appear on every host. Other warnings appear only on local hosts.

## Set up with an AI agent

Paste this into your coding agent:

```text
Add MIRA FIVE analytics to this website with the hosted script tag.
Docs: https://docs.mirafive.io/sdks/script-tag.md

1. Get the source's website key (mf_…). Never put a secret key (MIRAFIVE_SECRET_KEY) in a page.
2. Into the <head> of every page (the shared layout, theme header or base template), add exactly:
     <script>window.mirafive=window.mirafive||function(){(mirafive.q=mirafive.q||[]).push(arguments)}</script>
     <script defer src="https://cdn.mirafive.io/mira.js" data-key="WEBSITE_KEY"></script>
   Print the key from the project's config or env (MIRAFIVE_WEBSITE_KEY) if templates read it;
   the key is public, so a literal is acceptable. Load mira.js by src, never inline it.
   On Laravel use @mirafiveScript from mirafive/sdk-laravel; on Symfony mirafive_script() from mirafive/sdk-symfony.
3. Keep the default consentless mode: it needs no consent banner. Only if the site already has a
   consent manager (CMP) and the owner wants ids: add data-mode="full" to the tag and call
   mirafive('consent', { statistics, experiments, targeting }) from the CMP's consent callback,
   or set window.__mirafive_consent before the tag when the answer is already known.
4. If the site sends a Content-Security-Policy, add https://cdn.mirafive.io to script-src and
   https://events.mirafive.io to connect-src, and give the inline snippet the page's nonce.
   If script-src uses 'strict-dynamic', give the mira.js tag the page's nonce as well.
5. Read feature flags only inside mirafive('flags', () => { … mirafive('flag', key, fallback) … }).
6. Verify: load a page on a non-localhost domain (or add data-track-localhost), run
   mirafive('flush') in the console, and check the network tab for
   POST https://events.mirafive.io/v1/batch/<key> answering 202. Report what you changed.
Do not add other analytics libraries, cookies or consent banners.
```
