Docs/Channels/Web widget

Web widget

The web widget is an embeddable chat surface for any HTML site. The loader is 1.2 KB; the full widget bundle is 47 KB gzipped and only loaded when the user opens the chat. Built with Preact + Vite, no React peer dependency.

Install

Paste a single tag in your HTML — anywhere in <body>, but before </body> is conventional:

<script
  src="https://cdn.neofir.com/loader.js"
  data-org="your-org-slug"
  data-api-key="pk_live_..."
  async></script>

Required attributes:

Optional attributes:

Configuration

All visual configuration (colour, welcome message, shortcuts, position, language) is set in the dashboard under Settings → Widget. The bundle reads them from the /widget/init response, not from script attributes. This keeps the embed snippet stable across rebranding.

Lifecycle

  1. Page loads → loader fetches widget.js asynchronously.
  2. Loader renders the chat bubble (no chat shell yet).
  3. User clicks the bubble → widget bundle loads if not already.
  4. Widget calls POST /widget/init with the visitor's persisted id (cookie-stored in neofir_vid_<slug>).
  5. /widget/init returns org config + conversation id (new or resumed) + welcome message.
  6. Widget renders the chat shell with the welcome message pre-populated.
  7. Subsequent messages POST /widget/message and receive an SSE stream of delta + tool_call + final events.

Visitor identity

The widget persists a visitor_id in localStorage. The same visitor across sessions resumes the same conversation if it is still open (status < 30 days). Visitor identifiers are random UUIDs — no PII is collected client-side without explicit user input.

SSE event format

Each event is a data: { ... } line followed by a blank line. Types:

{ "type": "delta", "content": "" }
{ "type": "tool_call", "tool": "check_availability", "args": { ... } }
{ "type": "final", "content": "", "chips": ["..."], "message_id": "..." }
{ "type": "error", "code": "RATE_LIMIT", "message": "..." }

CORS

Widget endpoints have open CORS (Access-Control-Allow-Origin: *). Origin restriction is enforced server-side: each org has an allowed_domains list, and the widget middleware rejects requests whose Origin header is not in the list (or whose Referer is empty, for older browsers).

Rate limits

EndpointLimitWindowNotes
POST /widget/init105 min / IPPrevents conversation-row spam.
POST /widget/message301 min / visitor_idPer-visitor message rate.
Org-wide201 min / orgCross-visitor cap.

Customization

Testing

Use data-api-key with the pk_test_* prefix to point at a staging org without affecting production usage counters. The widget appearance is identical.