embed + theme the widget
drop the one-line script on any page, then theme it from KV without a redeploy.
embed + theme the widget
widget.js is dependency-free and lives in a Shadow DOM, so your site's CSS can't leak in
(and vice versa). host it anywhere static โ your CDN, the edge Worker's origin, an R2 /
Pages bucket.
embed
drop one tag on any page, before </body>:
<script
src="https://YOUR-HOST/widget.js"
data-api="https://krispy-edge.YOU.workers.dev"
data-tenant="self"
async
></script>script attributes
these are the boot-time knobs read off the <script> tag. richer appearance is set via the
theme (below), not attributes.
| attribute | required | default | meaning |
|---|---|---|---|
data-api | yes | โ | your @krispy/edge Worker base URL |
data-tenant | no | self | tenant id (multi-tenant uses this) |
data-site | no | (default site) | site id for accounts running multiple websites โ each has its own config/kbase/branding |
data-title | no | Chat with us | header text (theme headerTitle supersedes it) |
data-accent | no | #e39a2b | brand color, used before the theme fetch lands |
data-launcher | no | (built-in) | none suppresses the built-in launcher button โ bring your own |
theme (from KV, no redeploy)
on open, the widget fetches GET /api/widget/config?t=<tenant> โ a public, secret-free
projection of your tenant's theme. change it in KV (via the CLI or your own tooling
writing POST /api/tenant/config) and the widget picks it up on next load; no rebuild.
The config response may be stored, but every widget boot revalidates it, so warm tabs pick up
changes after a reload without generating one-off cache-buster URLs.
the themeable fields (WidgetTheme):
| field | default | what it controls |
|---|---|---|
primaryColor | #e39a2b (gold) | visitor bubble, send button, header halo, input focus ring |
launcherColor | transparent | optional badge behind the launcher mascot; unset keeps Buttr floating |
launcherStyle | "circle" | "pill" = labelled plate that collapses to the avatar while open |
launcherLabel | the widget title | the word on the pill (โค24 chars); inert unless launcherStyle:"pill" |
glowColor | โ (no glow) | opt-in launcher glow tint (hex); unset = no glow layer at all |
position | br | br (bottom-right) or bl (bottom-left) |
avatar | โ | "buttr" (inline default), "none", an https:// URL, or a data:image/โฆ URI |
greeting | โ | first bot bubble shown when the panel opens |
headerTitle | โ | header text (supersedes data-title) |
tagline | built-in | header sub-line ("we usually reply in minutes") |
radius | 20 | panel corner radius, px, clamped 0โ20 |
font | Bricolage/system | a CSS font-family stack |
sound | true | notification "ding" on inbound messages while the panel is closed |
sparkle | false | idle sparkle loop on the launcher (shadow swell + conic ring) |
direction | ltr | rtl flips the panel: bubble corners, input dir, mirrored send icon |
popupText | โ (popup off) | proactive teaser card above the launcher (see timing below) |
timing | see below | WidgetTiming โ attention/timing knobs |
Unset knobs keep their documented defaults. Tenant accents automatically keep Krispy's brand ink
when it reaches 4.5:1 contrast; otherwise the widget chooses black or white by measured contrast.
No foreground field is required. All launcher, popup, panel, and message animations respect
prefers-reduced-motion.
timing (WidgetTiming)
all values are milliseconds unless noted, clamped 0โ300000; each only takes effect when
the feature it belongs to is enabled:
| field | default | meaning |
|---|---|---|
launcherDelayMs | 0 | >0 hides the launcher, then plays a one-time entrance pop (skipped on revisit in the same tab session) |
sparkleAfterMs | 10000 | when the sparkle loop starts; inert unless sparkle |
popupDelayMs | 8000 | when the teaser popup shows; inert unless popupText |
popupCooldownHrs | 24 | hours before the popup may show again (per-browser localStorage) |
autoOpenMs | 0 | auto-open a closed panel after an inbound reply; 0 = never (try 2000) |
the popup never shows while the panel is open, clicking it opens the chat, and the ร
dismisses it for the cooldown window.
theme values cross a CSS trust boundary. the widget sanitizes them: colors must be
#hex or rgb(...) (glowColor hex-only), radius is clamped 0โ20, fonts reject ;{}<>,
timing is clamped, and avatars must be the literal buttr, none, an https:// URL, or a
data:image/โฆ URI. anything else is dropped. see security.
bring your own launcher
the theme restyles the built-in launcher โ a circular <img> in the shadow root. that is
enough for a color and a logo, and not enough for a brand with its own mark: an animated
silhouette, a hover gesture, a word instead of an icon. so the launcher can be turned off
and replaced with your own element.
add data-launcher="none" to the script tag, then drive the panel yourself:
<button id="support" type="button">support</button>
<script>
var b = document.getElementById("support");
b.addEventListener("click", function () {
if (window.krispy) window.krispy.toggle(); // guard: the widget script is async
});
document.addEventListener("krispy:unread", function (e) {
b.dataset.unread = String(e.detail.unread); // your own dot when a reply lands
});
</script>
<script
src="https://YOUR-HOST/widget.js"
data-api="https://krispy-edge.YOU.workers.dev"
data-tenant="self"
data-launcher="none"
async
></script>window.krispy
commands, available once the script has run. it is a singleton per page โ one host element, one session per tenant.
| call | returns | meaning |
|---|---|---|
krispy.open() | โ | open the panel (no-op if already open) |
krispy.close() | โ | close the panel |
krispy.toggle() | โ | what the built-in launcher does |
krispy.isOpen() | boolean | is the panel open right now |
krispy.unread() | boolean | is there an unread inbound message |
krispy.el | Element | the widget's host element (see the stable handle below) |
events on document
state comes back as events rather than something you poll, because the panel also opens from
paths you never called โ a teaser popup, autoOpenMs, the panel's own ร โ and because a
listener can be registered before the async script has run.
| event | detail | fires when |
|---|---|---|
krispy:open | โ | the panel opened, from any path |
krispy:close | โ | the panel closed |
krispy:unread | { unread: boolean } | an inbound message landed while closed / cleared |
the host element
widget.js appends one <div class="krispy-widget"> to <body> and attaches an open shadow
root to it. select it as .krispy-widget (or take window.krispy.el) โ never on the z-index
in its inline style, which is what integrators had to do before the class existed.
every part of this is opt-in. leave data-launcher off and the built-in launcher renders exactly
as it always has; window.krispy and the events are added either way and are inert until
something calls or listens.
what the widget does
- markdown โ bot/operator bubbles render a safe subset (
**bold**,*italic*,`code`,[label](url)) built withcreateElement/textContent, neverinnerHTML. visitor and system messages stay literal. links only render forhttp(s)/mailto. - notifications โ a WebAudio "ding" + launcher pulse + unread dot, fired only on an inbound message while the panel is closed. respects a per-visitor mute (persisted) and the browser autoplay policy (silent until first interaction).
- mobile โ a keyboard-aware floating card via
visualViewport,16pxinputs to dodge iOS zoom, safe-area insets, and 44px mute/close touch targets on coarse pointers. - resilience โ the operator WebSocket reconnects with exponential backoff + jitter; the
chat still works over plain
POSTif the socket is down. - audio calls โ when enabled, a team member can invite a visitor; a visitor can also request a call after human handoff if the tenant allows it. A request alerts the operator app and waits for a team member to accept; the visitor connects automatically on acceptance. Accepting an incoming invitation also starts connecting without another tap. The idle call suggestion can be dismissed for the current chat session, including across page reloads; incoming invites and active calls still appear normally. The browser may request microphone permission as the accepted call connects. After a failed join, Connection details shows the failed step (grant, audio library, room connection, or microphone) without revealing credentials or URLs. Grant failures also show a numeric HTTP status when the server returned one, including non-JSON errors; a failed connection offers a clearly labeled retry. A real incoming invitation rings when browser audio is allowed and notifications are unmuted, including attempts in hidden tabs; browser autoplay and background restrictions can still prevent sound. Accept and Decline remain visible when autoplay blocks the ringtone. Returning visitors with a previously registered call capability keep their call socket connected while the page is visible even when chat is closed; backgrounding or leaving the page closes it. The server still validates the capability before an operator can invite them. The call card distinguishes waiting for the team member, connected, and reconnecting. A requested call opens a full-screen audio view with the team identity and call status. Return to chat collapses it without ending the call; Open call view restores it. If remote audio playback is blocked, Play call audio resumes it from a tap. Mute/Unmute and End call remain available during the call. Open audio settings on the card or full-screen view: permitted microphones can be switched without a new permission prompt; speaker selection appears only when the browser supports it and has multiple outputs. When the runtime 0.5 coordinator emits a durable receipt, the chat transcript shows its outcome, occurred time, and verified connected duration without recording or summarizing the conversation. Observed endpoint times are labeled approximate; replay preserves time order and scroll position. Older cached chat bubbles without timestamps keep their relative order after historical receipts instead of receiving invented times. Calls require the page in the foreground: switching tabs, minimizing the browser, navigating away, or closing the page stops local media and ends the call. Closing only the chat panel leaves the call active.
local demo
cd services/edge && bunx wrangler dev # Worker on :8787
bunx serve packages/widget # demo page (index.html points data-api at :8787)serve over http so the WebSocket and localStorage work.