---
name: deacon-install
description: Install the Deacon support widget on a website or web app: place one script tag on every page, tell Deacon who is signed in, and confirm from the command line that a real page has loaded it. Use when asked to install Deacon, add its snippet or embed code, or set it up on a site.
---

# Install Deacon

Deacon is added to a site once: one script tag, on every page. How it looks is set on the Appearance page of the dashboard and updates on its own, so the tag never changes after it is placed. This is the dashboard's Install page as a document, for a coding agent doing the work.

Work through the steps in order. Steps 1 and 2 are the install; step 3 is the one a founder pasting the tag by hand almost never does, and it is what makes Deacon answer for the person in front of it rather than in general.

## 1. The domains

On the dashboard's Install page, the founder lists the domains the widget may run on. The widget appears on every page of those domains and nowhere else, and the first domain generates the site's snippet. No `https://` is needed, www and non-www match as one site, and localhost can be added for local testing. On a domain that is not listed the widget stays hidden, although the loader has set its visitor cookie by then.

This step is the founder's, on the dashboard. The prompt you were given lists the domains as they stand. If the origin this app runs on is not among them, carry on with the install and say so at the end: it is one field on the Install page, and nothing you can do from here.

## 2. The snippet

Paste the snippet just before the closing `</body>` tag on every page the widget should appear on. The founder copies it from the Install page with the site's own key in `data-key`; the key below is a placeholder.

```html
<script src="https://heydeacon.com/widget/v1/loader.js" data-key="pk_your_widget_key" async></script>
```

It loads in the background and never blocks the page. Anywhere HTML can be added takes it as it is: Shopify's `theme.liquid`, WordPress through a header-and-footer plugin, Webflow's or Framer's custom code, or a bare template.

In an app with a layout, the tag goes in the layout so it runs once for every page:

- Laravel: `resources/views/app.blade.php` or `layouts/app.blade.php`, before `</body>`.
- Next.js: the root layout, `app/layout.tsx`, as a plain script tag in the body or through `next/script` with the `afterInteractive` strategy.
- Rails: `app/views/layouts/application.html.erb`, before `</body>`.
- Any single-page app: the one HTML file that hosts it. The loader counts client-side navigations itself.

On an app built with one of the AI builders, the tag goes in the same place, and two of them document the route themselves:

- Lovable: paste the snippet into the project chat and ask Lovable to place it, which is what its own guide on embedding third-party widgets says to do. The code editor is read-only on the free plan, so on a free project the chat or the Git sync is the way in.
- Bolt: open Code view and paste it before `</body>`, or paste it into the chatbox and ask Bolt to add it. Its hosting documentation names both routes for third-party scripts, and leaves the location to the script's author. Check the published site rather than the preview. The preview runs at an address of its own, inside the browser on Chrome and hosted by Bolt on Safari, and the widget stays hidden on any address that is not in the allowed domains.
- v0: the root layout, `app/layout.tsx`, or `src/app/layout.tsx` in a project that keeps its code in a `src` folder. A plain script tag in the body, or `next/script` with the `afterInteractive` strategy. v0 does not document third-party scripts, so this is our instruction rather than theirs.
- Replit: the HTML file the deployment serves, or the layout if the app has one. Replit does not document third-party scripts either. If the project followed Replit's security checklist it may carry a `default-src 'self'` content security policy. That blocks the loader outright, and widening it takes four directives rather than one. `script-src`, `connect-src` and `frame-src` each need Deacon's origin, `https://heydeacon.com`, beside `'self'`: the first for the loader, the second for the settings request and the page, goal and error beacons, the third for the chat frame. `style-src` needs `'unsafe-inline'` beside `'self'`, for the style sheet the loader writes into its shadow root; a nonce cannot reach that sheet, and a hash would break with the next loader release. The replies stream inside the frame, under Deacon's own policy, so they need nothing here.

On Lovable and Bolt a custom domain is a paid feature, so a free project is published on a generated `lovable.app` or `bolt.host` subdomain. That generated address is the one that has to be in the allowed domains, or the widget stays hidden.

Never write the key anywhere else, and never load the script twice.

## 3. Tell Deacon who is asking

Deacon reads `window.deaconContext` when the page loads, and answers for that visitor instead of generically: it knows their plan, stops asking what the app already knows, and stops offering things they already have.

Do this whenever the app has signed-in users. Skip it only on a site with no accounts at all, such as a plain marketing page.

```html
<script>
  window.deaconContext = {
    plan: 'Pro',
    state: 'active',
    role: 'owner',
    seats: 4,
    trial_ends: '12 Aug',
  };
</script>
```

The block goes **above** the snippet, in the same layout, because the loader reads it as the widget is built. Up to 12 keys; anything past the twelfth is dropped, as is a context over 1500 characters of JSON once serialized. Values should be strings, numbers or booleans.

Take the values from the session the app already has, not from a new request:

**Laravel.** In the layout, from the authenticated user. Use `@json` so the values are escaped rather than concatenated into the script:

```blade
@auth
    <script>
        window.deaconContext = @json([
            'plan' => auth()->user()->currentTeam?->plan_name,
            'state' => auth()->user()->currentTeam?->subscriptionState(),
            'role' => auth()->user()->role,
        ]);
    </script>
@endauth
```

`Auth::user()` and the `auth()` helper are the same thing; use whichever this codebase already uses. If the app puts this on a view composer or a shared Inertia prop, read it from there instead of querying again in the layout.

**Next.js.** In the root layout, which is a server component, from whatever session helper the app already uses — `auth()` with NextAuth, `getSession()`, a cookie-backed helper of its own:

```tsx
const session = await auth();

return (
  <html>
    <body>
      {children}
      {session?.user && (
        <script
          dangerouslySetInnerHTML={{
            __html: `window.deaconContext = ${JSON.stringify({
              plan: session.user.plan,
              state: session.user.subscriptionState,
            }).replace(/</g, '\\u003c')}`,
          }}
        />
      )}
      <script src="…" data-key="…" async />
    </body>
  </html>
);
```

`JSON.stringify` alone does not keep a user-supplied value from closing the script tag: it leaves `</script>` in a string as it is. The `.replace(/</g, '\\u003c')` after it does, and the browser still reads the same values. Never interpolate a raw string into that block.

**Rails.** In `application.html.erb`, from `current_user`:

```erb
<% if current_user %>
  <script>
    window.deaconContext = <%= raw({
      plan: current_user.plan,
      state: current_user.subscription_state,
      role: current_user.role
    }.to_json) %>;
  </script>
<% end %>
```

**Anything else.** Render the same object server-side from the session, above the snippet.

Send what helps answer a support question — plan, subscription state, role, seat count, trial end, whether onboarding is finished. Send **never a password, a token, an API key or an internal id**: the context is emitted into the page's HTML, so the visitor can read every value in it, and so can anyone they share a screenshot with. It arrives from the visitor's browser, so Deacon treats it as a hint rather than proof, and you should too.

When something changes without a page load — a sign-in, an upgrade, a plan switch in a single-page app — merge over it from code:

```js
window.Deacon.setContext({ plan: 'Team', state: 'active' });
```

`Deacon.setContext` merges into what is already there. A key set to `null` is removed, and `setContext(null)` clears the lot, which is what to call on sign-out.

## 4. Open it from your own button

```html
<button onclick="Deacon.open()">Ask a question</button>
```

`Deacon.open()`, `Deacon.close()` and `Deacon.toggle()` are available as soon as the script has run.

With the Help button hidden on the Appearance page, Deacon paints nothing at all, and a button like this is the only way in. **If the prompt you were given says the Help button is hidden, adding one is not optional** — without it the install looks like it failed. Put it where this app's users already look for help: an item in the account or help menu, a link in the footer, a button at the bottom of an empty state.

## 5. Check it went live

A sighting means a real page loaded the snippet. Ask for it here:

```
curl -s "https://heydeacon.com/api/widget/seen?k=SITE_KEY"
```

Put the site key in place of `SITE_KEY`; if you were given a prompt, it names the key and carries this address with the key already in it. Before anything has loaded:

```json
{"seen": null, "latest": null}
```

Afterwards:

```json
{"seen": {"origin": "https://acme.com", "lastSeenAt": "2026-09-06T14:02:10+00:00", "local": false, "fresh": true},
 "latest": {"origin": "http://localhost:3000", "lastSeenAt": "2026-09-08T09:14:22+00:00", "local": true}}
```

Two answers, because there are two questions. `seen` is the one the founder's Install page asks — are you live — so it prefers a real deployment and keeps reporting it once one has been heard from. `latest` is the one you are asking: the newest sighting whatever its origin, which is the only one that moves when you install into a dev environment on a site that is already live.

`local` is true for localhost and 127.0.0.1, which is a working install rather than a launch. `fresh` is false when nothing has been heard for a day, or when the founder has removed every domain.

**Ask once before you start, and keep the answer.** A site that has been running Deacon already answers straight away with its own sighting, so a non-null first call is evidence of nothing you did. What you are waiting for is `latest` changing.

You almost certainly have no browser, and a sighting cannot be faked, so the usual path is:

1. Call the address once. Note `latest.lastSeenAt`, or that `latest` is null.
2. Start the app's dev server.
3. Ask the founder to open one of its pages in their browser, and wait.
4. Poll every few seconds until `latest` stops being null, or `latest.lastSeenAt` moves past the one you noted. Give it a couple of minutes before concluding something is wrong.

If you do have a headless browser, load a page of the running app yourself and skip the asking.

**Never request the config endpoint by hand to make a sighting appear.** A sighting is the evidence that the tag is on a page that really loads; a request you make yourself proves only that you can make requests.

If nothing arrives: the page you loaded is not on a listed domain, the tag is not in a layout that page uses, the key is wrong, or the dev server is serving a cached build.

## Tell the founder what you did

End with a short report, and be specific:

- Which files you changed, and where the tag went.
- What you put in `window.deaconContext`, and where those values come from.
- Whether you added a button of your own, and where it is.
- Where the widget was seen — the origin under `latest`, which is the page you caused to load. If `latest.local` was true, say so plainly: **seen on localhost — add your real domain on the Install page when you deploy**, and name the domain to add.
- If the app's origin was not in the allowed domains, say which one they need to add. Only they can do it.

Then point them at the next step: to count sign ups, paywall hits and upgrades on their dashboard, follow https://heydeacon.com/agent/goals.md.

## Page views, with no further step

Once the snippet is on a site, Deacon counts page views, visits, sources, pages, countries and devices on Analytics › Website. Page views are counted without a cookie, from a hash that changes every day, and no IP address is kept for them. The widget itself sets one cookie, `deacon_visitor_id`, when it loads, and keeps the same id in local storage, so list it in the site's cookie policy. Nothing is counted from a development address, from automated browsers or from a domain that is not listed.

To keep pages out of the count — an app's signed-in routes, say, so the founder's own clicking is not traffic — add `data-no-page-views` to the script tag on those pages. It stops page views and JavaScript errors, and never stops a goal: a goal only fires because the site asked for it by name, and the moments most worth counting happen behind a login. The attribute is read afresh for every page view, not once when the script loads, so in a single-page app it has to be kept in step with the router rather than set at the start:

```js
// Wherever the app handles a route change.
document
  .querySelector('script[src*="/widget/v1/loader.js"]')
  ?.toggleAttribute('data-no-page-views', isSignedInRoute(path));
```

Miss that in a single-page app and the attribute describes only whichever page the visitor loaded first.
