# Add a hiy.ai chat to a website

You are adding one chat script to the owner's site. Change nothing else about the site. The owner's prompt gives you this agent's snippet, form, slug and door, the sites it answers on, and whether calls or a visitor token are involved. The same steps are the `hiy-install` agent skill: https://hiy.ai/skills/hiy-install/SKILL.md

## 1. Look first

- Find the stack: framework, router, shared layouts, and where security headers are set (proxy.ts or middleware, framework config, vercel.json, netlify.toml, _headers, server config, or a meta tag).
- Search the repo for `embed.js` and `data-twin`. If a script with this agent's `data-twin` and the same `data-mode` is already installed, replace it; never add a second copy. Leave this agent's other forms (another `data-mode`) alone. If another chat widget is installed, stop and ask the owner.
- If an old install passed a visitor token (`data-token` or `data-token-url`) from this site's own server code, and the snippet you were given has neither, remove that route or helper, its cookie, and its variable names in example env files. List them in your report, and tell the owner they can delete the secret from their hosting.
- Write a short plan (stack, files, pages, header lines, checks), then go ahead.

## 2. The snippet

Copy the snippet from the owner's prompt exactly. Don't retype, reorder or drop an attribute: the ids and the look come from the owner's dashboard, and a change there means copying a new snippet.

Where it goes depends on the form:
- Chat widget (`data-mode="bubble"`) and floating chat bar (`data-mode="bar"`): the site's public pages (home, marketing, pricing, help, docs, blog). Not sign-in, account or signed-in app pages, unless the owner asked for them.
- Chat block in the page (no `data-mode`): the one place the owner asked for. If they didn't say, ask.

## 3. Load it the right way

- Plain HTML or server templates: a real `<script>` tag just before `</body>` in the layout those pages share. If the Content-Security-Policy uses `'strict-dynamic'`, put this request's nonce on the tag (`nonce="…"`).
- Next.js: `next/script` with `strategy="lazyOnload"` in a layout that wraps those pages and stays mounted, every attribute copied, plus `nonce={nonce}` when the site sets one. `next/script` loads one script per `src`, so add any second snippet on the same page from an effect.
- React, Vue or another single-page app: add it from an effect. First do nothing if `document.querySelector('script[data-twin="<slug>"][data-mode="<mode>"]')` finds one, so development mode's double run or a remount can't add it twice. Copy every attribute onto the element.
- Chat block in the page: the block appears right after its own tag, so put the tag inside the element where the chat belongs. In React or Next.js, append the script to that element from an effect, after checking `script[data-twin="<slug>"]:not([data-mode])` isn't already inside it. Don't use `next/script`: it appends to `<body>`.
- Keep it a classic script: not `type="module"`, not an import. It reads its settings from its own tag.
- It stays until a full page load, client-side navigation included. To keep it off some pages (sign-in, the app), or while it covers something, hide it with `[data-hiy-part]{display:none !important}`, for example under a class that area's layout sets on `<body>`. The attribute is on every element it adds, its style tags included. Never remove its elements or listeners by hand.
- WordPress, Shopify and strict Content-Security-Policy sites also have a stack note below.

## 4. Security headers

Only if the site sends them. Add to the policy it already sends; never send a second policy.
- Content-Security-Policy: add `script-src https://hiy.ai`. If `script-src` has `'strict-dynamic'`, browsers ignore that host: the tag needs this request's nonce, or must be added by code that already runs under it (section 3). Keep the host for older browsers. Never add `'unsafe-inline'`.
- Content-Security-Policy: add `frame-src https://hiy.ai`. With no `frame-src`, add one holding the sources the browser fell back to (`child-src`, else `default-src`) plus `https://hiy.ai`. Don't widen `default-src`.
- Chat widget only, `style-src`: no change when it allows `'unsafe-inline'` and lists no nonce or hash, or when it carries the same nonce as `script-src`. Otherwise the chat works but its button may draw wrongly: tell the owner; don't loosen it yourself.
- Nothing in `connect-src`, `img-src` or `font-src`.
- If the site sends `require-trusted-types-for` or `Cross-Origin-Embedder-Policy`, stop and tell the owner.
- Never serve or proxy `embed.js` from this site: it builds the chat's address from where it was loaded, and visitors must get the current version.

## 5. Phones

At 390px wide, open each page you changed.
- Chat widget: the 56px button sits fixed near its corner, above everything. If it covers a fixed or sticky control (a cookie banner, a bottom nav, an open menu's buttons), hide the chat while that control shows (section 3) and say so in your report.
- Floating chat bar: it sits fixed along the bottom edge, above everything. The same rule applies.
- Chat block in the page: the block fills its container. Check the page doesn't scroll sideways: `document.documentElement.scrollWidth` equals the viewport width.

## 6. Where it answers

For an agent anyone on the listed sites can open (your prompt has no visitor token line):
- Only on `https://` pages of the sites in the owner's prompt. A listed domain covers its `www.` and other subdomains; a listed `www.` host covers only itself.
- On `localhost`, previews and other unlisted hosts the widget's button still shows. Opened, it says "This agent isn’t available right now." and hides once closed. The bar and the block hide themselves. That is expected; don't change code for it.
- On the live site, if it says the same, no code change fixes it. Ask the owner to check on the Install screen that the agent is Live, this site is listed, and Who can open it is Anyone on your sites.

## 7. For calls

Only when the owner's prompt says calls are on. If the site's Permissions-Policy names `microphone` at all, its list must include `"https://hiy.ai"`, for example `microphone=(self "https://hiy.ai")`. Both `microphone=()` and `microphone=(self)` block calls. With no `microphone` entry, change nothing.

## 8. Signed-in users

Only when the owner's prompt says the agent needs a visitor token. The snippet then carries `data-token-url`. Build the route that mints the token for this site's framework, as the stack note "Signed-in users" describes. Put the snippet on the pages the owner named; if they didn't say, ask.
- Only on the listed sites and their subdomains. For local work the owner can add `localhost` to Allowed websites.
- "This agent isn’t enabled for this website.": the owner adds this host under Allowed websites on the Install screen.
- "This agent needs a valid visitor token." on a listed site: the token's `aud` must equal `location.origin` exactly.

## 9. Check

- Build or render the chosen pages: the script is there once, attributes unchanged, and not on other pages.
- After deploy, on the live site: the console shows no Content-Security-Policy or Permissions-Policy error naming `https://hiy.ai`. Open the chat and ask one question. The Install screen then shows Live.
- For a visitor token: `curl -i https://<this site>/api/hiy-token` answers 403; the page's own request answers `{ "token": … }`.

## 10. Report

Stack, files changed and removed, header lines added, pages that get the chat, anything you couldn't check. Remind the owner that visitors' messages are processed by hiy.ai, which their privacy policy may need to name.

## Stack note: Plain HTML and server templates

- Put the tag just before `</body>` in the layout or partial the public pages share (base template, footer include, static-site layout). One tag per page.
- Behind a nonce-based Content-Security-Policy, print this request's nonce on the tag. See the "Strict Content-Security-Policy" note.
- A site built into static files has no request to carry a nonce: if it has a nonce policy, tell the owner, and don't loosen the policy yourself.

## Stack note: Next.js

- App Router: render `next/script` in the layout of the public pages (a route group such as `app/(site)/layout.tsx`), not in a page, so it mounts once and stays. Pages Router: `pages/_app.tsx`.
- Copy every attribute from the snippet as props: `<Script src="https://hiy.ai/embed.js" data-twin="…" data-door="…" data-mode="…" strategy="lazyOnload" />`. Next passes `data-*` props through.
- `next/script` loads one script per `src`. A page with two forms (a widget and a bar) adds the second from an effect.
- With a nonce policy, read the nonce your middleware or proxy sets (for example from `headers()`) and pass `nonce={nonce}`. The layout then renders on every request.
- To keep the chat off the signed-in app, set a class on `<body>` in that area's layout and hide it with `[data-hiy-part]` (see section 3).

## Stack note: React, Vue and other single-page apps

Add the script once from an effect in a component that stays mounted. A guard stops development mode's double run and remounts from adding a second copy:

```js
useEffect(() => {
  const selector = 'script[data-twin="<slug>"][data-mode="<mode>"]';
  if (document.querySelector(selector)) return;
  const tag = document.createElement("script");
  tag.src = "https://hiy.ai/embed.js";
  tag.async = true;
  tag.dataset.twin = "<slug>"; // …then every other data-* attribute from the snippet, as written
  document.body.appendChild(tag);
}, []);
```

- Don't remove the script or its elements when the component unmounts. The chat stays until a full page load; hide it with `[data-hiy-part]` where it shouldn't show.
- For the chat block in the page, append the script to the element where the chat belongs, and check `script[data-twin="<slug>"]:not([data-mode])` inside it instead.
- Put the page's nonce on the element (`tag.nonce = …`) if the site uses a nonce policy.

## Stack note: WordPress

- Easiest: a header and footer plugin (such as WPCode). Paste the snippet in its Footer box and save.
- In a theme, print the tag from `footer.php` just before `wp_footer()`, or from a `wp_footer` action. `wp_enqueue_script` drops `data-*` attributes unless a `script_loader_tag` filter adds them back, so don't enqueue it.
- A page-builder or cache plugin that combines or delays scripts can break the tag: exclude `embed.js` from it.
- On WordPress.com the plan must allow custom code.

## Stack note: Shopify

- Online Store › Themes › Edit code › `layout/theme.liquid`: paste the snippet just before `</body>`. Every storefront page shares that layout.
- Checkout pages don't use `theme.liquid`, so the chat doesn't show there.
- A theme can't change the store's response headers. Add header lines only if the store sends a Content-Security-Policy through an app or a proxy.

## Stack note: Strict Content-Security-Policy

- With `'strict-dynamic'`, browsers ignore host entries in `script-src`. A script tag in the page's HTML needs this request's nonce (`nonce="…"`); a script added from code that already runs under the policy is trusted.
- The chat widget adds up to two small `<style>` tags. It copies the nonce from its own script tag (or, failing that, from the first `script[nonce]` on the page) onto them, so a `style-src` that carries the same nonce works with no other change.
- A `style-src` with a hash, or a nonce that isn't on the script tag, leaves the button drawn wrongly: tell the owner. Never add `'unsafe-inline'` to `script-src`.
- `frame-src` falls back to `child-src`, then `default-src`. Add `https://hiy.ai` to the first of those the site has.
- A `Permissions-Policy` only matters for calls (see "For calls").

## Stack note: Signed-in users

The snippet's `data-token-url="/api/hiy-token"` points at a route on THIS site that mints a short-lived visitor token. Build it for this site's framework with exactly this behaviour:
- Refuse unless the request's `Sec-Fetch-Site` header is `same-origin` (respond 403). Third parties must not be able to mint.
- Read a first-party cookie `hiy_visitor` (httpOnly, 1 year); create it with a random id if missing. Use it as `sub`.
- Mint `v1.<base64url(payload)>.<base64url(HMAC-SHA256("v1." + base64url(payload), secret))>` where payload is JSON `{ "twin": TWIN_ID, "sub": <cookie id>, "aud": "<this site's location.origin: scheme + host, no path, no trailing slash>", "iat": <unix seconds>, "exp": iat + 900 }` (base64url without padding).
- Respond `{ "token": "<token>" }` with `Cache-Control: no-store`.

Environment, server only, never in client code:
- `HIY_TWIN_ID`: the id in the owner's prompt.
- `HIY_SIGNING_SECRET`: the owner copies it from the Install screen (Signed-in users › Your signing secret) into the deployment's environment. Never ask for it in chat and never write it into a file.

Reference implementation: https://hiy.ai/docs/developers#tokens-for-restricted-agents

Docs: https://hiy.ai/docs/embed.md
