Web widget
MarkdownThe 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
Section titled “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:
https://acme.comhttps://app.acme.comhttps://*.acme.devA *. 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
Section titled “2. Install”Script tag
Section titled “Script tag”Paste this before </body> on every page that should show the chat:
<script 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. |
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):
<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/[email protected]/dist/concierge.bundle.js instead: the same widget as one classic file, with every language built in (about 80 KB gzip).
npm install @conciergesdk/webimport { Concierge } from '@conciergesdk/web';
Concierge.configure({ appKey: 'app_YOUR_APP_KEY' });
// From your Help linkdocument.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:
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.
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). 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
Section titled “Identify your users”Concierge.identify(user.id, { name: user.name, email: user.email, token });Sign token on your server with the identity secret (see Identity verification). 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
Section titled “Reset and suspend”// "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 inlocalStorage, the identity, context, cached history and appearance, the outbox, seen cards and handled tool calls. It also forgets screens fromregisterScreenand theonOpenScreenhandler. 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 callsuspend()again on the next launch if needed.
Open, close and the unread badge
Section titled “Open, close and the unread badge”Concierge.open();Concierge.dismiss();Concierge.toggle();Concierge.setLauncherVisible(false);
const sub = Concierge.onUnreadCountChange((count) => updateBadge(count));Concierge.getUnreadCount(); // the last known countsub.remove(); // stop listeningThe 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
Section titled “Context, breadcrumbs and errors”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
Section titled “Client tools”Let the AI agent or your team ask the page to do something, with the visitor’s consent:
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
Section titled “Screen links”Register the pages support answers may link to. See Knowledge autopilot. Links must be on the page’s own origin, relative or absolute; links to other origins are refused with a console warning.
Concierge.registerScreen('settings.backup', 'Backup settings', '/settings/backup');Concierge.unregisterScreen('settings.backup'); // its buttons stop showingTapping 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:
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 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.
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:
Concierge.onPaywallCard((paywallId) => router.push(`/pricing?plan=${paywallId}`));- Offer cards are redeemed for the web, where the server only picks
paywallandcompsteps from the offer’s chains (store purchases can’t run in a browser). Take over withonOfferCardto run your own checkout and passredemption.accountTokenalong (RevenueCat: subscriber attributeconcierge_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.
Wire your paywall, offers and reviews
Section titled “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:
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
Section titled “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):
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.
Appearance
Section titled “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:
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
Section titled “Events”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
Section titled “How it behaves”- Network: nothing at page load for a first time visitor. The browser registers as a
webdevice the first time the chat opens. Requests go out withcredentials: '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-Afterand 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, whichenforcelets through only while development mode is on. - Browsers: current Chrome, Edge, Firefox and Safari (Safari 15 and later).
Debug overlay
Section titled “Debug overlay”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.