Skip to main content

Feature: Embeddable Customer-Facing Chat Widget

The embeddable chat widget is a standalone, zero-dependency JavaScript bundle a client embeds on their own website so their visitors can chat with the client's assigned Specialist persona directly from the browser. It is a new inbound ingress channel (channel: "web") that lands messages into the same conversation + Expert-queue pipeline as Email/Slack/WhatsApp/Telegram — see channels.md.

Provenance. Shipped originally as PR #926 (feat: Embeddable customer-facing chat widget, merged 2026-05-27, closes #43) as a UI-only placeholder, then grown into a working channel: visitor-session auth (#2925), REST conversation/message wiring, and Socket.IO realtime. This doc closes the /docs gap tracked in #1281. The widget source lives in packages/chat-widget/; the public session endpoint lives in api/src/public/.

What it is

AspectValue
Package@humanwork/chat-widget (packages/chat-widget/)
Build outputSingle IIFE bundle dist/chat-widget.js, global HumanworkChatWidget, sourcemap emitted
Runtime depsNone — vanilla JS, no framework
Style isolationAll CSS class names prefixed humanwork-; styles injected once under #humanwork-chat-widget-styles
Mount pointA fixed-position container appended to document.body (bottom-right toggle button + 360×500 panel)
RealtimeSocket.IO over native WebSocket on the /notifications namespace

The widget renders a floating toggle button; opening it lazily bootstraps a session, loads (or creates) a conversation, and streams agent/Expert replies in real time. Outbound replies from the Specialist (sent by an HP Expert or the AI agent) arrive over the socket as agent_message_sent.

How a client embeds it

Two embed shapes — see the full integration guide at ../integrations/embed.md.

Auto-init via script tag (simplest):

<script
src="https://cdn.example.com/chat-widget.js"
data-auto-init
data-api-url="https://api.h.work"
data-org-slug="acme"
></script>

Programmatic (full control over lifecycle/theme):

<script src="https://cdn.example.com/chat-widget.js"></script>
<script>
const widget = new HumanworkChatWidget.ChatWidget({
apiUrl: "https://api.h.work",
orgSlug: "acme",
theme: { primary: "#4F46E5" },
});
widget.init();
</script>

Configuration surface

Constructor options (and their data-* script-tag equivalents where auto-init reads them in index.js):

Optiondata-* attributeDefaultPurpose
apiUrldata-api-url""Base URL of the h.work API. Required for any backend interaction. Trailing slashes are trimmed.
orgSlugdata-org-slugnullClient org slug. Used to mint a visitor session via POST /public/widget/session. Required unless a widgetToken is supplied.
widgetTokendata-widget-tokennullA pre-issued widget JWT. Lets the host skip the session call; the widget refreshes via orgSlug when it nears expiry (if orgSlug is also set).
visitorIdnullOptional caller-supplied visitor UUID. Otherwise resolved from localStorage (humanwork.chat.visitorId.{orgSlug}) or generated.
containerIdhumanwork-chat-widgetDOM id of the injected container.
theme.primary#4F46E5Accent color (button, header, user bubbles) via the --humanwork-primary CSS var.

The widget needs at minimum apiUrl plus either orgSlug or widgetToken (hasBootstrapConfig()). With neither, it renders but stays inert (no session, no messaging).

Auth model

The widget authenticates an anonymous visitor, not a logged-in platform user. The flow:

  1. Visitor identity. A per-org visitor UUID is resolved in priority order: explicit visitorId option → in-memory token claim → localStorage (humanwork.chat.visitorId.{orgSlug}) → freshly generated UUID (persisted back to localStorage). This gives a returning visitor continuity of their threads on the same browser.
  2. Session mint. POST /public/widget/session with { orgSlug, visitorId } returns { token, expiresAt, visitorId }. The endpoint is @Public() (no auth), rate-limited to 30 req/min, and resolves the org by slug (404 if unknown). It signs a JWT with:
    • sub = widgetVisitorId = the visitor UUID
    • role: "client", platformRole: "client"
    • orgMemberships: [{ orgId, orgRole: "member", orgSlug, slug }]
    • TTL = 24h (WIDGET_SESSION_TTL_SECONDS, #2925) — a workday without silent re-auth.
  3. Token use. The widget sends Authorization: Bearer <token> on every REST and socket call. On 401 it transparently re-mints (when orgSlug is set) and retries once. It proactively refreshes within a 5-minute skew of expiry (TOKEN_REFRESH_SKEW_MS).
  4. Client-view masking. Because the token carries platformRole: "client", every conversation response is passed through ClientConversationViewInterceptor (#2353) — Expert identity fields are stripped and role: "expert" is masked to "agent". A visitor cannot tell whether a reply came from a human Expert or the AI.

Isolation note (ADR-020). The minted token scopes the visitor to a single org via orgMemberships. Conversations/messages created through the widget are Org × Specialist-scoped like any other channel; the Specialist is resolved server-side from the conversation context, not supplied by the browser.

Threading semantics

  • On first open the widget calls GET /conversations for the visitor:
    • 0 conversations → it creates one (POST /conversations with { customer_id, role: "customer", channel: "web" }) on first send.
    • 1 conversation → it auto-selects it and loads GET /conversations/:id.
    • 2+ conversations → it renders a thread picker; the visitor chooses one.
  • A sent message is optimistically appended (greyed "pending"), then reconciled with the persisted message from POST /conversations/:id/messages (body { content, text }). On failure the bubble is marked "Not sent".
  • The active conversation is joined over the socket (join_conversation); switching threads emits leave_conversation for the previous one.
  • Inbound realtime events on the /notifications namespace:
    • agent_message_sent → append the Specialist's reply (deduped by message id).
    • agent_typing → show a transient typing indicator (auto-hides after 3s).
  • Socket reconnect uses capped exponential backoff (max 5 attempts, ≤8s) and re-joins the active conversation on reconnect.

Public endpoints

MethodPathAuthNotes
OPTIONS/public/widget/sessionPublicCORS preflight (Access-Control-Allow-Origin: *)
POST/public/widget/sessionPublic, 30/minMint a 24h visitor JWT for { orgSlug, visitorId? }

After holding a session token the widget reuses the standard conversation API (GET/POST /conversations, GET /conversations/:id, POST /conversations/:id/messages) as a client-role caller. Those are documented in ../api/reference.md.

Operational notes

  • CORS. The session endpoint sets wildcard CORS headers so it can be called from any client domain. The conversation/socket calls flow through the standard API CORS layer (api/src/common/cors/hwork-cors-allowlist.ts); a client embedding on a third-party origin must have that origin permitted (or rely on the bearer-token model rather than cookies — the widget uses bearer tokens, not cookies, precisely so cross-origin embedding works).
  • CSP. Host pages need to permit the script source, the API origin in connect-src (both https: and wss:), and inline <style> injection (or a nonce). See ../integrations/embed.md.
  • No in-process state (active-active safe): the widget is browser-side; the session endpoint is stateless (JWT only, no server session store).

Current limitations / future work

  • File attachments are not yet supported in the widget UI (the channel pipeline supports media on other channels).
  • No admin configuration panel yet (theme/branding is set per-embed via options).
  • CDN deployment automation for dist/chat-widget.js is out of scope of the package build (bun run build produces the bundle; publishing is manual).