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).
visitorIdโ€”nullOptional caller-supplied visitor UUID. Otherwise resolved from localStorage (humanwork.chat.visitorId.{orgSlug}) or generated.
containerIdโ€”humanwork-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).