# Connect a site you host

> Review a site that needs a backend or a database by adding one script tag: allowed origins, deploy labels, your content security policy and the first deploy.

Source: https://critlab.app/docs/connect-a-site

critlab hosts the files a build produces. A project that needs a server or a database at runtime cannot run there ([What you can publish](/docs/what-you-can-publish)). You can host it yourself and connect it instead: one script tag adds critlab's comments to the live site, and the site appears in your folder as an **external project**. Reviewers open the site where it runs, behind your own login or VPN. critlab keeps the comments, their screenshots and the deploy each one was left on.

> **Free while the beta runs**
>
> External projects are free for every workspace while the free beta runs. Each one counts as a prototype toward the beta limit of 50, and deleting one does not give the slot back. Which plan includes them afterwards is not decided yet: see [When the beta ends](#when-the-beta-ends).

## When to use it

Use it when the project has to keep a backend: Next.js server rendering, API routes, Server Actions, a database, a login, or live data from your own API. If the project can be exported to static files, publish it instead: nothing to install, and every version gets its own link.

## Connect a site

1. Deploy the site somewhere reviewers can reach, such as a staging or preview environment.
2. In critlab, choose **New**, then **Connect a site you host**. Enter the page's address, add a name and a folder if you like, and list any other origin the snippet will run on, such as a preview domain.
3. Copy the script tag critlab shows and paste it into the layout of the site, so it loads on every page you want reviewed.
4. Deploy, then open a page of the site on one of the allowed origins. The critlab bar appears on the page.
5. Send reviewers the project's link in critlab, or the address of the site itself.

An agent can do step 2 with the CLI:

```bash
crit add-url "Acme checkout" https://staging.acme.com/checkout --origin https://preview.acme.com --folder "Acme/Website"
```

The MCP tool is `critlab_add_external_target`. It is in the `workspace` group, which is not loaded by default: see [Choose your tools](/docs/agents/tools).

The link critlab gives you for the project opens a page that says critlab neither hosts nor controls the site, shows the site's host and has an **Open site** button. Comments are left on the site itself.

## The script tag

```html
<script src="https://critlab.app/snippet.js"
        data-critlab-key="sk_..."
        data-branch="main"
        data-commit="9f3c2a1"
        data-env="staging"
        integrity="sha384-..."
        crossorigin="anonymous"
        defer></script>
```

| Attribute | What it does |
| --- | --- |
| `src` | The snippet, served by critlab. Its origin is also where the snippet sends its API calls. |
| `data-critlab-key` | Required. Names the project. It is public: it ships in your page source. |
| `data-branch`, `data-commit` | Which build this page is. Comments are filed under it. |
| `data-env` | The environment, such as `staging` or `preview`. Defaults to `staging`. |
| `data-reviewer` | A name your app already knows, used as the reviewer's name so they are not asked. |
| `data-api-base` | The critlab origin, for a snippet file you host yourself. See below. |
| `integrity`, `crossorigin` | Pin the exact file. See below. |

The tag critlab shows has `$GIT_BRANCH` and `$GIT_COMMIT` as placeholders. Your build has to replace them: critlab does not. Left as they are, every page load reports the literal text `$GIT_BRANCH` as the branch.

`integrity` makes the browser run only the file whose hash you pasted. When critlab ships a new `snippet.js`, the old hash no longer matches and the browser refuses to run it until you paste the new tag. The current hash is in the project's details, under **Site**. Leave out `integrity` and `crossorigin` to always run the current file.

## Allowed origins

The key names the project and authorises nothing. What decides whether a page may start the snippet is the page's origin, checked against the project's list of allowed origins on every page load.

- Origins are exact: scheme, host and port. `https://staging.acme.com` does not allow `https://acme.com` or `http://staging.acme.com`.
- There are no wildcards. Each preview deployment with its own hostname has to be listed, or put the previews behind one stable alias and list that.
- There is always at least one. Creating the project adds the origin of the address you entered.
- A page on another origin gets a 403 and no critlab bar.

An origin is a header a script outside a browser can send, so it authorises a page, never a person. Keep your site behind its own login or VPN, as you would without critlab.

Change the list in the project's details, under **Site** (open the card's menu and choose **Details**), or from a terminal:

```bash
crit site acme-checkout --add-origin https://preview.acme.com
crit site acme-checkout --origin https://staging.acme.com --origin https://preview.acme.com
```

`--add-origin` adds to the list and `--origin` replaces it. The MCP tool is `critlab_update_site`, in the `workspace` group.

## Deploy labels

The first time critlab sees a combination of branch and commit, it records it as a deploy, with the environment the tag reports. Every comment is stored with the deploy it was left on, so a thread says which build it is about. The project's page in critlab lists the deploys, and your agent reads the branch and commit with the thread, so it can check out the right build before it fixes anything.

Fill the attributes from your build, the same place you set any other build-time value. A tag with the same branch and commit on every deploy shows up as one deploy.

## Check the first deploy

1. Open a page of the deployed site on an allowed origin. The critlab bar appears.
2. Open the project's page in critlab. Until a deploy has reported in, it shows **Set up the snippet** with your allowed origins and the tag. Afterwards it lists the deploy.

If the bar does not appear, open the browser's console. The snippet prints one line and shows nothing else:

| Console | Cause |
| --- | --- |
| `snippet did not start: 403` naming an origin | The page's origin is not on the allowed list. Add it. |
| `snippet did not start: 404` | The key is not the project's: a typo, a deleted project or a rotated key. Copy the tag again from **Site**. |
| `snippet did not start: 403` saying the snippet only runs on prototypes with link access | The project's access was changed. Set it back to anyone with the link. |
| `snippet did not start: 403` saying external projects are a Team feature | The workspace's plan does not include external projects. See [When the beta ends](#when-the-beta-ends). |
| `snippet could not reach the API` | The browser could not reach critlab. Check your site's security policy below. |

## Your site's security policy

If your site sends a `Content-Security-Policy`, it has to allow the snippet. These rows were checked in Chrome by loading the snippet, with its `integrity` hash, on a page that sends `default-src 'none'` plus exactly this list, and running a whole review on it: comment mode, a comment with its pin and thread, the screenshot in the thread, a voice note, and the live connection to other reviewers.

| Directive | Allow |
| --- | --- |
| `script-src` | The critlab origin in the tag's `src`. |
| `connect-src` | The critlab origin for the API, and the `wss:` address of the realtime service (the `realtimeUrl` in the first session response, visible in the browser's network panel). |
| `img-src` | The critlab origin, for a comment's screenshot in the thread, and `data:`, which the snippet takes the screenshot through and draws comment mode's pointer from. |
| `media-src` | The critlab origin, if reviewers leave voice notes: a voice note plays from there. |
| `style-src-elem` | `'unsafe-inline'`. See below. |

None of those flows needed `unsafe-eval` or `blob:`; the snippet makes `blob:` images only for pictures attached to a comment, which it does not offer on a host page. Each row matters: without `data:` comments are saved with no screenshot, without the critlab origin in `img-src` the screenshot in the thread does not load, without the realtime address reviewers do not see each other, and without `media-src` a voice note cannot be played. In every case the browser's console names the directive that blocked it.

### Inline styles

The snippet draws its interface from two `<style>` elements its script inserts: one inside its own Shadow DOM, and one small one in your page for comment mode's pointer. A `style-src` governs both exactly as it governs an inline style in your own markup. Shadow DOM is no exemption, and the snippet adds no nonce.

With `style-src 'self'` and nothing for inline styles, the snippet still starts, connects and saves comments and screenshots, but it is **unstyled**: the bar sits in the flow of your page with the browser's default buttons, and comment mode keeps the default pointer. The console shows two `Applying inline style violates the following Content Security Policy directive` errors.

Allowing inline `<style>` elements is enough to fix that, and it can be done without loosening `style-src` itself:

```text
style-src 'self'; style-src-elem 'self' 'unsafe-inline'
```

Style attributes stay refused: the snippet needed none in any of the flows above. `style-src 'self' 'unsafe-inline'` works too and is the blunter row. Hashes are not a practical way out: the console prints them, but the large Shadow DOM stylesheet can change with any release of `snippet.js`, and the hash would stop matching. If your policy cannot allow inline `<style>` elements at all, the snippet stays unstyled on that site today.

## What the snippet sees

- The snippet sends no cookies to critlab, critlab sets none, and the snippet never reads yours. It runs only with link access: a password or members-only project cannot work over it, because that needs a cookie.
- When a reviewer comments, critlab stores a screenshot of the viewport, a crop of the element they clicked, a snapshot of the page's structure with form values removed, the element's selector and text, the page's path and the reviewer's name. The path is kept without its query string.
- When a visitor chooses Follow on someone in the room, the snippet shares the visitor's page, as a path without its query string, and their scroll position with the room, and moves them along with the presenter's path and scroll. Clicks and typed input are never shared. A change of path loads the new page on the follower's side, and the follow ends there: the follower chooses Follow again. Scrolling, hash-router routes and changes that keep the same path follow without a reload.
- The snippet does not read what anyone types. Do not review against real customer data: anything visible on the page can end up in a screenshot. Use staging data.

## Single-page apps

A client-side router that changes the address with `history.pushState` needs nothing: the snippet follows the route. If your code replaces the whole document, call `window.critlab.destroy()` first. It removes the critlab bar and every listener the snippet added, and it is the only function the snippet offers a page.

## Host the snippet yourself

If your policy allows only your own scripts, serve a copy of `snippet.js` from your origin, byte for byte so the `integrity` hash still matches, and tell it where critlab is:

```html
<script src="/vendor/critlab-snippet.js"
        data-critlab-key="sk_..."
        data-api-base="https://critlab.app"
        defer></script>
```

`connect-src` and `img-src` still have to allow the critlab origin, because the API calls and the screenshots do not move.

## Rotate the key

If a key ended up somewhere it should not be, rotate it: **Rotate key** in the project's details, `crit site <slug> --rotate-key`, or `critlab_update_site` with `rotateKey`. Every old script tag stops at its next page load. The project's link and its comments stay. Because the origins are the real gate, a copied key does nothing on a page you did not list.

## When the beta ends

The snippet asks critlab on every page load whether the workspace may use external projects. That is how a downgrade stops it, and it is what will happen to a project on a free workspace on the day the beta ends, unless its plan includes external projects by then. Your site is not affected: the page loads as before and the console shows the line from the table above.

Which plan includes external projects after the beta, and what happens to existing ones on that day, is decided together with pricing. It is not decided yet, and this page will say when it is.

## Self-hosted installs

On a self-hosted install the licence decides, not the beta: the key has to name `staging_snippet`. See [Licence](/docs/self-hosting/license). Everything else on this page is the same, with your own origin in place of critlab's.

## What the snippet does not do

Spotlight, password or members-only access and anchoring into your own nested iframes are not available on a connected site. Images from other sites that do not send CORS headers can show as grey boxes in a screenshot, and a comment is never blocked because its screenshot could not be taken. The [security overview](/docs/enterprise/security) covers the rest.
