@paramms/chat-widget
v1.9.8
Published
Embeddable real-time chat widget for the Relay platform
Downloads
984
Maintainers
Readme
@paramms/chat-widget
Real-time embeddable chat widget for the Relay platform. Drop into any website with a single script tag — no build step required.
Install
npm install @paramms/chat-widgetQuick start — CDN, ESM mount() (no npm, no build, manual container)
This is the ESM path — you control the mount point yourself. For a floating bubble on any site with zero JavaScript (or a config object), see Script tag below instead — it's usually simpler unless you specifically need to place the widget inline in your own container (data-relay-*/Relay('boot', ...) don't support choosing a container element; this ESM form does, via el).
<div id="chat"></div>
<script type="module">
import { mount } from 'https://relay.paramms.com/index.js'
mount({
el: document.getElementById('chat'),
url: 'https://api.relay.paramms.com', // ONE url, any scheme — ws + REST derived
profileId: 'YOUR_PROFILE_ID',
})
</script>React / Next.js
General support chat (ChatWidget)
'use client'
import { ChatWidget } from '@paramms/chat-widget/react'
export default function SupportPage({ session }) {
return (
<ChatWidget
url={process.env.NEXT_PUBLIC_RELAY_URL}
profileId={process.env.NEXT_PUBLIC_RELAY_PROFILE_ID}
userId={session?.user.id} // optional — anonymous if omitted
userName={session?.user.name} // optional — shown to agents
userEmail={session?.user.email} // optional — shown to agents
/>
)
}Marketplace / multi-listing chat (MarketplaceChat)
Two modes — one component:
On a listing page (with listingId) → opens that item's chat directly. No list. Buyer is already looking at the item.
'use client'
import { MarketplaceChat } from '@paramms/chat-widget/react'
// Listing detail page — floating bubble in bottom-right corner
export default function ListingPage({ car, session }) {
return (
<>
<YourPageContent />
<MarketplaceChat
url={process.env.NEXT_PUBLIC_RELAY_URL}
profileId={process.env.NEXT_PUBLIC_RELAY_PROFILE_ID}
listingId={car.id}
listingTitle={car.title} // shown in chat header
listingPrice={car.price} // shown as "$12,500" tag
listingMeta="214,500 km · LPG" // one line of detail
listingStatus="Available"
userId={session?.user.id} // optional — anonymous if omitted
userName={session?.user.name} // optional
launcher // floating bubble button
position="bottom-right"
/>
</>
)
}Without listingId → opens the buyer's single general (non-listing) thread.
For a /messages inbox page (WhatsApp-style thread list, tap to open, ✎ to start a new chat) use ChatApp — by default it lists every conversation the user has with your business, across all your chatrooms, and opens each against its own chatroom (scope="tenant"; pass scope="profile" for one chatroom only). It's a single-pane stack, like a native messaging app: the list fills the surface, tapping a row pushes the chatroom over it, and the chatroom's back chevron (‹) returns to the list — the same on desktop, in a launcher panel, and on mobile. The list updates live over the socket (a subscribe_inbox stream keyed to the user), so new messages and threads appear without a refresh.
Add an inbox to a single-thread widget. Pass inbox to ChatWidget or MarketplaceChat to add a back chevron in the chatroom header that opens the full ChatApp list (and the list's ✕ returns to the thread). inboxScope="tenant" (default) lists the user's threads across all chatrooms; "profile" limits it to the current one:
<MarketplaceChat url={RELAY} profileId={PID} listingId={car.id} listingTitle={car.title} inbox />// Dedicated inbox page — e.g. /messages
import { ChatApp } from '@paramms/chat-widget/react'
export default function MessagesPage({ session }) {
return (
<div style={{ height: '600px' }}>
<ChatApp
url={process.env.NEXT_PUBLIC_RELAY_URL}
tenantId={process.env.NEXT_PUBLIC_RELAY_TENANT_ID} // or profileId={…} — either works
userId={session?.user.id}
userName={session?.user.name}
/>
</div>
)
}Prefer a floating bubble that opens the same app in a panel? <ChatAppLauncher … floating />.
ChatAppLauncher (both floating and inline) shows an always-visible close (✕) in the panel's top-right, so it dismisses reliably on mobile too (where a fullscreen panel has no Escape key or reachable backdrop). If you embed a bare <ChatApp /> inside your own overlay, pass onClose={() => …} to get the same close control; omit it for a plain inline embed you chrome yourself.
Multi-tenancy note: conversations are created and keyed under the tenant the server resolves from
profileId— guests can't spoof it. For reading your own inbox,ChatApp/ChatAppLauncher(anduseRelayChatList/mountChatList/GET /conversations/mine) also accept atenantIddirectly: it lists every conversation the user has with that business across all of its chatrooms, and is equally safe because the list is always self-scoped to the caller's own identity.scope="tenant"/tenantIdnever crosses into another business's data.
Script tag (any website — plain HTML, WordPress, Shopify, PHP/Rails/Django/Laravel, or literally anything)
This is the path for every site that isn't React: one <script> tag, no build step, no framework. It works two ways — pick based on how much JavaScript you're willing to write:
- Zero-JavaScript —
data-relay-*attributes right on the<script>tag. This is the one that matters most for a plain-HTML site, a WordPress block, or a no-code builder, because it's the only option when there's no JavaScript on the page at all — a server template just fills in attribute values. - Full config — a JS object (
window.relaySettings = {...}orRelay('boot', {...}), same shape either way). Needed the moment you want something an HTML attribute can't hold: a nested object, an array, a function, or changing the widget's identity after the page has loaded (e.g. once a visitor logs in).
Both configure the exact same widget with the exact same field names as the React components above — profileId, userName/userEmail/userAvatar, contextTitle/contextSubtitle/contextStatus (or listingTitle/listingMeta/listingPrice/listingStatus for a marketplace-style card), accent, launcher, position, launcherMessage. If you already know the React props, you already know these.
Zero-JavaScript — data-relay-* attributes
<script
async
src="https://relay.paramms.com/embed.js"
data-relay-app="YOUR_PROFILE_ID"
data-relay-user="user_123"
data-relay-user-name="Jane Doe"
data-relay-user-email="[email protected]"
data-relay-accent="#6c5ce7"
data-relay-position="bottom-right"
></script>Drop that before </body> and you have a working floating bubble — no other JavaScript needed anywhere on the page. Every attribute is optional except data-relay-app.
| Attribute | Matches field | Notes |
|---|---|---|
| data-relay-app | profileId | Required. data-relay-profile-id works identically — same thing, matches the React prop name |
| data-relay-user | userId | Unauthenticated stable id. Omit for an anonymous guest |
| data-relay-token | token | Signed ES256 JWT — the production identity tier |
| data-relay-user-name | userName | Shown to agents |
| data-relay-user-email | userEmail | Shown to agents; also powers the offline email fallback |
| data-relay-user-avatar | userAvatar | Shown to agents |
| data-relay-listing | listingId | Sugar for subjectId: "listing_<id>" |
| data-relay-context-title / -subtitle / -status | contextTitle / contextSubtitle / contextStatus | Context card (general — an order, ticket, booking) |
| data-relay-listing-title / -meta / -price / -status | listingTitle / listingMeta / listingPrice / listingStatus | Marketplace card (a specific item — price + status badge). Use these OR the context-* set, both build the same card |
| data-relay-accent | accent | Brand colour hex |
| data-relay-url | url | Only needed for self-hosted Relay |
| data-relay-position | position | bottom-right | bottom-left |
| data-relay-launcher | launcher | Set to "false" for an inline (non-floating) widget |
| data-relay-launcher-message / data-relay-launcher-subtitle | launcherMessage | Two flat attributes combine into the teaser card — see note below |
| data-relay-target | el | CSS selector for an existing element to mount into. Ignored in launcher mode |
| data-relay-height | height | Inline container height. Ignored in launcher mode |
| data-relay-inbox | inbox | "true" adds a back-chevron → full conversation list. Requires data-relay-launcher="false" — see the note below the table |
| data-relay-inbox-start | inboxStart | "true" opens directly on the conversation list instead of a single thread — for a dedicated "Messages" page. Implies inbox. Requires data-relay-launcher="false" |
| data-relay-inbox-scope | inboxScope | tenant (default) | profile |
| data-relay-translate-lang | translateLang | ISO code (e.g. es, ja) to auto-translate incoming messages into — shows a 🌐 button per message |
| data-relay-tenant-id | tenantId | Your business id. Lists threads across all your chatrooms without naming one — use instead of data-relay-app on an inbox page |
What's NOT available as an attribute: user/subject as nested objects, quickReplies, i18n, and refreshToken — an HTML attribute can only hold a string, so these need the JS-object form below. (launcherMessage's two attributes are a workaround for exactly this: the React prop takes one { title, subtitle } object, which an attribute can't express, so it's split into two flat attributes that get recombined.)
inbox requires data-relay-launcher="false" — same limit React has (a launcher+inbox combination needs a different component there, ChatAppLauncher); with the default floating launcher, data-relay-inbox is silently ignored.
A whole "Messages" page, zero JavaScript. data-relay-inbox alone only puts the list behind a back-chevron — the widget still lands on one thread, which is right for a product page and wrong for a page whose entire job is the inbox. data-relay-inbox-start makes the list the landing view, and data-relay-tenant-id spans every chatroom you run:
<div id="relay-inbox" style="height:600px"></div>
<script
async
src="https://relay.paramms.com/embed.js"
data-relay-tenant-id="acme"
data-relay-inbox-start="true"
data-relay-launcher="false"
data-relay-target="#relay-inbox"
data-relay-user="user_123"
></script>Tapping a row opens that conversation against its own chatroom; its back-chevron returns to the list. There's no ✕ in this mode — the list is the root view, so there'd be nothing behind it to close to. ✎ compose opens against your default chatroom (the server reports which). This is the script-tag equivalent of React's <ChatApp tenantId={...} />.
Full config — JS object
Same field names, all in one place, same object whether it's set on page load or reacts to something happening later:
<script async src="https://relay.paramms.com/embed.js"></script>
<script>
window.relaySettings = {
profileId: 'YOUR_PROFILE_ID', // required — your chatroom id
url: undefined, // optional — only for self-hosted Relay
apiUrl: undefined, // optional — only if REST lives on a different origin than the socket
token: undefined, // optional — signed ES256 JWT, production identity (wins over userId)
userId: 'user_123', // optional — unauthenticated stable id; omit both → anonymous guest
refreshToken: () => fetchNewToken(), // optional — called when a signed token expires
userName: 'Jane Doe', // optional — shown to agents, not identity
userEmail: '[email protected]', // optional — also powers the offline email fallback
userAvatar: 'https://…/jane.png', // optional
// or, if it's easier to build one object: user: { name, email, avatar }
subjectId: undefined, // optional — pins a dedicated thread; usually just use listingId below
listingId: '4821', // optional — sugar for subjectId: "listing_4821"
contextTitle: 'Order #4821', // optional — context card title (general use)
contextSubtitle: 'Placed Mar 3 · $129.00', // optional — context card subtitle
contextStatus: 'Shipped', // optional — status badge
// or, for a marketplace-style card: listingTitle / listingMeta / listingPrice / listingStatus
// or build the card yourself: subject: { title, subtitle, tags, status }
accent: '#6c5ce7', // optional — brand colour
launcher: true, // optional — floating bubble vs inline; default true
position: 'bottom-right', // optional — 'bottom-right' | 'bottom-left'
launcherMessage: { title: 'Questions? Chat with us', subtitle: 'Start a conversation' }, // optional — a bare string also works (title only)
quickReplies: ['Track my order', 'Return an item'], // optional — reply chips above the input
i18n: { send: 'Enviar' }, // optional — UI string overrides
translateLang: undefined, // optional — auto-translate incoming messages (ISO code)
// Inline placement (ignored in launcher mode — a floating bubble is a
// fixed popup mount() owns, not something placed at a point in the page):
el: '#chat', // optional — CSS selector or element; omit → an auto-created host
height: '600px', // optional — inline container height
inbox: false, // optional — back-chevron → full conversation list (inline only, see below)
inboxScope: 'tenant', // optional — 'tenant' (default, all your chatrooms) | 'profile' (this one only)
}
</script>Relay('boot', {...}) (below) takes this exact same object — use whichever form fits how the page is built.
inbox matches React exactly, including its one limit: it only works with launcher: false. A floating launcher bubble is a fixed-size popup the widget owns; React itself needs a different component (ChatAppLauncher) for a launcher-with-inbox experience, and the script-tag path draws the same line. With inbox: true and launcher: false, the widget's header shows a back-chevron; tapping it swaps to the full conversation list (reusing the same list engine the dashboard's own inbox runs on); tapping a row opens that conversation; a ✕ in the list view returns to this widget's original thread.
Commands — the same object, applied later
For identity that arrives after the page loads (a visitor logs in), or SPA-style navigation between pages without a full reload, call Relay(...) directly instead of (or in addition to) setting window.relaySettings. Same field names as above — Relay('boot'/'identify'/'update', {...}) all take a RelaySettings object, just at a different moment:
<script async src="https://relay.paramms.com/embed.js"></script>
<script>
Relay('boot', { profileId: 'YOUR_PROFILE_ID' }) // mount immediately, anonymous
// later, once the visitor logs in:
Relay('identify', { userId: currentUser.id, userName: currentUser.name }) // merges their guest history in
// on navigation to a different listing/order page:
Relay('update', { listingId: newListing.id, listingTitle: newListing.title })
// on logout:
Relay('shutdown') // removes the widget + local session
</script>Relay(...) is queue-safe — calls made before embed.js finishes loading are never lost. identify/update merge onto whatever's currently running (a fresh boot replaces it entirely).
Network requirement: if the host page sets a Content-Security-Policy, it must allow the relay connection — connect-src https://api.paramms.com wss://api.paramms.com; (both are separate origins to the browser; include both), or your self-hosted equivalent.
Old section below (kept for reference)
'use client'
import { ChatWidget } from '@paramms/chat-widget/react'
export default function SupportChat() {
return (
<ChatWidget
url="https://api.relay.paramms.com"
profileId="YOUR_PROFILE_ID"
/>
)
}Identified users (no separate login needed)
Pass your own user's ID as the token and their details via user. Anonymous users work without any configuration.
<ChatWidget
url="https://api.relay.paramms.com"
profileId="YOUR_PROFILE_ID"
token={currentUser.id} // your own stable user ID — ties history across devices
user={{
name: currentUser.name, // shown to agents instead of opaque ID
email: currentUser.email, // agents can follow up even after disconnect
avatar: currentUser.avatarUrl,
meta: {
plan: currentUser.plan,
accountId: currentUser.id,
},
}}
/>Cryptographically verified identity (Tier 4)
For marketplaces and financial services — the guest's identity is verified against your ECDSA key so it cannot be spoofed.
// 1. Generate a key pair (once, server-side)
// openssl ecparam -genkey -name prime256v1 -noout | openssl pkcs8 -topk8 -nocrypt -out private.pem
// openssl ec -in private.pem -pubout | base64 -w0 → paste in Dashboard → Domain → Guest identity linking
// 2. Sign a token per user (your backend)
import { createSign } from 'node:crypto'
const hdr = Buffer.from(JSON.stringify({ alg: 'ES256', typ: 'JWT' })).toString('base64url')
const pay = Buffer.from(JSON.stringify({ sub: userId, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600 })).toString('base64url')
const sig = createSign('SHA256').update(`${hdr}.${pay}`).sign(privateKey, 'base64url')
const signedToken = `${hdr}.${pay}.${sig}`
// 3. Pass to widget — server verifies the signature automatically
<ChatWidget url="..." profileId="..." token={signedToken} />Marketplace / multi-item (one thread per listing)
<ChatWidget
url="https://api.relay.paramms.com"
profileId="YOUR_PROFILE_ID"
subjectId={`car_${listing.id}`} // one conversation per item
showChatList={true} // guest can switch between their threads
/>Launcher (floating button)
<ChatWidget
url="https://api.relay.paramms.com"
profileId="YOUR_PROFILE_ID"
launcher={true}
position="bottom-right"
accent="#6c5ce7"
/>Launcher message (the teaser card)
An optional card shown above the closed bubble to invite a conversation. Visitors can dismiss it with the ×; it stays dismissed for the browser session and retires automatically once the chat is opened.
<ChatWidget
url="https://api.relay.paramms.com"
profileId="YOUR_PROFILE_ID"
launcher
launcherMessage={{ title: 'Questions? Chat with us', subtitle: 'Start a conversation' }}
/>A bare string works too (title only): launcherMessage="Need help?".
You usually don't need this prop. Set the message once per chatroom in the
dashboard (Chatrooms → your chatroom → Widget message & availability) and it
arrives automatically over the socket. Pass launcherMessage only to override
the dashboard value for one embed (it also renders instantly, before the
socket connects).
Script-tag embeds use data-relay-launcher-message / data-relay-launcher-subtitle (or the launcherMessage: {title,subtitle} object in JS-object form) — see the Script tag section above for the full reference.
Internationalisation
<ChatWidget
url="https://api.relay.paramms.com"
profileId="YOUR_PROFILE_ID"
i18n={{
placeholder: 'Écrivez un message…',
send: 'Envoyer',
offline: 'Nous sommes absents pour le moment', // the away notice
poweredBy: '', // empty string hides the footer
}}
/>offline is the fallback text for the away notice shown above the composer
outside the chatroom's office hours. It never blocks anything: guests can always
send, the message is delivered like any other, and an agent replies when they're
back. The chatroom's own "offline message" (dashboard → Widget message &
availability) takes precedence over this string.
RTL is detected automatically for Arabic, Hebrew, Persian and Urdu browsers.
All options
| Option | Type | Default | Description |
|---|---|---|---|
| el | HTMLElement | required | Mount target |
| url | string | required | Relay URL — ONE url, any scheme (https://api.relay.paramms.com); the WebSocket URL and REST base are derived |
| profileId | string | required | Domain profile ID |
| token | string | auto-generated | Guest identity token — pass your user's stable ID to tie history across devices |
| user | UserInfo | — | Name, email, avatar, custom metadata — shown to agents |
| subjectId | string | — | Item ID for marketplace mode |
| showChatList | boolean | false | Show conversation switcher in header |
| accent | string | #6c5ce7 | Brand colour (hex) |
| launcher | boolean | false | Render as floating button |
| position | 'bottom-right' \| 'bottom-left' | 'bottom-right' | Launcher position |
| launcherMessage | string \| { title, subtitle? } | chatroom setting | Teaser card above the closed launcher. Overrides the dashboard's per-chatroom message; dismissible per session |
| translateLang | string | — | Auto-translate incoming messages (ISO language code) |
| quickReplies | string[] | — | Pre-set reply chips shown above input |
| i18n | I18nStrings | English | UI string overrides |
| inbox | boolean | false | Add a ‹ back in the chatroom header that opens the full ChatApp conversation list (inline widgets only) |
| inboxScope | 'tenant' \| 'profile' | 'tenant' | With inbox: list threads across all chatrooms (tenant) or just this one (profile) |
| inboxStart | boolean | false | Land ON the conversation list instead of a single thread (a dedicated "Messages" page). Implies inbox; requires launcher: false. Script-tag/embed only — in React use <ChatApp/> directly |
| tenantId | string | — | Script-tag/embed only. Your business id: lists threads across ALL your chatrooms with no chatroom named. Only meaningful with inboxStart — a single thread still needs a profileId |
Development
npm install
npm run dev # Vite preview at http://localhost:5174/
npm run build # tsc → dist/
npm run build:bundle # Vite → standalone CDN bundle
npm test # 75 tests via vitest
npm run typecheck