# Web widget

> Add Concierge support chat to a website or web app with one script tag or an npm package. Shadow DOM, live replies, photos, cards and client tools.

Source: https://conciergesdk.com/docs/web/

The web widget puts the same support chat your mobile users get onto your website or web app: a launcher in the corner, a chat panel with live replies from your team or the AI agent, photo uploads, review and offer cards, and client tools. It is the same protocol as the iOS, Android, React Native and Flutter SDKs, so one inbox covers every platform and history follows a verified user everywhere.

- **One script tag or one npm package.** Framework free, with a small React binding.
- **Shadow DOM.** Your CSS can't restyle the chat and ours can't leak into your page.
- **Light on your page.** About 32 KB gzip at page load and no API request until someone opens the chat. The panel code (about 8 KB) loads on first open.
- **Accessible.** Keyboard first, focus trapped in the open panel, Escape closes it, new replies are announced to screen readers, and it becomes a full screen sheet on phones (under 640px).

## 1. Allow your website

The app key is public, so the API only answers browsers on websites you list. In the dashboard open **Settings, Security, Allowed websites** and add one origin per line:

```text
https://acme.com
https://app.acme.com
https://*.acme.dev
```

A `*.` entry covers every subdomain (not the bare domain). While **Development mode** is on, `localhost`, `127.0.0.1` and `[::1]` work on any port, so you can build locally before you add anything. A page that is not allowed gets `403 origin_not_allowed` and the widget logs a console error telling you where to add it.

Until your workspace subscribes, only 3 test devices can chat. Each browser profile counts as one device per website (a private window, another browser or another origin is a new one), and `reset()` registers a new one. Past the limit, opening the chat fails with `402 subscription_required`.

## 2. Install

### Script tag

Paste this before `</body>` on every page that should show the chat:

```html
<script
  src="https://cdn.jsdelivr.net/npm/@conciergesdk/web@0.1/dist/concierge.js"
  data-app-key="app_YOUR_APP_KEY"
  async
></script>
```

It configures itself from the data attributes and exposes `window.Concierge`:

| Attribute | Default | What it does |
|---|---|---|
| `data-app-key` | (required) | Your public app key. |
| `data-base-url` | `https://api.conciergesdk.com` | API URL, for a local API in development. |
| `data-launcher` | `true` | `false` hides the floating button; open the chat from your own. |
| `data-position` | `right` | `left` puts the launcher and panel in the bottom left corner. |
| `data-accent` | from the dashboard | `#RRGGBB` accent, so the launcher has your color before the first open. |
| `data-accent-dark` | `data-accent` | `#RRGGBB` accent for dark mode. |
| `data-capture-errors` | `false` | `true` reports uncaught errors with `captureError`. |
| `data-locale` | browser languages | Any BCP 47 tag (`ja`, `pt-BR`, `zh-Hant`). 34 languages ship; see [Localization](https://conciergesdk.com/docs/localization.md). |
| `data-debug` | `false` | Shows the debug overlay. |

Calls made before the async script finishes loading can wait in a queue. Put this above the script tag (in `<head>` is fine):

```html
<script>
  window.Concierge = window.Concierge || { q: [] };
  Concierge.q.push(['identify', 'user_42', { token: '...' }]);
</script>
```

The queued calls run right after the widget configures itself. From then on `window.Concierge` is the SDK itself and has no `q`, so code that runs later calls it directly (`Concierge.open()`). The `concierge:ready` event fires on `window` once it is ready.

`concierge.js` is a few hundred bytes that import the module build next to it. If your Content Security Policy or hosting setup can't load modules from the CDN, point the same tag at `https://cdn.jsdelivr.net/npm/@conciergesdk/web@0.1/dist/concierge.bundle.js` instead: the same widget as one classic file, with every language built in (about 80 KB gzip).

### npm

```bash
npm install @conciergesdk/web
```

```ts
import { Concierge } from '@conciergesdk/web';

Concierge.configure({ appKey: 'app_YOUR_APP_KEY' });

// From your Help link
document.querySelector('#help')?.addEventListener('click', () => Concierge.open());
```

Call `configure` once, in your client entry (for example `src/main.ts`). It shows the floating launcher, like the script tag. It takes the same settings as the data attributes: `baseUrl`, `launcher`, `position`, `captureErrors`, `locale` and `debug`, plus `zIndex` (stacking order, default `2147483000`). For a local API in development:

```ts
Concierge.configure({ appKey: 'app_YOUR_APP_KEY', baseUrl: 'http://localhost:4000' });
```

`configure` is safe to import during server side rendering (Next.js, Remix, Astro): it does nothing until it runs in a browser.

### React

```tsx
import { ConciergeProvider, useUnreadCount, useConcierge } from '@conciergesdk/web/react';

export function App() {
  return (
    <ConciergeProvider appKey="app_YOUR_APP_KEY" user={session ? { id: session.userId, token: session.conciergeToken } : null}>
      <Routes />
    </ConciergeProvider>
  );
}

export function HelpButton() {
  const unread = useUnreadCount();
  const concierge = useConcierge();
  return <button onClick={() => concierge.open()}>Help {unread > 0 && <span>{unread}</span>}</button>;
}
```

`session` stands for your own auth state, with a token your server signed (see [Identify your users](#identify-your-users)). Use the provider instead of calling `configure` yourself: it takes the same options as props (`baseUrl`, `launcher`, `locale`...), configures once, identifies `user` (and logs out when it goes from a user to `null`), and renders only its children. Hooks: `useUnreadCount`, `useChatVisible`, `useConciergeEnabled`, `useConciergeEvent`, `usePaywallCard`, `useOfferCard`, `useOpenScreen`, `useScreen`.

## Identify your users

```ts
Concierge.identify(user.id, { name: user.name, email: user.email, token });
```

Sign `token` on your server with the identity secret (see [Identity verification](https://conciergesdk.com/docs/identity.md)). With a valid token the conversation follows the user across browsers and devices, mobile apps included. New apps require verified users, so an `identify` without a token is refused (`401 identity_verification_required`, the `identityRejected` event). Call `identify` on every page load while signed in, and `Concierge.logout()` when they sign out (never on load for signed out visitors).

## Reset and suspend

```ts
// "Clear my data": wipe Concierge in this browser.
Concierge.reset();

// Hide Concierge completely (for example in a kiosk or guest mode).
Concierge.suspend();
Concierge.resume();
Concierge.isSuspended();
```

- `reset()` wipes Concierge on this device back to a fresh install without a network request: the device token in `localStorage`, the identity, context, cached history and appearance, the outbox, seen cards and handled tool calls. It also forgets screens from `registerScreen` and the `onOpenScreen` handler. It closes the chat. Tools, other handlers and appearance stay, and the next open registers a new device.
- While suspended the SDK sends no request and keeps no socket open, the chat closes and won't open, the support button and badge are hidden, the unread count reads 0 and no events fire. Consent prompts and tool calls wait: a pending call is picked up after `resume()` or expires on the server.
- `resume()` brings the entry points back, refreshes the unread count and sends anything queued. Suspension lasts until the page reloads, so call `suspend()` again on the next launch if needed.


## Open, close and the unread badge

```ts
Concierge.open();
Concierge.dismiss();
Concierge.toggle();
Concierge.setLauncherVisible(false);

const sub = Concierge.onUnreadCountChange((count) => updateBadge(count));
Concierge.getUnreadCount(); // the last known count
sub.remove(); // stop listening
```

The unread count stays live while the tab is visible for visitors who have chatted before, the same rule as the mobile SDKs.

## Context, breadcrumbs and errors

```ts
Concierge.setContext({ plan: 'pro', screen: 'billing' });
Concierge.breadcrumb('clicked_upgrade');
Concierge.captureError(error);
Concierge.setMetadata({ signup: '2026-03-01' });
```

The team sees all of it next to the conversation. Context rides along with the next message, so nothing extra is sent while the chat is closed. `configure({ captureErrors: true })` reports uncaught errors and unhandled rejections for you.

## Client tools

Let the AI agent or your team ask the page to do something, with the visitor's consent:

```ts
Concierge.registerTool(
  'export_invoices',
  'Export the invoices as a CSV download',
  { type: 'object', properties: { year: { type: 'number' } } },
  async ({ year }) => ({ file: await exportInvoices(Number(year)) }),
  { title: 'Export your invoices?' },
);
```

The consent prompt shows your `title` and always names the tool. Its text never comes from the server, and server fields can't turn consent off. **Allow** runs the handler (30 second limit), **Not now** declines.

## Screen links

Register the pages support answers may link to. See [Knowledge autopilot](https://conciergesdk.com/docs/knowledge.md). Links must be on the page's own origin, relative or absolute; links to other origins are refused with a console warning.

```ts
Concierge.registerScreen('settings.backup', 'Backup settings', '/settings/backup');
Concierge.unregisterScreen('settings.backup'); // its buttons stop showing
```

Tapping the button closes the chat and navigates with `location.assign`. In a single page app, route it yourself; the handler gets the screen id and the absolute same-origin URL:

```ts
Concierge.onOpenScreen((_id, url) => router.push(new URL(url).pathname));
```

The deep link never leaves the device: Concierge learns only the id and title, and only while the [knowledge autopilot](https://conciergesdk.com/docs/knowledge.md) is on. When an AI answer is about a registered screen, it can end with an **open screen** button; the SDK shows it only if that id is registered on this device. Ids are 1 to 64 letters, digits, `.`, `_` or `-`, starting with a letter; titles are 1 to 120 characters; up to 100 screens. Registering an id again replaces it, and invalid input is logged and ignored. Safe to call before `configure`.

## Cards

Review, paywall and offer cards render in the thread with the same design as the mobile SDKs, including the offer price row. On the web:

- **Review** cards open the store page in a new tab (only App Store and Google Play links are opened).
- **Paywall** cards, and offers that resolve to a paywall, call your handler:

```ts
Concierge.onPaywallCard((paywallId) => router.push(`/pricing?plan=${paywallId}`));
```

- **Offer** cards are redeemed for the web, where the server only picks `paywall` and `comp` steps from the offer's chains (store purchases can't run in a browser). Take over with `onOfferCard` to run your own checkout and pass `redemption.accountToken` along (RevenueCat: subscriber attribute `concierge_card`).

Store purchases happen in your mobile apps, so linking them to customers (refund desk, receipts) is set up there: see [Link purchases to customers](https://conciergesdk.com/docs/offers.md#link-purchases-to-customers).

## Wire your paywall, offers and reviews

Your site has a pricing page or an upgrade modal and a checkout (Stripe, Paddle or your own). Point the cards there:

```ts
Concierge.onPaywallCard((paywallId) => openPricing(paywallId)); // unknown ids open the default plan view

Concierge.onOfferCard((r) => {
  if (r.mechanism.kind === 'paywall') openPricing(r.mechanism.paywallId);
  // comp needs nothing: it is granted on the server
});
```

On the web the server only resolves offers to `paywall` and `comp` steps. Put `r.accountToken` in your checkout metadata if it takes any. There is no review prompt on the web; review cards open the App Store or Google Play page only when the app's store listing is set (dashboard **Settings, General**). The dashboard's Connect page and the `verify_install` MCP tool show whether `onPaywallCard` and `onOfferCard` are set, and a card with no handler emits `paywallUnhandled`, `offerUnhandled` or `reviewUnhandled`.

## Language

The widget follows the browser's preferred languages. To use your site's language instead, pass a BCP 47 tag when you configure (calling `configure` again with the same key changes nothing):

```ts
Concierge.configure({ appKey: 'app_YOUR_APP_KEY', locale: 'ar' });
```

Arabic, Hebrew and Persian lay the widget out right to left. Regions and scripts fall back sensibly (`es-MX` uses Spanish). The script tag takes `data-locale`. See [Localization](https://conciergesdk.com/docs/localization.md).

## Appearance

The accent, title and welcome text come from **Settings, Chat appearance** in the dashboard and are cached, so returning visitors see your color at once. Override locally:

```ts
Concierge.setAppearance({ accentLight: '#0A7CFF', accentDark: '#4DA3FF', title: 'Help', colorScheme: 'system' });
```

Light and dark follow `prefers-color-scheme`. Text on the accent switches to dark automatically for light accents.

## Events

```ts
Concierge.addEventListener((event) => analytics.track(`concierge_${event.type}`, event));
```

`chatOpened`, `chatClosed`, `messageSent`, `messageReceived`, `unreadCountChanged`, `cardShown`, `cardTapped`, `reviewOpened`, `reviewUnhandled`, `paywallOpened`, `paywallUnhandled`, `offerRedeemed`, `offerFailed`, `offerUnhandled`, `identityRejected`, `availabilityChanged`, `error`.

## How it behaves

- **Network:** nothing at page load for a first time visitor. The browser registers as a `web` device the first time the chat opens. Requests go out with `credentials: 'omit'`, so your site's cookies never reach the API.
- **Live replies:** a WebSocket while the chat is open (and for returning visitors while the tab is visible), with reconnect and backoff, catching up on anything it missed.
- **Offline:** messages you send offline wait in an outbox that survives reloads and go out, in order and never twice, when the connection is back. A rate limited send waits out `Retry-After` and reads "Sending soon".
- **Photos:** pick, paste or drop up to 4 images. They are scaled to 2048px and re-encoded as JPEG in the browser before upload.
- **Storage:** the device token, identity and outbox live in `localStorage`, queued photos in IndexedDB, all scoped to your origin.
- **Attestation:** browsers can't attest. With device checks on, a browser registers as `dev`, which `enforce` lets through only while development mode is on.
- **Browsers:** current Chrome, Edge, Firefox and Safari (Safari 15 and later).

## Debug overlay

```ts
Concierge.configure({ appKey: 'app_...', debug: true });
```

A draggable panel shows the connection, app key, user, customer and device ids, registered tools, pending tool calls and the last 20 requests. It logs a warning if you leave it on outside localhost.
