Installation
Installing the widget takes two steps: load the script, then initialize it with your workspace slug. Chat, ideas, roadmap and changelog live inside your app; the widget loads in the background and runs in a Shadow DOM, so it never touches your styles.
With the default launcher, that's all it takes for the floating button to show up on your page.
1. Add the script
Paste this near the end of your page, right before the closing </body> tag:
<script> window.Jotly = window.Jotly || function () { (Jotly.q = Jotly.q || []).push(arguments) };</script><script async src="https://jotly.si/widget.js"></script>The first line creates the global window.Jotly function and queues any calls you make before widget.js loads; once it loads, it runs them in order.
2. Initialize the widget
After the script, initialize Jotly with your slug:
<script> Jotly('init', 'YOUR_WORKSPACE');</script>Replace YOUR_WORKSPACE with your workspace slug. You'll find it in your dashboard URL (/app/YOUR_WORKSPACE) and in Settings → Install widget, where the snippet already includes your slug. The same slug powers your public portal at /b/YOUR_WORKSPACE.
Minimal example
This is the shortest complete installation:
<script> window.Jotly = window.Jotly || function () { (Jotly.q = Jotly.q || []).push(arguments) }; Jotly('init', 'YOUR_WORKSPACE'); // Optional: identify your user Jotly('identify', { user: { id: 'user_123', email: 'ana@company.com', firstName: 'Ana' } });</script><script async src="https://jotly.si/widget.js"></script>A single tag (no inline script)
If your Content Security Policy (CSP) blocks inline scripts, a single tag is enough. The widget initializes itself from data-project; if you also call Jotly('init', …), your call takes precedence over the attribute.
<script async src="https://jotly.si/widget.js" data-project="YOUR_WORKSPACE"></script>Optional: identify your users
If your product already knows who the user is, identify them after init:
const { viewer, identityTrust, warnings } = await Jotly.identify({ user: { id: 'user_123', // required (or a signed token) email: 'ana@company.com', firstName: 'Ana', lastName: 'López', avatar: 'https://…/ana.png', traits: { plan: 'pro', monthlySpend: 499 }, }, companies: [{ id: 'acme', name: 'Acme Inc' }],});You need either user.id or a token signed on your server. Repeating the same input during a live session does nothing, and if you call it several times, the last call wins. To make sure no one can impersonate another user, sign the identity on your server as described in Identify users. The legacy format ({ id, email, name, signature }) still works.
Init options
You can also configure the widget options in Settings → Install widget; options you pass to init override them. init returns a promise with the state and only rejects on programming errors (INVALID_PROJECT_ID, INVALID_OPTIONS).
Jotly('init', 'YOUR_WORKSPACE', { widget: { // false turns it off (createEmbed and identify still work) launcher: true, // false, or { selector: '#help' } to use your own button whispers: true, // short-lived notices next to the launcher notifications: { updates: 'whisper' }, // 'whisper' | 'full' | 'none' position: 'right', // 'right' | 'left' offset: { bottom: 20, side: 20 }, // px theme: 'auto', // 'auto' | 'light' | 'dark' }, locale: 'en-US', // defaults to the browser language pageContext: 'origin', // 'origin' | 'path' | 'none': how much of the URL your team sees in chat onReady: function () {}, onError: function (error) {}, // { code, message, recoverable }});With notifications.updates: 'full', a new changelog entry opens the widget on that article at most once per page load, and only when the tab is visible and focused and the widget is closed. With 'whisper', a notice shows up next to the launcher for 7 seconds.
Browser API
Every method can be called as Jotly('open', { to: 'roadmap' }) or, once the script has loaded, as Jotly.open({ to: 'roadmap' }). Calls made before widget.js loads are queued and run in order.
// Promise with the state once init finishes (never rejects on network errors: check state.status)const state = await Jotly.ready(); // Open at an exact spot; resolves once the panel is visibleJotly.open({ to: 'home' });Jotly.open({ to: 'roadmap' });Jotly.open({ to: 'notifications' });Jotly.open({ to: 'feedback' });Jotly.open({ to: 'feedback', compose: true }); // new requestJotly.open({ to: 'feedback', submissionId: 'pst_123' }); // a single requestJotly.open({ to: 'updates', updateId: 'chg_123' }); // a single changelog entryJotly.open({ to: 'messages', compose: { text: 'Hi, I need help with…' } }); Jotly.close();Jotly.toggle(); // Appearance and languageJotly.setTheme('dark'); // 'auto' | 'light' | 'dark'await Jotly.setLocale('en-US'); // Read-only stateJotly.getState(); // When the user signs out of your appawait Jotly.logout(); // Tear everything down; on kiosks, also clear anonymous conversations, drafts and the remembered contactJotly.destroy({ clearConversationData: true });Events
Jotly.on(event, handler) returns a function that stops listening. Listeners survive destroy().
| Event | Receives |
|---|---|
ready | The state. If it's already ready, it fires right away. |
statechange | The state, on every change. |
open | The state when the panel opens. |
close | The state when the panel closes. |
identify | { viewer, identityTrust } |
unread | { count } |
presence | { availability: 'online' | 'neutral' } |
error | { code, message, recoverable } |
destroy | The state after destroy(). |
const off = Jotly.on('unread', function ({ count }) { document.querySelector('#help-badge').textContent = count || '';});off(); // stop listening Jotly.on('error', function (error) { if (!error.recoverable) console.error(error.code, error.message);});Listening to unread or presence keeps the count up to date even with widget: false.
State
{ status: 'ready', // 'idle' | 'initializing' | 'ready' | 'error' | 'destroyed' projectId: 'YOUR_WORKSPACE', locale: 'en-US', unreadCount: 1, // chat replies + unseen changelog entries; null at first presence: 'online', // 'online' | 'neutral' ('neutral' doesn't mean offline) lastError: null, widget: { enabled: true, open: false, position: 'right', theme: 'auto' }, session: { status: 'anonymous', identityTrust: null, viewer: null },}Errors
Every error has the shape { code, message, recoverable }. Errors reach the error event, onError and state.lastError; promises reject with the same object.
| Code | When |
|---|---|
INVALID_PROJECT_ID | The project id is malformed or the workspace doesn't exist. |
INVALID_OPTIONS | An option passed to init, setTheme, setLocale or createEmbed is invalid. |
INVALID_NAVIGATION | The open target or the navigate path is invalid. |
INVALID_IDENTIFY | identify was called without a token or user.id. |
IDENTIFY_FAILED | The server rejected the identity (invalid signature, a workspace that requires signing, etc.). It's the only network error that rejects the promise. |
BOOTSTRAP_FAILED | The configuration couldn't be loaded; the state stays in error. |
NOT_READY | A method was called after init failed. |
WIDGET_DISABLED | open was called with widget: false. |
CONVERSATIONS_DISABLED | open({ to: 'messages' }) with messages turned off; Home opens instead. |
LAUNCHER_NOT_FOUND | No element matches launcher.selector (it keeps watching in case one shows up). |
EMBED_CONTAINER_NOT_FOUND | The embed container doesn't exist. |
DESTROYED | A method was called after destroy; call init again. |
Embeds in your page
Jotly.createEmbed renders the portal (feedback, roadmap and changelog) inside your app in an iframe, without its navigation bar and with the same identity as the widget. You can have several at once.
<div data-jotly-embed style="height: 720px"></div><script> Jotly('init', 'YOUR_WORKSPACE', { widget: false }); var portal = Jotly.createEmbed({ // container: '#feedback', // defaults to [data-jotly-embed] path: '/roadmap', // defaults to '/' basePath: '/feedback', // syncs the URL: /feedback/p/<slug> theme: 'auto', onReady: function () {}, onError: function (error) {}, }); portal.navigate('/updates'); // portal.destroy();</script>- With
basePath, every page change inside the embed updates your site's URL throughhistory, and the browser's back and forward buttons move the embed. Loading a URL underbasePathopens that same page in the embed. - The iframe and your page only talk through
jotly:messages (ready,route:change,navigate,identify,logout), and each side verifies the other's window and origin. - You can also embed the portal by hand: any page under
/b/YOUR_WORKSPACEaccepts?navbar=hiddenand?theme=light|dark|auto.
Next.js
With next/script you load it once for the whole site and call window.Jotly from any component.
// app/layout.tsximport Script from "next/script"; export default function RootLayout({ children }) { return ( <html lang="en"> <body> {children} <Script id="jotly-init" strategy="afterInteractive"> {`window.Jotly = window.Jotly || function () { (Jotly.q = Jotly.q || []).push(arguments) };Jotly('init', 'YOUR_WORKSPACE');`} </Script> <Script src="https://jotly.si/widget.js" strategy="afterInteractive" /> </body> </html> );}Verify the installation
After reloading the page:
- The widget launcher appears in the bottom corner.
- Clicking it opens the widget.
- You can move between messages, ideas, roadmap and changelog inside the widget.
- In the console,
Jotly.getState().statusreturns'ready'.
If you use your own button (launcher: { selector } or launcher: false), the widget can be installed correctly even though no floating button shows up.
Troubleshooting
The widget doesn't show up
Check these first:
- The
widget.jsscript is on the page Jotly('init', …)runs with the right slug (or the tag hasdata-project)launcherisn't set tofalseandwidgetisn't set tofalseJotly.getState().lastErroris empty
The widget shows up, but doesn't recognize the user
This almost always means the installation is fine but the identity is missing. Start with Identify users.
If you send a signature that doesn't match, Jotly rejects the identification with IDENTIFY_FAILED instead of creating an unverified user.
See also Feedback widget in your app