@synerise/ai-assistant-sdk
v1.15.0
Published
Drop-in browser SDK for embedding the Synerise AI Assistant in vanilla JavaScript applications.
Maintainers
Readme
@synerise/ai-assistant-sdk
Drop-in browser SDK for embedding the Synerise AI Assistant in vanilla JavaScript applications. Ships as ESM and CJS — works with bundlers or directly via <script type="module">.
Requirements
This SDK is designed for the Synerise platform. To use it you need:
- An active Synerise tenant and a tracker key
- Network access to
https://api.synerise.com(or your custom Synerise API endpoint) - A browser environment (the SDK uses
window,document,fetch)
If you don't have a Synerise account, visit synerise.com.
The SDK technically allows pointing
apiUrlat any HTTP endpoint that follows the Synerise AI Assistant API contract, but it is not designed as a generic chat SDK.
Which package do I need?
| Your stack | Package |
| -------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Vanilla JS / no bundler / <script> tag | @synerise/ai-assistant-sdk (this package) |
| React 18 | @synerise/ai-assistant-react |
| Preact | @synerise/ai-assistant-core |
| Build a custom chat UI from primitives | @synerise/ai-assistant-ui |
How packages relate
@synerise/ai-assistant-sdk ──┐
├──> @synerise/ai-assistant-core ──> @synerise/ai-assistant-ui
@synerise/ai-assistant-react ──┘The -sdk package is a self-contained ESM/CJS bundle. It already includes Preact and the UI internally — you don't install or configure anything else.
Installation
Bundler (Webpack, Vite, Rollup, esbuild, …)
npm install @synerise/ai-assistant-sdk
# or
pnpm add @synerise/ai-assistant-sdk
# or
yarn add @synerise/ai-assistant-sdkQuick start
ESM / CJS (bundler)
// ESM
import { init } from "@synerise/ai-assistant-sdk";
const chat = init({
rootElementId: "chat-container",
context: { profileId: "user-123" },
additionalContextValues: { segment: "premium" },
});
chat.open();// CommonJS
const { init } = require("@synerise/ai-assistant-sdk");
const chat = init({
rootElementId: "chat-container",
});Browser (<script type="module">)
Host the contents of build/ on a CDN with CORS enabled and load the entry (ai-assistant.es.js) as a module. The browser fetches lazy chunks (e.g. the markdown renderer) on demand from the same path — serve them with Access-Control-Allow-Origin: * and Content-Type: text/javascript headers. Synerise's own CDN re-publishes the entry under sdk.es.js at https://web.snrbox.com/ai-shop-assistant/.
<!DOCTYPE html>
<html>
<body>
<div id="chat-container"></div>
<!-- 1. Synerise JS SDK (required for default auth flow) -->
<script>
function onSyneriseLoad() {
SR.init({
trackerKey: "{{YOUR_TRACKER_KEY}}",
});
}
(function (s, y, n, e, r, i) {
s["SyneriseObjectNamespace"] = r;
((s[r] = s[r] || []),
(s[r]._t = 1 * new Date()),
(s[r]._i = 0),
(s[r]._l = i));
var z = y.createElement(n),
se = y.getElementsByTagName(n)[0];
z.async = 1;
z.src = e;
se.parentNode.insertBefore(z, se);
z.onload = z.onreadystatechange = function () {
if (!z.readyState || /complete|loaded/.test(z.readyState)) {
s[i]();
z.onload = null;
z.onreadystatechange = null;
}
};
})(
window,
document,
"script",
"//web.snrbox.com/synerise-javascript-sdk.min.js",
"SR",
"onSyneriseLoad",
);
</script>
<!-- 2. Initialize the chat -->
<script type="module">
import { init } from "https://cdn.example.com/ai-assistant-sdk/ai-assistant.es.js";
const chat = init({
rootElementId: "chat-container",
header: "Customer Support",
context: { profileId: "user-123" },
additionalContextValues: {},
});
</script>
</body>
</html>Authentication
The SDK supports two auth modes via the disableXSRFToken option:
| Mode | disableXSRFToken | Behavior |
| --------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Synerise SDK auth (default) | true | SDK reads SR.auth.trackerKey() and SyneriseTC.uuid from the page and sends them as authParams in each request. Requires the Synerise JS SDK to be loaded and initialized first. |
| Cookie + XSRF token | false | SDK includes credentials with each fetch and reads the XSRF-TOKEN cookie into the X-XSRF-TOKEN header. Use this if your tenant is configured for session-based auth. |
If you pass an explicit apiUrl, you also bypass the default Synerise endpoint — useful for staging/dev environments.
API reference
init(options) → ChatInstance
| Option | Type | Required | Description |
| ------------------------- | ------------------------- | -------- | --------------------------------------------------------------------------------- |
| rootElementId | string | yes | DOM element id where the chat is mounted |
| context | Context \| null | no | Context sent with each request. Omit or pass null if unused (default null). |
| additionalContextValues | AdditionalContextValues \| null | no | Extra metadata sent with each request. Omit or pass null if unused (default null). |
| header | string | no | Chat header title (default "AI Assistant") |
| avatar | string | no | URL of the assistant avatar |
| theme | Theme | no | Theme variables override (see Theming) |
| texts | Texts | no | Override default UI texts |
| slots | Slots | no | Override specific UI parts (see Slots) |
| stream | boolean | no | When true, uses SSE for assistant responses |
| fastMode | boolean | no | When true, sends fastMode=true query param |
| assistantId | string | no | Optional assistant configuration ID (UUID) sent as assistantId query param. Falls back to backend default when omitted. |
| apiUrl | string | no | Override base API URL (default https://api.synerise.com/agents/v1/ai-assistant) |
| disableXSRFToken | boolean | no | See Authentication (default true) |
| threadId | string | no | Mount with this existing thread loaded — see Conversation history |
| onMessage | (response) => void | no | Called on every successful response |
| onConversationTitle | ({ threadId, title }) => void | no | Called when the backend emits the conversation's auto-generated title (stream mode only) — see Conversation history |
| onStreamError | ({ message, type, stage }) => void | no | Called when the backend refuses a streamed exchange (error SSE event) — e.g. a guardrail rejection. See Refused responses |
| pageContext | StorefrontPageContext \| null | no | Initial storefront page (PDP) the user is on — see Storefront page context |
| autoDetectPageContext | boolean | no | When true, SDK reads OpenGraph product meta tags and keeps pageContext in sync — see Storefront page context |
Returned ChatInstance methods
| Method | Returns | Description |
| ----------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| open() | void | Opens the chat UI |
| close() | void | Closes the chat UI |
| message(text) | void | Sends a message programmatically |
| unmount() | void | Removes the chat from the DOM and cleans up |
| setContext(value) | void | Replace context |
| setAdditionalContextValues(value) | void | Replace additionalContextValues |
| setPageContext(value) | void | Set or clear the storefront page (PDP). Engages the dedicated channel, which overrides any additionalContextValues.page_context — see Storefront page context |
| loadThread(threadId) | Promise<void> | Replace current messages with the messages of an existing thread — see Conversation history |
| getConversations() | Promise<AIAssistantConversation[]> | List the user's historical threads — see Conversation history |
Storefront page context (PDP)
When the user opens the chat from a product detail page, telling the assistant which product they're viewing lets it resolve "this", "it", "cheaper", "is this any good?" against the right item instead of asking the user to repeat themselves.
The signal travels per turn under
additionalContextValues.page_context. Two ways to wire it up.
Manual — call setPageContext on route change
import { init, type StorefrontPageContext } from "@synerise/ai-assistant-sdk";
const chat = init({ rootElementId: "chat-container" });
// On entering a PDP:
chat.setPageContext({ pageType: "product", itemId: "SKU-12345" });
// On leaving the PDP (or any non-product page):
chat.setPageContext(null);pageType accepts only "product" today. itemId must match the feed
catalog id used elsewhere in your Synerise integration.
Automatic — autoDetectPageContext: true
The SDK reads OpenGraph product-extension meta tags at mount and then
watches <head> for further changes via MutationObserver. Works out
of the box with libraries that mutate meta tags on navigation
(react-helmet, vue-meta, next/head, etc.). The initial /chat
POST already carries the detected context — meta tags are read
synchronously during mount, so you don't need to defer opening the
chat after navigating to a PDP.
<head>
<meta property="og:type" content="product" />
<meta property="product:retailer_part_no" content="SKU-12345" />
</head>init({ rootElementId: "chat-container", autoDetectPageContext: true });Recognised meta values:
og:type—productorproduct.item.product.group(variant parent) is intentionally ignored.product:retailer_part_no— the canonical Facebook / Synerise catalog id.
Auto-detect is opt-in because the meta tags only help when they match
your feed ids. If they don't, use manual setPageContext from your
router instead.
How the two channels interact
The dedicated channel (pageContext init prop, autoDetectPageContext,
setPageContext) and the generic
additionalContextValues.page_context channel interact deterministically:
- Before you touch the dedicated channel (no
pageContextprop, noautoDetectPageContext, nosetPageContext()call), anything underadditionalContextValues.page_contextflows through unchanged. - Once you touch it — pass
pageContext(evennull), enable auto-detect, or callsetPageContext()— the dedicated channel becomes authoritative for every subsequent turn:setPageContext({...})overrides anyadditionalContextValues.page_context.setPageContext(null)stripspage_contextfrom the merged extras even if it's still present inadditionalContextValues.
Pick one channel and stick with it; the dedicated one is recommended.
Pure detection helper
import { detectPageContextFromMeta } from "@synerise/ai-assistant-sdk";
const detected = detectPageContextFromMeta(); // StorefrontPageContext | nullFor when you want to read meta tags on demand from your own listener without subscribing to the SDK's observer.
Conversation history
The SDK exposes the user's conversation history as programmatic API. The chat UI does not render a history picker today — your host application owns that surface and uses these methods to build it.
Mount with an existing thread
const chat = init({
rootElementId: "chat-container",
threadId: "b9f1d2a4-…", // UUID of an existing thread
});
chat.open();The chat fetches that thread's messages on mount instead of starting a fresh conversation.
Switch threads at runtime
await chat.loadThread("b9f1d2a4-…");Clears the active messages and loads the selected thread in place.
List the user's conversations
const conversations = await chat.getConversations();
// AIAssistantConversation[]Shape:
type AIAssistantConversation = {
threadId: string;
agentType: string;
realm: string;
businessProfileId: number;
userId?: number | null;
clientId?: string | null;
createdAt: string; // ISO timestamp
title?: string | null; // auto-generated from the first user message
summary?: string | null; // assistant-generated short label
meta?: Record<string, unknown> | null;
};Minimal history picker
const chat = init({ rootElementId: "chat-container" });
chat.open();
const list = document.getElementById("history");
const conversations = await chat.getConversations();
conversations
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
.forEach((conv) => {
const item = document.createElement("li");
item.textContent = conv.title || conv.summary || conv.threadId;
item.onclick = () => chat.loadThread(conv.threadId);
list.appendChild(item);
});Live title updates (onConversationTitle)
In stream mode the backend generates a conversation title from the first
user message and pushes it as a conversation_title SSE event. Pass
onConversationTitle to receive it without refetching the history:
const chat = init({
rootElementId: "chat-container",
stream: true,
onConversationTitle: ({ threadId, title }) => {
// update the single entry in your history picker in place…
renameHistoryItem(threadId, title);
// …or treat it as a trigger to refetch:
// chat.getConversations().then(renderHistory);
},
});The same title is persisted server-side, so subsequent
getConversations() calls return it as title.
When the thread no longer exists
If the backend returns 404 for the requested thread (deleted, expired,
wrong tenant), the chat enters a fatal-error state with
errorType: "THREAD_NOT_FOUND". The recovery button is labelled
"Start new conversation" (customise via texts.startNewConversation)
and starts a fresh conversation rather than retrying the failing request
— a stale link in a history list degrades gracefully into a new chat.
Refused responses (guardrails)
In stream mode the backend can refuse an exchange instead of answering it.
It then emits an error SSE event in place of the response — no
message event follows, only [DONE]:
event: error
data: {"message":"Nie mogę pomóc w tej sprawie. Czy mogę pomóc w czymś innym?","type":"validation_failed","stage":"input"}
data: [DONE]The SDK handles both shapes of that event:
- With
message— the backend already prepared user-facing copy, so it is rendered as the assistant's reply, exactly like the fallback messages guardrails used to send as ordinary messages. The chat stays usable, no error state is shown, andonMessagereports the turn like any other response. A prompt refused atstage: "input"is never echoed by the backend, so the SDK keeps the user's own bubble in the transcript; note that such a turn is not part of the server-side history, so it will not reappear afterloadThread().- The exception is the opening request being refused this way: it
carries no
threadId, so nothing can be sent afterwards. The copy is still rendered, with the error state (and Try Again) below it — and since that turn ends as an error rather than a response,onMessagedoes not fire for it.
- The exception is the opening request being refused this way: it
carries no
- Without
message— nothing renderable, so the chat falls back to its error state witherrorType: "STREAM_ERROR"and a Try Again button (customise the copy viatexts.errorMessage.STREAM_ERROR). The prompt stays in the transcript, so the error is shown under the question it belongs to.
onStreamError fires for both, with the raw payload — use it for analytics
or to react to specific type / stage values (a callback that throws is
contained and does not break the response):
init({
rootElementId: "ai-assistant",
stream: true,
onStreamError: ({ type, stage }) => {
analytics.track("assistant_refused", { type, stage });
},
});Theming
All visual properties are theme variables. Override only the keys you need:
init({
rootElementId: "chat-container",
context: {},
additionalContextValues: {},
theme: {
variables: {
"chat-header-bg": "#0f172a",
"chat-header-text-color": "#f8fafc",
"chat-submit-button-bg-default": "#7c3aed",
},
},
});Full list of theme variables and defaults:
{
"chat-error-color": "#F52922",
"chat-success-color": "#54CB0B",
"chat-border-color": "#DBE0E3",
"chat-box-shadow": "0px 4px 12px 0px #2329360A",
"chat-container-text-family": "'Graphik LCG Web', Arial, sans-serif",
"chat-container-border-color": "linear-gradient(270deg, #8e54e0 14.42%, #ff6c4d 100%)",
"chat-container-border-radius": "8px",
"chat-container-bg": "#f9fafb",
"chat-border-radius": "8px",
"chat-header-bg": "#ffffff",
"chat-header-text-color": "#384350",
"chat-header-border-color": "#e9edee",
"chat-prompt-bg": "#384350",
"chat-prompt-text-color": "#ffffff",
"chat-message-text-color": "#384350",
"chat-message-highlighted-border-color": "linear-gradient(270deg, #8e54e0 14.42%, #ff6c4d 100%)",
"chat-suggestion-element-description-color": "#384350",
"chat-carousel-button-radius": "3px",
"chat-link-text-color-default": "#0044d9",
"chat-link-text-color-hover": "#0337A8",
"chat-message-buttons-direction": "row",
"chat-message-buttons-justify": "flex-start",
"chat-message-buttons-gap": "8px",
"chat-input-bg-default": "#ffffff",
"chat-input-bg-disabled": "#f9fafb",
"chat-input-text-color": "#57616d",
"chat-input-text-placeholder-color": "#949ea6",
"chat-input-border-color-default": "linear-gradient(270deg, #8e54e0 14.42%, #ff6c4d 100%)",
"chat-input-border-color-focus": "#6d2ed3",
"chat-button-radius": "8px",
"chat-button-color-default": "#0b68ff",
"chat-button-color-hover": "#238afe",
"chat-button-color-disabled": "#238afe",
"chat-button-bg-default": "#ffffff",
"chat-button-bg-hover": "#ffffff",
"chat-button-bg-disabled": "#ffffff",
"chat-button-border-color-default": "transparent",
"chat-button-border-color-hover": "transparent",
"chat-button-border-color-disabled": "transparent",
"chat-button-border-color-focus": "#0b68ff",
"chat-button-border-width": "1px",
"chat-button-shadow": "0px 4px 12px 0px rgba(35, 41, 54, 0.04)",
"chat-submit-button-radius": "3px",
"chat-submit-button-color-default": "#ffffff",
"chat-submit-button-color-hover": "#ffffff",
"chat-submit-button-color-disabled": "#ffffff",
"chat-submit-button-bg-default": "#6d2ed3",
"chat-submit-button-bg-hover": "#8e54e0",
"chat-submit-button-bg-disabled": "#b88cee",
"chat-submit-button-border-color-default": "transparent",
"chat-submit-button-border-color-hover": "transparent",
"chat-submit-button-border-color-disabled": "transparent",
"chat-submit-button-border-color-focus": "#0b68ff",
"chat-submit-button-border-width": "0px",
}Slots
Replace specific parts of the UI with your own renderer. Each slot is a function that returns either a raw HTMLElement (handy for vanilla JS) or a Preact/React ReactNode (e.g. a VNode produced with h(...)). The chat detects which one you return and wraps it accordingly.
init({
rootElementId: "chat-container",
context: {},
additionalContextValues: {},
slots: {
header: {
closeButton: ({ onClose }) => {
const button = document.createElement("button");
button.textContent = "x";
button.onclick = onClose;
return button;
},
},
},
});Available slots: header.title, header.closeButton, header.extraActions, messages.avatar, messages.loader, messages.error, messages.type.{bubbleText,button,text,suggestion,list,products,custom}, textArea.submitButton, textArea.input, markdown.{tagName}. See type definitions for full signatures.
The product and bubble-text slots additionally receive an onItemClick callback for click tracking — see Product click tracking.
Product click tracking
The assistant reports an assistant.click telemetry event whenever a user
clicks a product it surfaced — a card in a product carousel (CAROUSEL) or an
inline product link woven into assistant text (BUBBLE_TEXT).
Default UI — nothing to do
If you don't override the product or bubble-text slots, clicks on the built-in markup are tracked automatically, including middle-click to open a product in a background tab. No configuration.
Custom UI — call onItemClick
When you override a slot you replace the assistant's own markup, so it can no
longer detect the click itself. Each click-bearing slot is therefore handed a
pre-bound onItemClick: call it from your click handler and the event —
with the owning response's seqNo/correlationId and the source component —
is sent for you. You never assemble the payload yourself.
| Slot | Callback signature | Call it with |
| -------------------------- | --------------------------- | ---------------------------------------------------------------------- |
| messages.type.products.item | onItemClick(): void | nothing — already bound to this card's item |
| messages.type.products.container | onItemClick(itemId): void | the clicked product's itemId |
| messages.type.bubbleText | onItemClick(itemId): void | the itemId of the clicked link (resolve it from productLinks) |
init({
rootElementId: "chat-container",
slots: {
messages: {
type: {
products: {
// data: ProductItem & { onItemClick: () => void }
item: ({ title, img, action, onItemClick }) => {
const card = document.createElement("a");
if (action.type === "REDIRECT") {
card.href = action.url;
card.target = "_blank";
card.rel = "noopener noreferrer";
}
card.innerHTML = `<img src="${img}" alt="" /><span>${title}</span>`;
card.addEventListener("click", () => onItemClick());
return card;
},
},
},
},
},
});For a container slot (you render every card yourself), pass each product's id:
container: ({ content, onItemClick }) => {
const wrap = document.createElement("div");
content.forEach((product) => {
const card = document.createElement("a");
card.textContent = product.title;
card.addEventListener("click", () => onItemClick(product.itemId));
wrap.appendChild(card);
});
return wrap;
};Notes:
- Calling
onItemClickfor a product the backend couldn't attribute (noitemId), or for items in the opening greeting (which carries noseqNo), is a safe no-op — you can wire it unconditionally. - The event is best-effort telemetry: it's fire-and-forget and never blocks the click or navigation, and a network failure is swallowed silently.
Programmatic example
const chat = init({
rootElementId: "chat-container",
context: { profileId: "user-123" },
additionalContextValues: { segment: "premium" },
onMessage: (response) => console.log("threadId:", response.meta.threadId),
});
chat.open();
chat.message("Show me top products from the last 7 days");Troubleshooting
Root element with id X not found— ensure the DOM is ready and#chat-containerexists before callinginit().Chat is already mounted in container— the container already has rendered content. Callchat.unmount()before re-mounting.- 401 / 403 from API — verify your tracker key, that the Synerise JS SDK loaded successfully, and that
disableXSRFTokenmatches your tenant's auth flow. - CORS errors — your tenant must allow your origin. Contact Synerise support.
- Server-side rendering (Next.js, Remix, etc.) — this package is browser-only. Import it inside
useEffector behind atypeof window !== "undefined"guard. - TypeScript errors with
Slots— the type uses both Preact and ReactReactNodein some signatures; if you mix React/Preact in the same TS project, narrow the slot return types explicitly.
Support
For technical and licensing inquiries: [email protected].
License
Proprietary. See LICENSE.
