Chat Widget SDK
Drop an AI chat assistant, trained on your own content, onto any website with one script tag. Framework-agnostic, dependency-free, and safe for anonymous visitors.
Overview
The Chat Widget SDK embeds a floating chat assistant into any page. In knowledge mode it answers strictly from the sources you trained it on (URLs, workspace files, folders) and refuses to invent answers. It is designed for public sites: the browser never holds a long-lived credential.
| Bundle | Size (gzipped) | Includes |
|---|---|---|
| thinkflyflow-chat.min.js | ~10 KB | Launcher, chat, theming, events |
| thinkflyflow-chat-advanced.min.js | ~16 KB | Everything in basic, plus attachments, image paste, voice, quick replies, feedback, unread badge, session persistence and human takeover |
Loading the advanced bundle does not enable its features. They are controlled by the widget settings you configure in the dashboard, so you can change behaviour without redeploying your site.
Install & embed
The simplest integration is a single script tag:
<script src="https://cdn.thinkflyflow.com/chat-widget/thinkflyflow-chat-advanced.min.js" async></script>
<script>
window.addEventListener('DOMContentLoaded', function () {
ThinkFlyFlow.init({
mode: 'knowledge',
widgetId: '<your-widget-id>',
apiBaseUrl: 'https://api.laureljar.com/api/v2',
getToken: async function () {
// Your backend mints a short-lived visitor token (see Authentication)
const res = await fetch('/api/widget-token', { method: 'POST' })
const data = await res.json()
return { token: data.token, features: data.features, tier: data.tier }
},
})
})
</script>The SDK can also be imported as ESM if you bundle your site: import { ThinkFlyFlow } from '@thinkflyflow/chat-widget'. A React wrapper is shown in the repository examples.
Where the script lives.We publish the bundles as static files. Serve them from your own domain if you prefer (copy the file into your project's public/ directory and point script.src at it). Serving from your own origin avoids third-party script CSP complications.
Authentication & tokens
A browser embed must never carry a long-lived secret. The SDK takes a short-lived visitor token minted by your backend. The SDK never mints or stores credentials itself.
The flow is three steps:
| Step | Who | What happens |
|---|---|---|
| 1 | You | Register a widget in the dashboard. You receive a public key and a secret key (shown once). |
| 2 | Your backend | Exchange the public and secret key for a short-lived visitor token via the mint endpoint. |
| 3 | The browser | The SDK receives that token and uses it for chat. It expires and is re-minted as needed. |
// POST /api/widget-token - runs server-side only
export async function POST() {
const res = await fetch('https://api.laureljar.com/api/v2/widgets/token/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
public_key: process.env.WIDGET_PUBLIC_KEY,
secret_key: process.env.WIDGET_SECRET_KEY,
origin: 'https://your-site.com',
}),
})
const body = await res.json()
// Pass through features + tier so dashboard settings drive the widget.
return Response.json({
token: body.data.token,
features: body.data.features,
tier: body.data.tier,
})
}Pass features and tier through. If your endpoint drops them, the widget falls back to defaults and your dashboard toggles will appear to do nothing.
Tokens expire (default one hour). When a request returns 401 the SDK calls getToken() again automatically and retries once, so you never need to manage expiry yourself.
Configuration reference
All options are passed to ThinkFlyFlow.init(config).
| Option | Type | Default | Description |
|---|---|---|---|
| mode | 'workspace' | 'knowledge' | workspace | knowledge answers only from trained sources and is the mode for public sites. |
| widgetId | string | undefined | The widget to talk to. Required for knowledge mode and for attachments, voice and handoff. |
| apiBaseUrl | string | prod API | API base URL. Point at the test API while verifying. |
| getToken | function | undefined | Returns a token, or { token, features, tier }. Called before the first message and after a 401. |
| token | string | null | undefined | A token you already hold. Prefer getToken so expiry is handled. |
| refreshToken | string | null | undefined | Workspace mode only: enables one silent POST /auth/refresh/ retry on 401. |
| projectId | string | undefined | Workspace mode: scopes chat context to a project. |
| position | 'bottom-right' | 'bottom-left' | bottom-right | Corner the launcher docks to. |
| title | string | Chat with us | Header title and launcher aria-label. |
| greeting | string | undefined | First assistant message shown before the visitor types. |
| placeholder | string | Type a message… | Input placeholder text. |
| logoUrl | string | built-in mark | Brand logo shown in the header. |
| brandUrl | string | thinkflyflow | Where the header logo and footer link. |
| avatarUrl | string | undefined | Optional support avatar in the header. |
| theme | { primaryColor, mode } | indigo, light | Accent colour and colour scheme. |
| branding | BrandingConfig | server settings | Logo, title, greeting, colours and attribution. See Brand customisation. |
| model | string | platform default | Model id for this widget. See Choosing the AI model. |
| zIndex | number | 2147483000 | Stacking order. |
| maxTokens | number | undefined | Response length cap forwarded to the API. |
| model | string | undefined | Optional model override. |
| container | HTMLElement | document.body | Custom mount point. |
Tiers & feature flags
Features are resolved with this priority (highest first): init().features, then the flags returned by getToken(), then tier defaults, then hard defaults (everything off).
Operate features from the dashboard, not the site. Set your tier and per-feature toggles in the widget settings so changes take effect on every embedded page on the next load. Hardcoding features in init() overrides the server and makes the dashboard toggles appear broken.
| Flag | Advanced default | What it does |
|---|---|---|
| attachments | on | Visitors attach files (images, PDFs, documents). |
| screenshot | on | Paste an image from the clipboard (Ctrl/Cmd+V) or via the paste button. |
| voice | on | Record a voice message (push-to-talk by default). |
| feedback | on | Thumbs up/down on assistant replies. |
| unreadBadge | on | Badge on the launcher when a reply arrives while closed. |
| persistSession | on | Keep the transcript across page reloads. |
| humanTakeover | on | Visitor sees "a team member is responding" and agent replies appear live. |
| context | off | Send prior turns with each message for follow-ups. |
| quickReplies | off | Tappable suggestion chips before the visitor types. |
| handoff | off | A "Talk to a human" form capturing name, email and message. |
| aiPausedDuringTakeover | off | When on, the AI stops replying while a human is handling the chat. |
| maxUploadMb | 10 | Per-file upload cap. |
| accept | common types | Accepted file extensions. |
Quick replies and handoff are never on by default, even on the advanced tier. They turn on only when you configure them, so a widget does not accidentally show a handoff form you did not ask for.
Knowledge mode
In knowledge mode the widget answers only from the content you trained it on. Train it in the dashboard by adding URLs, workspace files or folders, then run ingestion.
| Behaviour | What the visitor sees |
|---|---|
| A question matches trained content | An answer grounded in that content, with sources available to the owner. |
| No content matches | An honest "I do not have information about that yet" rather than a made-up answer. |
| A question about another widget | Never possible: retrieval is scoped to the widget that was trained. |
The widget answers with the visitor token only, so it works for anonymous visitors on third-party sites with no account and no login.
Brand customisation
Owners control the widget's appearance from the dashboard (the widget detail page), so branding can change without editing the embedding site. Settings are delivered to the widget with the visitor token.
| Setting | Effect |
|---|---|
| Logo URL | Image shown in the widget header. Leave blank for the default mark. |
| Theme colour | Any CSS colour. Applied to the launcher, header and outgoing message bubbles. Pick from the colour wheel or enter a hex value. |
| Colour scheme | Light or dark. |
| Title | Header title and launcher accessible label. |
| Greeting | First assistant message shown before the visitor types. |
| Input placeholder | Placeholder text in the message box. |
| Hide attribution | Removes the "Powered by ThinkFlyFlow" footer for white-label sites. |
You can also set these per page through init(), which takes precedence over the dashboard. This is useful for a different logo on a landing page, for example:
ThinkFlyFlow.init({
mode: 'knowledge',
widgetId: '<your-widget-id>',
getToken: mint,
branding: {
logoUrl: 'https://your-site.com/logo.png',
primaryColor: '#0ea5e9',
mode: 'light',
hideAttribution: true,
},
})Hiding the attribution is available on white-label plans. If it is not enabled for your widget the footer stays, and the rest of your branding still applies.
Choosing the AI model
Each widget can use a different model. Pick one from the dashboard (the widget page) or leave it on the platform default. The catalogue lists each model with a short description of what to expect and a relative cost hint, so you can trade quality against cost per widget.
| Readiness | Meaning |
|---|---|
| Higher cost | Premium models. Best reasoning, charged at a higher credit rate. |
| Lower cost | Lighter models. Faster and cheaper, good for high-volume sites. |
| Platform default | Recommended. Uses whichever model the platform is currently optimised around. |
If a model fails, visitors are still answered. The platform automatically falls back through its provider chain and then a free tier, so an outage never leaves a visitor without a reply. When this happens you are emailed, and the widget page shows a notice with the model that actually served the reply.
Alerts are throttled to one per model per hour so a prolonged outage does not flood your inbox. Only workspace owners and admins can change the model.
ThinkFlyFlow.init({
// ...config
model: 'deepseek-chat', // optional; omit to use the dashboard setting
})Theming & position
ThinkFlyFlow.init({
theme: { primaryColor: '#ce42f5', mode: 'light' }, // or 'dark'
position: 'bottom-right',
title: 'Ask Laurel Jar',
greeting: 'Hi! Ask me anything.',
logoUrl: '/logo.png',
brandUrl: 'https://your-site.com',
})The widget renders inside a shadow DOM, so your page CSS cannot leak in and the widget CSS cannot leak out. It sizes itself with dynamic viewport units and respects safe-area insets, so it does not clip on mobile browsers with collapsing toolbars.
Programmatic API
| Method | Description |
|---|---|
ThinkFlyFlow.init(config) | Mount the widget. Safe to call again to reconfigure. |
.open() / .close() / .toggle() | Control the panel. |
.sendMessage(text) | Send a message as if the visitor typed it. |
.setToken(token, refreshToken?) | Update the token after your own refresh flow. |
.setUser({ id, name, email }) | Attach visitor metadata (never credentials). |
.isOpen() | Whether the panel is open. |
.destroy() | Tear down the widget and remove listeners. |
Events
Subscribe with on(event, handler). It returns an unsubscribe function.
ThinkFlyFlow.on('ready', () => {})
ThinkFlyFlow.on('open', () => {})
ThinkFlyFlow.on('close', () => {})
ThinkFlyFlow.on('message', ({ role, content }) => {})
ThinkFlyFlow.on('error', ({ message, status, code }) => {})Human handoff & takeover
When handoff is enabled, a visitor can ask for a human. Their name, email, message and the conversation transcript are recorded so nothing is lost even if email delivery fails. Requests appear in your widget inbox.
You can also take over any conversation without the visitor asking. While a team member is handling it, the visitor sees that a team member is responding and your replies appear in their chat. Whether the AI keeps answering during takeover is a per-widget setting (aiPausedDuringTakeover); a human is never the default responder.
Replies are delivered by short polling, which keeps the integration simple and works through restrictive networks. The transport is isolated inside the SDK, so it can be upgraded without changing your integration.
Security model
| Guarantee | How it holds |
|---|---|
| No secret in the browser | Only a short-lived, expiring visitor token reaches the page. It is scoped to one workspace. |
| Origin allow-list | The mint endpoint rejects origins not on the widget allow-list. Entries are an exact match, so add every domain you embed on, including www and preview domains. |
| Cross-tenant isolation | Retrieval is scoped to the widget, so one customer content can never surface in another widget. |
| Safe rendering | Model output is escaped before any formatting is applied, so a reply cannot inject markup into your page. |
| Shadow DOM isolation | Your styles and the widget styles cannot affect each other. |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Widget shows "No widget token available" | The mint route failed or the origin is not allowed. | Check the mint response status. If 403, add the page origin to the widget allow-list. |
| Dashboard toggles do nothing | features or tier are hardcoded in init(), or your token route drops them. | Remove from init() and pass features and tier through from the mint response. |
| A feature renders that you unchecked | The saved settings do not include an explicit value for that flag, so a tier default filled in. | Save the feature set with an explicit value for every flag. |
| Handoff form appears unexpectedly | handoff is enabled in settings or init(). | Set handoff to false; it is off by default. |
| Replies never arrive for a visitor | The visitor token expired and was not re-minted. | Ensure getToken() is provided so the SDK can re-mint on 401. |
| CORS or CSP errors | Your Content-Security-Policy blocks the script or API origin. | Allow the script origin and connect-src for the API base URL. |