---
name: tagsentry-install
description: Install TagSentry (consent banner + event monitoring) on a website with the live REST API. Use when asked to add TagSentry, a cookie/consent banner via TagSentry, or Consent Mode v2 defaults above Google Tag Manager.
---

# Install TagSentry on a site

Two ways in, both live:
- **The MCP server (use this from Claude Code, Claude or Cursor):** `https://app.tagsentry.ai/api/mcp`. It uses
  Streamable HTTP and OAuth sign-in only; API keys don't work there. See step 1.
- **The REST API, for anything else:** `https://app.tagsentry.ai/api/v1` (spec:
  `https://app.tagsentry.ai/api/v1/openapi.json`), with webhooks.

- **The CLI, for any coding agent in a project folder:** `npx @tagsentry/cli --json` signs the human in through
  the browser, detects the framework, places the line and reports in JSON, one object per line. `tagsentry
  status` and `tagsentry undo` exist. Prefer it for Next.js and other React apps: it writes the right code.

The npm `@tagsentry/mcp` package isn't published; use the remote MCP server instead.

The job is one line in `<head>`. A banner appears only when all three are true:
1. the tag is live on the production homepage, in server-rendered HTML,
2. the domain is verified (the live tag itself is the proof),
3. a site scan has completed.
Your install can be correct and still show no banner until 2 and 3 are done. That's expected, so don't "fix" it. The
dashboard's banner editor shows the real banner before the scan finishes. A US visitor in an opt-out state sees only a
"Your Privacy Choices" link, by design. The same line runs event monitoring even when the banner is off; a
monitoring-only site gets no Consent Mode defaults from TagSentry.

## Never
- Never remove, reorder below, or edit the existing Google Tag Manager or gtag.js snippet.
- Never add `async`, `defer`, `type="module"`, or load the tag via JS injection, a tag manager, or `next/script`.
- Never write to, publish, or change settings in a GTM container. (The API can't, and you shouldn't in the GTM UI.)
- Never remove or disable another consent tool (OneTrust, Cookiebot, CookieYes, Osano, Termly, iubenda, Complianz,
  Shopify/Squarespace/Webflow built-in banners) without the human saying so.
- Never print, log, or commit the API key or the `deviceCode`. Never send the `userCode` to anyone except the human
  in front of you.
- Never deploy to production without the human's go-ahead.

## 0. Preflight (read only)
- Identify the stack: Next.js (App or Pages Router), plain HTML, WordPress, Shopify, Webflow, Squarespace, other.
- Find the production domain (bare host, e.g. `example.com`) and confirm it with the human.
- Grep the codebase, and `curl -s https://<domain>/` if the site is hosted, for: `googletagmanager.com/gtm.js`,
  `gtag/js`, other CMP scripts, an existing `tagsentry` tag, and a `Content-Security-Policy`.
- **Stop and ask** if: another CMP is present; the site is not publicly reachable; a strict CSP forbids inline
  handlers; or you can't find a place in the page's `<head>` that renders before GTM.

## 1. Connect: the easiest way first

**In Claude Code (or any client that speaks MCP), use the MCP server.** It's one browser sign-in, with no code to copy and no key to store:

    claude mcp add --transport http tagsentry https://app.tagsentry.ai/api/mcp

Claude Code loads MCP servers when a session starts, so if you added it inside a running session, ask the human
to restart (`exit`, then `claude --continue` in the same folder). Then ask them, in your reply text (not only
inside a tool call), to type `/mcp`, choose **tagsentry**, then **Authenticate**. Their browser opens, they sign in or sign up, pick a business and press Allow. The MCP tools do
everything below: `create_site`, `get_install_snippet` / `get_install_instructions`, `check_verification`,
`start_scan`, `get_site_status`. `update_banner` and `archive_site` only act when called again with
`confirm: true` after the human agrees.

In Claude, use Settings > Connectors > Add custom connector with the same URL. In Cursor, add
`{"mcpServers":{"tagsentry":{"url":"https://app.tagsentry.ai/api/mcp"}}}` to `~/.cursor/mcp.json`.

**No MCP? Use the REST API** with a key, as below.

## 1b. Get a key (REST fallback)
If `TAGSENTRY_API_KEY` is set, use it (a `tsk_test_` key is a real key on real data; the prefix only records which
deployment minted it): `Authorization: Bearer $TAGSENTRY_API_KEY`. Otherwise use device authorization
(unauthenticated, the only unauthenticated calls in the API):

    POST /device-authorizations
    {"clientName":"Claude Code (tagsentry SKILL.md)","projectHint":"<repo dir name>",
     "scopes":["account:read","sites:read","sites:write","install:read","scans:write"]}
    -> 201 {deviceCode, userCode, verificationUrl, verificationUrlComplete, expiresAt, expiresIn, interval}

Show the human the full `verificationUrlComplete` link and the `userCode` **in your reply text** (a tool's output is not visible to them), and open the link if you can. They sign in (or sign up) and
approve. Then poll every `interval` seconds (5 today) until `expiresAt` (10 minutes):

    POST /device-authorizations/token   {"deviceCode":"..."}
    200 {status:"pending"}   -> wait `interval`, poll again
    200 {status:"issued", apiKey, accountId, prefix, scopes, expiresAt}  -> done
    4xx                      -> STOP. Read error.message. 404 = unknown/expired (start over if the human wants),
                                409 = denied or already claimed (ask the human), 429 = wait Retry-After.

`apiKey` is shown exactly once. Store it outside the repo (e.g. `~/.config/tagsentry/key`, mode 600) or in the
shell environment. Never put it in `.env` files that get committed. Device keys expire after 90 days. Only these
scopes can be granted this way: account:read, sites:read, sites:write, install:read, monitoring:read,
consent:read, scans:write, banner:write, trackers:read. Anything else fails the whole request.

Then `GET /account` and tell the human the account `name` ("I'll create the site under <name>, OK?"). If
`onboarded` is false, say the name is a placeholder.

## 2. Find or create the site
`GET /sites`. If a site with this `domain` exists, use its `id`. Otherwise:

    POST /sites  {"domain":"example.com","displayName":"Example","region":"EU"|"US"}   -> 201 {id, ...}

**Ask the human for the region.** It decides where consent records and events are stored, and it can never be
changed. It's storage only: our servers run in the US either way. Suggest the region where most of their visitors
are, and ask once, in the same message where you confirm the domain. A 409 means the account already has this domain: `GET /sites` and use that one.

## 3. Read the install
`GET /sites/{id}/install` -> `{siteTag, snippet, parts, deliveryOrigin, ready, problems, siteKey, region, ...}`

- If `ready` is false, show `problems` to the human and stop.
- **Plain HTML and site builders** (WordPress, Shopify, Webflow, Squarespace): use `siteTag`, one synchronous
  `<script src=... onerror=...>`. If `siteTag` is null, use `snippet`.
- **React and Next.js:** use `parts` (step 4). React can't render `siteTag`'s `onerror` string.
- Copy the bytes exactly. Don't reformat them, and don't drop the `onerror` (it sets denied Consent Mode defaults
  if our CDN fails).
- CSP: the install response's `csp` field lists exactly what to allow, computed from the bytes returned: origins for
  `script-src`, `connect-src` and `img-src`, the hashes, and `style-src 'unsafe-inline'` for the banner. The one-line
  tag's `onerror` needs `'unsafe-hashes'` plus its hash (the same on every site). If the human won't allow that, use
  `snippet` (per-site hashes) instead. Never just drop `onerror`: it is what fails CLOSED if our CDN is down.
- `siteKey` is public. It's fine in client-side env vars.

## 4. Place it: first in <head>, above GTM, synchronous
Rule for every stack: in the served HTML, the TagSentry line comes before any GTM/gtag/pixel script. Only
`<meta charset>`, `<meta viewport>`, and anything that sets the visitor's jurisdiction may come before it.

**Next.js App Router** (`app/layout.tsx`). Do NOT use `next/script` (with `beforeInteractive` it doesn't block
the parser, and other strategies load after hydration). React can't render a string `onerror` attribute, so
don't hand-type `siteTag` into JSX either. Use `parts`, which exists for React root layouts:

```tsx
// app/layout.tsx  (server component). Values come from GET /install `parts`; keep them in a checked-in
// constants file or public env vars (they're public). Order: configJs (if non-empty), bootstrapJs,
// blocker and consentModeJs (if present), tcfStubJs (if non-null), payload last.
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        {TS.configJs ? <script dangerouslySetInnerHTML={{ __html: TS.configJs }} /> : null}
        <script dangerouslySetInnerHTML={{ __html: TS.bootstrapJs }} />
        {TS.blocker && <script src={TS.blocker.url} integrity={TS.blocker.integrity} crossOrigin="anonymous" />}
        {TS.consentModeJs && <script dangerouslySetInnerHTML={{ __html: TS.consentModeJs }} />}
        {TS.tcfStubJs && <script dangerouslySetInnerHTML={{ __html: TS.tcfStubJs }} />}
        {TS.payload.kind === "tag"
          ? <script async src={TS.payload.url} integrity={TS.payload.integrity} crossOrigin="anonymous" />
          : <script dangerouslySetInnerHTML={{ __html: TS.payload.js }} />}
        {/* existing GTM snippet stays here, BELOW */}
      </head>
      <body>{children}</body>
    </html>
  );
}
```
The payload is the only async piece, and `parts` says it must be. The inline bootstrap makes no network request.
If GTM comes from `@next/third-parties` `<GoogleTagManager>`, leave it as it is. It loads after hydration, so it's
already after TagSentry. Check the result with `curl -s https://<domain>/ | head -c 4000`: the bootstrap must be
in the HTML, before `gtm.js`.
Always confirm in the served HTML (not the React tree) that the bootstrap and blocker scripts are present, come before
`gtm.js`, and carry no `async` or `defer`. If React has moved or changed them, don't ship it: stop and tell the
human, and email support@tagsentry.ai with what you saw.

**Next.js Pages Router**: same `parts` approach, in `pages/_document.tsx` inside `<Head>` as the first children.
Don't use `next/script`.

**Plain HTML / static generators**: paste `siteTag` as the first element after `<meta charset>` (and viewport) in
every page template's `<head>`, above the GTM `<script>`. Leave GTM's `<noscript>` iframe in `<body>` alone.

**WordPress**: add it to the child theme (never the parent theme) with
`add_action('wp_head', fn() => print TAGSENTRY_TAG, -1000);` or with a header-code plugin at its earliest priority,
so it prints before Site Kit, GTM4WP, or any other plugin's wp_head output. Excluding it from the optimisers is
mandatory: add it to the "exclude" lists for WP Rocket (Delay/Defer JS), Autoptimize, LiteSpeed, SiteGround
Optimizer, and Cloudflare Rocket Loader (or add `data-cfasync="false"`). Otherwise they make it async. If
Complianz, CookieYes, or Cookiebot plugins are active, stop and ask.

**Shopify**: Online Store > Themes > Edit code > `layout/theme.liquid`. Put `siteTag` right after `<head>` and
before `{{ content_for_header }}` and any GTM. Duplicate the theme first and tell the human. Checkout pages and
Shopify's own Customer Privacy banner are outside this. If Shopify's banner is on, ask. Shopify's own Customer Privacy banner is separate from TagSentry: if it is on, ask the human which one stays.

**Webflow**: Site settings > Custom code > Head code. Put `siteTag` at the top of that box, above any GTM already
there. Webflow puts this box after its own head tags, which is fine as long as nothing tracking comes first. If the
site uses Webflow's built-in Google Analytics/Tag field in Integrations, tell the human it loads outside this order.
Publish is the human's call. Custom code needs a paid site plan.

**Squarespace**: Settings > Developer tools (older: Advanced) > Code Injection > Header, at the top, above GTM.
It needs a plan that allows code injection. Squarespace's built-in cookie banner and its GA/Pixel integrations are
separate. Ask before touching them.

**GTM present (any stack)**: the GTM snippet stays exactly as it is, directly after TagSentry. Don't add
consent-mode tags or templates in the container, and don't publish a version. Tag changes are proposed in the
TagSentry dashboard and approved by a person there.

## 5. Deploy, then verify
Ask the human to deploy to production (or deploy if they say so). Localhost, previews behind auth, and
password-protected staging can't be verified or scanned.

1. `curl -s https://<domain>/` and confirm the tag (or bootstrap) appears in the raw HTML before `gtm.js`. A tag
   injected only by client JS doesn't count.
2. `POST /sites/{id}/verification/checks` with `{}`. Every outcome is 200 with `status`:
   `verified` / `already_verified` -> go on; `not_found` -> the tag isn't in the served HTML yet (CDN cache?);
   `unreachable` -> we couldn't fetch the site, so fix that first. Limit: 10 full checks per site per hour; a 429
   carries `retry-after`. Don't poll faster than once a minute.
   Pasting the tag is normally the proof on its own (method `site_code`): a background homepage check runs every
   10 minutes for the first hour, then hourly, then daily for 30 days, so you don't need to call anything. The check
   endpoint just asks for a look now. `GET /sites/{id}/verification` returns `method` and a `methods` list. If it won't verify, `GET /sites/{id}/verification`
   gives a `metaTag.html` line and a `dnsTxt` record. Ask before adding either.
3. `GET /sites/{id}/scans/latest`. If `status` is `none` or `failed`, run `POST /sites/{id}/scans`
   (202; `started:false` means one is already running, which counts as success). Poll `scans/latest` every 30 to 60
   seconds. **Read `everSucceeded`, not `status`.** `partial` doesn't count.
4. `GET /sites/{id}/status`. Read `nextAction` first. You're done when `verified`, `rulesetPublished`,
   `snippetInstalled` are true and `everSucceeded` is true. `monitoringSignalSeen:false` is NOT a failure. It
   stays false until a visitor grants analytics.
5. Report to the human: site id, region, where you put the tag (file and line), the check outcomes, and
   `nextAction`. Tell them to open the site in a private window to see the banner. Point them to the dashboard for
   the banner wording and the GTM consent review.

## Errors, limits, truncation
- Every non-2xx is `{error:{code,message,requestId,detail?}}`. Codes: unauthorized, forbidden, domain_not_verified,
  not_found, invalid_request, conflict, rate_limited, not_implemented, internal. Quote `requestId` to the human.
- 401: bad or expired key (the API won't say which). Re-run device authorization if the human agrees.
  403 `forbidden`: missing scope, so say which. 403 `domain_not_verified`: finish step 5.2.
  404: wrong site id, or it belongs to another account.
- Rate limits are per key and per account per minute (plus banner writes per site). 429 names the budget and sends
  `Retry-After`. Wait that long; don't retry sooner. Today: 120 per key, 300 per account and 10 banner writes per site, per
  minute.
- Every list response has `truncated`. If it's true, the list is incomplete, so don't act on it as if it were
  complete.
- 500: stop, and give the human the `requestId`.

## Stop and ask the human when
another CMP is present · the region choice · the account name looks wrong · `ready:false` · strict CSP · the site
isn't public · a deploy is needed · verification returns `unreachable`, or `not_found` twice after a confirmed
deploy · any 409 on device authorization · any 5xx · anything would change GTM.

## Remove
Delete the line (or the `parts` block) and redeploy. Leave GTM as it is. To close the site in TagSentry too, ask the
human, then `POST /sites/{id}/archive` (scope `sites:write`, idempotent). A site on a paid plan needs a key an
owner minted. Consent records are kept for their 24 months either way; archiving never deletes them.

