Krispy AI
guides

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.

attributerequireddefaultmeaning
data-apiyesโ€”your @krispy/edge Worker base URL
data-tenantnoselftenant id (multi-tenant uses this)
data-siteno(default site)site id for accounts running multiple websites โ€” each has its own config/kbase/branding
data-titlenoChat with usheader text (theme headerTitle supersedes it)
data-accentno#e39a2bbrand color, used before the theme fetch lands
data-launcherno(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):

fielddefaultwhat it controls
primaryColor#e39a2b (gold)visitor bubble, send button, header halo, input focus ring
launcherColortransparentoptional badge behind the launcher mascot; unset keeps Buttr floating
launcherStyle"circle""pill" = labelled plate that collapses to the avatar while open
launcherLabelthe widget titlethe 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
positionbrbr (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)
taglinebuilt-inheader sub-line ("we usually reply in minutes")
radius20panel corner radius, px, clamped 0โ€“20
fontBricolage/systema CSS font-family stack
soundtruenotification "ding" on inbound messages while the panel is closed
sparklefalseidle sparkle loop on the launcher (shadow swell + conic ring)
directionltrrtl flips the panel: bubble corners, input dir, mirrored send icon
popupTextโ€” (popup off)proactive teaser card above the launcher (see timing below)
timingsee belowWidgetTiming โ€” 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:

fielddefaultmeaning
launcherDelayMs0>0 hides the launcher, then plays a one-time entrance pop (skipped on revisit in the same tab session)
sparkleAfterMs10000when the sparkle loop starts; inert unless sparkle
popupDelayMs8000when the teaser popup shows; inert unless popupText
popupCooldownHrs24hours before the popup may show again (per-browser localStorage)
autoOpenMs0auto-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.

callreturnsmeaning
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()booleanis the panel open right now
krispy.unread()booleanis there an unread inbound message
krispy.elElementthe 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.

eventdetailfires 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 with createElement/textContent, never innerHTML. visitor and system messages stay literal. links only render for http(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, 16px inputs 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 POST if 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.

On this page