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/docsgap tracked in #1281. The widget source lives inpackages/chat-widget/; the public session endpoint lives inapi/src/public/.
What it is
| Aspect | Value |
|---|---|
| Package | @humanwork/chat-widget (packages/chat-widget/) |
| Build output | Single IIFE bundle dist/chat-widget.js, global HumanworkChatWidget, sourcemap emitted |
| Runtime deps | None — vanilla JS, no framework |
| Style isolation | All CSS class names prefixed humanwork-; styles injected once under #humanwork-chat-widget-styles |
| Mount point | A fixed-position container appended to document.body (bottom-right toggle button + 360×500 panel) |
| Realtime | Socket.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):
| Option | data-* attribute | Default | Purpose |
|---|---|---|---|
apiUrl | data-api-url | "" | Base URL of the h.work API. Required for any backend interaction. Trailing slashes are trimmed. |
orgSlug | data-org-slug | null | Client org slug. Used to mint a visitor session via POST /public/widget/session. Required unless a widgetToken is supplied. |
widgetToken | data-widget-token | null | A 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 | — | null | Optional caller-supplied visitor UUID. Otherwise resolved from localStorage (humanwork.chat.visitorId.{orgSlug}) or generated. |
containerId | — | humanwork-chat-widget | DOM id of the injected container. |
theme.primary | — | #4F46E5 | Accent 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:
- Visitor identity. A per-org visitor UUID is resolved in priority order:
explicit
visitorIdoption → in-memory token claim →localStorage(humanwork.chat.visitorId.{orgSlug}) → freshly generated UUID (persisted back tolocalStorage). This gives a returning visitor continuity of their threads on the same browser. - Session mint.
POST /public/widget/sessionwith{ 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 UUIDrole: "client",platformRole: "client"orgMemberships: [{ orgId, orgRole: "member", orgSlug, slug }]- TTL = 24h (
WIDGET_SESSION_TTL_SECONDS, #2925) — a workday without silent re-auth.
- Token use. The widget sends
Authorization: Bearer <token>on every REST and socket call. On401it transparently re-mints (whenorgSlugis set) and retries once. It proactively refreshes within a 5-minute skew of expiry (TOKEN_REFRESH_SKEW_MS). - Client-view masking. Because the token carries
platformRole: "client", every conversation response is passed throughClientConversationViewInterceptor(#2353) — Expert identity fields are stripped androle: "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 /conversationsfor the visitor:- 0 conversations → it creates one (
POST /conversationswith{ 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.
- 0 conversations → it creates 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 emitsleave_conversationfor the previous one. - Inbound realtime events on the
/notificationsnamespace: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
| Method | Path | Auth | Notes |
|---|---|---|---|
OPTIONS | /public/widget/session | Public | CORS preflight (Access-Control-Allow-Origin: *) |
POST | /public/widget/session | Public, 30/min | Mint 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(bothhttps:andwss:), 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.jsis out of scope of the package build (bun run buildproduces the bundle; publishing is manual).