# Web

The NewInstance Chat web widget drops a floating launcher button and a chat panel onto any website. Visitors talk to your AI assistant first, and the conversation hands off to a human agent in the dashboard when the AI cannot help or the visitor asks for a person. The widget is a single script that mounts a sandboxed `<iframe>`; all chat logic, realtime transport and rendering live inside that frame, so the host page stays untouched apart from one fixed-position container.

Served from `https://widget.newinstance.cloud/embed.js`.

## Authentication

The widget authenticates in exactly one of two modes (see [Authentication and identity](/authentication-and-identity)):

- **Customer token (recommended).** Your server mints a token with `POST /api/v1/chat/sessions` and renders it into the page (`data-customer-token`) or returns it to your front end (`customerToken`). No key appears in the page at all.
- **Key mode (trusted internal pages only).** `data-api-key` or `apiKey` set to the full `keyId:secret`. Anything inside a `<script>` tag is world-readable, so this puts your secret in front of every visitor: use it only on internal pages behind your own sign-in, never on a public website. The full key travels to the widget iframe in the URL fragment, never the query string.

Pass one or the other. Passing both, or neither, is a configuration error (`E_CONFIG`).

**Deprecated:** `data-api-key` (or `apiKey`) with only the key ID, such as `sk_live_abc123`, runs the pre-0.4.0 behaviour unchanged, including anonymous visitors, so deployed embeds keep working. It logs one console warning per page load. Do not use it for new embeds.

## Script tag embed (auto-init)

Add the script to `<head>` or just before `</body>`. Auto-init runs when `data-customer-token` or `data-api-key` is present, on `window load` (immediately if the document is already `complete`).

```html
<script
  src="https://widget.newinstance.cloud/embed.js"
  data-customer-token="TOKEN_FROM_YOUR_BACKEND"
  data-theme="dark"
  data-position="bottom-right"
  data-auto-open="false"
></script>
```

| Attribute | Type | Default | Description |
|---|---|---|---|
| `data-customer-token` | string | none | Customer token minted by your server. Selects customer token mode (recommended); no customer fields are needed. See [Authentication and identity](/authentication-and-identity). |
| `data-api-key` | string | none | The full `keyId:secret` selects key mode, for trusted internal pages only. A bare key ID is the deprecated legacy form. Pass this or `data-customer-token`, not both. |
| `data-theme` | `dark` or `light` | `light` | Widget colour scheme. Only these two values. |
| `data-position` | `bottom-right` or `bottom-left` | `bottom-right` | Launcher and panel corner. |
| `data-transport` | `sse` or `ws` | inherited | Realtime transport override, for testing. |
| `data-theme` | string | dashboard theme | `dark`, `light`, or JSON for individual tokens (`{"colors":{"sentBubble":"#16a34a"}}`). Ranked below the dashboard theme. See [Theming](/theming). |
| `data-customer-name` / `data-customer-email` / `data-customer-id` | string | none | Deprecated bare key ID embeds only, where they pre-fill untrusted display data. In token mode the identity comes from the token; in key mode call `setUser`. |
| `data-auto-open` | `"true"` or absent | `false` | Opens the panel once the key validates. Compared strictly against the string `"true"`. |
| `data-gql-endpoint` | URL | derived | Self-host override for the API endpoint. |
| `data-widget-url` | URL | derived from script `src` | Self-host override for the iframe origin. |

Sizing (`buttonSize`, `panelWidth`, `panelHeight`, `zIndex`) and callbacks are only available through the JavaScript API.

## JavaScript API embed

Load the script with no data attributes, then call `NInstanceChat.init(config)`. It returns a widget instance, or `null` if a widget is already mounted.

```html
<script src="https://widget.newinstance.cloud/embed.js"></script>
<script>
  const widget = NInstanceChat.init({
    customerToken: tokenFromYourBackend,
    theme: 'dark',
    position: 'bottom-right',
    autoOpen: false,
    buttonSize: 60,
    panelWidth: 400,
    panelHeight: 620,
    zIndex: 2147483647,
    callbacks: {
      onReady: () => console.log('iframe booted'),
      onAuth: (info) => console.log('auth', info.valid, info.status, info.reason),
      onOpen: () => console.log('panel opened'),
      onClose: () => console.log('panel closed'),
      onDestroy: () => console.log('widget torn down'),
    },
  });

  document.querySelector('#help').addEventListener('click', () => widget.open());
</script>
```

| Option | Type | Default | Description |
|---|---|---|---|
| `customerToken` | string | none | Customer token minted by your server. Selects customer token mode (recommended). |
| `apiKey` | string | none | The full `keyId:secret`. Selects key mode, for trusted internal pages only. Pass `customerToken` or `apiKey`, not both. A bare key ID is the deprecated legacy form. |
| `theme` | `dark` or `light` | `light` | Widget theme. |
| `transport` | `sse` or `ws` | inherited from platform settings, SSE when nothing is advertised | Realtime transport override. |
| `customerName` / `customerEmail` / `customerId` | string | none | Deprecated bare key ID embeds only. Use the token, or `setUser` in key mode. |
| `position` | `bottom-right` or `bottom-left` | `bottom-right` | Launcher position. |
| `autoOpen` | boolean | `false` | Open the panel after the key validates. |
| `buttonSize` | number | `60` | Launcher diameter in px. |
| `panelWidth` | number | `400` | Panel width in px. |
| `panelHeight` | number | `620` | Panel height in px. |
| `zIndex` | number | `2147483647` | CSS z-index of the root container. |
| `gqlEndpoint` | URL | derived | Self-host API endpoint override. |
| `widgetUrl` | URL | derived from script `src` | Self-host iframe origin override. |
| `callbacks` | object | none | `onReady`, `onAuth`, `onOpen`, `onClose`, `onDestroy`. |

Everything else about the widget's behaviour (welcome and offline messages, input placeholder, file-upload, emoji and typing toggles, email-required, sound, online status, branding) is configured in the dashboard, not from the embed.

### Callbacks

- `onReady()` fires as soon as the iframe boots, **before** the API key round-trip. Use it to hide a host-side spinner or start a fallback timer.
- `onAuth({ valid, status, reason })` fires after credential validation. The launcher stays hidden until `valid: true`.
- `onOpen()` / `onClose()` fire when the open or close originates inside the chat iframe (for example the panel's own close control). Opens and closes you trigger from the host (`widget.open()`, `widget.close()`, the launcher button) do not fire them; track those yourself at the call site, or poll `widget.isOpen()`.
- `onDestroy()` fires on `destroy()` and on auth-failure cleanup.

Payloads are deliberately small and non-sensitive: lifecycle markers, sanitized status codes, public IDs. Form values, tokens and customer PII are never forwarded over this channel. A callback that throws is caught and ignored, so a host bug cannot break the widget.

## Identifying the customer

In customer token mode the token is the identity: the pre-chat form is skipped and nothing else needs to be supplied. When a fresh token is due, swap it in without reloading the chat:

```js
const widget = NInstanceChat.init({ customerToken: tokenFromYourBackend });

widget.setUserToken(freshTokenFromYourBackend);
```

`setUserToken` posts the token to the widget iframe, targeted at the widget origin and never `*`. The iframe is not reloaded and the conversation carries on.

In key mode, name the customer with `setUser` on the instance `init` returns:

```js
const internal = NInstanceChat.init({ apiKey: 'sk_live_abc123:YOUR_SECRET_KEY' });
internal.setUser({ customerName: 'Ada Lovelace', customerEmail: 'ada@example.com', customerId: 'usr_123' });
```

The token, or the full key in key mode, travels in the iframe's URL **fragment**, which is never sent to a server, so it cannot land in the widget host's access logs. The widget reads it once and erases it from its own URL.

The `customerName`, `customerEmail` and `customerId` init options belong to the deprecated bare key ID form. There they are **display data, not authentication**, and the pre-chat form is skipped only when a non-blank `customerName` was supplied; anonymous visitors get the form.

## Local theme

`theme` accepts `'dark'`, `'light'`, a partial appearance, or the legacy flat palette. It is ranked **below** the merchant's dashboard theme and **above** the built-in one, merged token by token:

```js
NInstanceChat.init({
  customerToken: tokenFromYourBackend,
  theme: { mode: 'dark', colors: { sentBubble: '#16a34a' } },
});
```

If remote configuration cannot be fetched, this local theme still applies, with the built-in palette filling the gaps. Full rules in [Theming](/theming).

## Errors

`callbacks.onError` receives every diagnosable problem (a rejected key, chat switched off, unreachable configuration, a rejected identity token, a dropped transport, a failed send) as `{ code, message, severity, recoverable, source, timestamp }`. Nothing on this channel is ever rendered inside the chat interface, and it never carries the key, the token, or customer data.

```js
NInstanceChat.init({
  customerToken: tokenFromYourBackend,
  callbacks: {
    onError: (e) => {
      if (e.code === 'INVALID_IDENTITY_TOKEN') return refreshChatToken();
      if (!e.recoverable) reportToYourMonitoring(e);
    },
  },
});
```

The code list is in **Authentication and identity -> Diagnosing problems**. `WIDGET_UNREACHABLE` is web-specific: the iframe never answered the loader's handshake within 10 seconds.

Set identity at init, then use `setUserToken` (customer token mode) or `setUser` (key mode). `updateConfig` reloads the iframe, which resets an in-progress conversation.

## Method surface

```js
widget.open();                       // show the panel
widget.close();                      // hide the panel
widget.toggle();                     // flip open state
widget.isOpen();                     // boolean, synchronous
widget.setUserToken(freshTokenFromYourBackend);
widget.setUser({ customerName: 'Ada Lovelace' });
widget.updateConfig({ theme: 'light' });
widget.destroy();                    // tear down and remove from the DOM

// Attachments, all promise-returning
await widget.attachFile(file, 'invoice.pdf');
await widget.attachFiles([{ input: fileA }, { input: fileB, name: 'photo.png' }]);
await widget.removeAttachment(id);
await widget.clearAttachments();
const items = await widget.getAttachments();
await widget.openComposer();
```

- `open`, `close`, `toggle`, `isOpen`, `destroy` and `updateConfig` are synchronous and run entirely in the host frame.
- `setUserToken(token)` swaps the customer token without reloading the iframe; it is a configuration error in key mode. `setUser(user)` names the customer in key mode; in customer token mode its fields are not sent.
- `attachFile(input, name?)` accepts a `File`, a `Blob` (a filename is generated), a data URL (`data:<mime>;base64,...`), or a plain base64 string (name and extension optional, MIME defaults to `application/octet-stream`). It resolves with `{ success, error?, id? }`; the `id` is the queue-local ID for `removeAttachment`.
- `attachFiles(items)` returns one result per input, in the same order.
- `getAttachments()` returns metadata snapshots only, never raw `File` refs: `{ id, name, type, size, status, progress, errorReason?, url? }` where `status` is `queued`, `uploading`, `uploaded` or `failed`.
- `openComposer()` opens the panel locally first, so `isOpen()` flips synchronously, then bubbles the op into the iframe.
- Attached files appear in the composer strip exactly as if the customer had picked them; sending runs the normal upload and message flow.

## Queueing and the 15 second timeout

Every attachment and composer method is a bridged call into the iframe. Calls made before the auth handshake completes are queued in an outbox and flushed the moment `onAuth` reports `valid: true`, so you can call the API immediately after `init()` without racing the widget's mount.

Each bridged call has a 15 second timeout. If the iframe does not answer in time the promise rejects with `'<op>' request timed out`. If auth fails, queued calls reject with `chat is unavailable`. If you call `destroy()`, in-flight calls reject with `widget destroyed`.

## Conversation model

Flow states run `idle → collecting_info → bot_conversation → handoff_pending → live_chat → ended | error`. Conversation statuses are `BOT_ACTIVE`, `WAITING`, `ACTIVE`, `RESOLVED` and `CLOSED`; channels are `ai`, `live` and `ticket`.

Conversations are real-time both ways (server-sent events or WebSocket, selected by your platform settings with SSE as the default), support file attachments, typing indicators and unread counts, and survive page reloads: session state is kept in the browser's `sessionStorage`, scoped to the widget. Prompts are screened by the platform's AI-injection guard before reaching the model.

The transport heartbeats roughly every 10 seconds. If no beat arrives within a 30 second window the widget tears down the transport, reconnects, and replays missed messages from the server event log, falling back to a recent-message reload if the gap is too wide. Connectivity is re-checked on `online`, `visibilitychange` and `pageshow`, so bfcache restores recover cleanly. Transient state surfaces to the customer as "Reconnecting" or "Offline".

## Lifecycle and destroy

1. Script loads. Auto-init fires on `window load`, or you call `NInstanceChat.init()` yourself.
2. The root container, hidden launcher and iframe mount. The launcher is hidden until auth succeeds.
3. The iframe posts ready, then the auth result. On `valid: true` the launcher appears, `autoOpen` is honoured, and queued calls flush.
4. On auth failure the root is removed, `onDestroy` fires, queued calls reject, and the console explains that chat is unavailable, disabled or not configured.
5. If the iframe never answers, a 10 second safety timeout removes the widget silently with a generic console warning.

A second `init()` while a widget is mounted returns `null` and warns that the widget is already initialized (it checks for `#ninstance-chat-root`). `destroy()` notifies the iframe first so it can flush in-flight network calls and close transports, then rejects pending bridged calls and removes the root.

## The iframe bridge and CSP

The widget runs in an `<iframe>` loaded from the widget origin. Host and widget talk over `postMessage` behind two gates:

- **Origin gate:** the host only accepts messages whose `event.origin` matches the exact origin the iframe was loaded from, and whose `event.source` is the iframe's own `contentWindow`. Every outbound `postMessage` targets that same origin explicitly, never `*`.
- **Source-tag gate:** host to widget messages are stamped `ninstance-chat-host`; widget to host messages are stamped `ninstance-chat`. Untagged messages are dropped, so a rogue script on the page cannot forge the protocol even if it wins the origin lottery.

Messages other than ready, auth and API responses are ignored until auth is validated.

Your host page CSP must allow the widget origin in both `frame-src` and `script-src`:

```http
Content-Security-Policy: script-src 'self' https://widget.newinstance.cloud; frame-src 'self' https://widget.newinstance.cloud;
```

## Theming

`theme` accepts `dark` or `light` only; the default is `light`. It can be set at embed time or changed later with `updateConfig({ theme: 'light' })`, which reloads the iframe. Colours, branding and copy beyond the light/dark switch are configured in the dashboard.

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| Launcher never appears | The token or key was rejected. | Check the console warning from `onAuth`. In token mode, mint a fresh token; in key mode, pass the full `keyId:secret`. Confirm the key and the org are active. |
| Nothing initializes after ~10 seconds | The iframe never posted back, usually an unreachable widget origin or CSP blocking the frame. | Confirm the widget origin is reachable from the host page and allowed in `frame-src` and `script-src`. |
| `init()` returns `null`, console says "already initialized" | A previous instance is still mounted. | Call `destroy()` first, or guard against double-init. |
| Bridged call rejects with "request timed out" | The iframe did not answer within 15 seconds. | Check the frame is still mounted and the network is up; retry after `onAuth`. Widget console output is prefixed `[NInstance Chat]`, so filter DevTools on that. |
| `attachFile` rejects with "widget destroyed" | The call was awaited across a `destroy()`. | Re-initialize before re-attaching, or cancel host promises on destroy. |
| Customer keeps seeing the info form | No identity reached the widget. | Pass a customer token, or call `setUser` with a non-blank `customerName` in key mode. Deprecated bare key ID embeds show the form to anonymous visitors. |
| Conversation resets after `updateConfig` | `updateConfig` reloads the iframe by design, and only re-applies `theme`, `customerName` and `customerEmail`. | Set identity at `init()`. Swap tokens with `setUserToken`, which does not reload. Use `updateConfig` only before a conversation starts. |
| Console warns that the bare key ID form is deprecated | The embed passes `data-api-key` or `apiKey` with only the key ID. | It keeps working. Move public pages to a customer token (`data-customer-token`). |
