Skip to main content
The CoreBase widget is a Preact-based chat UI that lives in a shadow-DOM-isolated container, talks to the FastAPI backend over a server-signed JWT, and weighs ~67KB gzipped. Designed to drop into CRM / ERP panels — PHP included.

How it works

  1. Your backend mints a short-lived JWT signed with the tenant’s B2B secret.
  2. You load the widget script and call window.CorebaseWidget.init(...) with that JWT.
  3. The widget renders a floating launcher (or inline element) and forwards user queries to CoreBase with the JWT.
  4. CoreBase trusts the identity in that JWT — and can pass it through to your own systems so each user only sees their own data (see Scoping data per end-user).

Installation

There is no auto-mountinit() must be called explicitly so you control when the widget appears and which token it gets.

npm

API

Either authToken or getAuthToken is required. Use getAuthToken when your token needs to be refreshed periodically — the widget calls it before each request.

Theme & language

The widget ships dark by default. Pass theme: "light" for a light surface, or theme: "auto" to follow the visitor’s OS preference (and switch live if they do). primaryColor sets the accent in any theme. The interface is localized — pass locale: "tr" for Turkish, "en" for English, or omit it to auto-detect from the visitor’s browser. (Assistant replies are already in the visitor’s own language regardless of this setting.)

Conversation history

Each visitor can reopen their own past conversations from the header — a History button lists them, and a New button starts a fresh one. History is scoped strictly to the signed-in end-user (the JWT sub); a visitor never sees another user’s chats. Nothing to enable — it’s on whenever the widget is authenticated.

Generating the JWT (server side)

Sign with your B2B JWT secret (panel → Widget → Rotate secret). The secret never reaches the browser. Any standard JWT library works — sign HS256 with the B2B secret. A few lines in every stack:
Required claims: sub (end-user ID, used in audit logs) and exp — a token signed without an expiry is rejected outright, it doesn’t just live forever. Widget sessions are always end-user sessions: a role or is_admin claim is ignored, so a leaked or mis-signed token can never reach admin surfaces. Any other top-level claim you sign is available to claim-templated source headers as {{claim.<name>}}. See Security model for the full TTL/rotation/rate-limit picture. The tenant lookup uses publicId from the widget option, not a JWT claim — publicId is sent as an X-Public-Id header by the widget.

Token lifecycle and refresh

Keep the JWT short-lived — 15–30 minutes is a good default (the exp claim in the example above). A short TTL limits how long a leaked token stays useful, and the widget is built around refreshing quietly rather than needing a long-lived one. Refreshing: pass getAuthToken instead of a static authToken, and the widget calls it fresh before every single request — it never caches or reuses a token internally, so your endpoint is the source of truth for “is this still valid”:
Your /api/corebase-token endpoint re-runs the signing code above (fresh exp, current user) on every call. If minting is cheap, sign a new token every time; if not, cache a valid one server-side and only re-sign once it’s close to exp. On expiry: there’s no automatic retry inside the widget — it doesn’t catch a 401 and re-ask for a token mid-request. With getAuthToken, that’s fine in practice: since a fresh token is fetched before every call, an already-expired token simply doesn’t get sent as long as your endpoint returns a currently-valid one. With a static authToken, once exp passes every subsequent call fails until the page is reloaded with a new token — prefer getAuthToken for anything longer than a single page view. Rotating the B2B secret: rotating in the panel (Widget → Rotate secret) issues a new secret and invalidates every JWT signed with the old one immediately — the same no-overlap behavior as API token rotation. publicId stays the same across a rotation; only the signing secret changes, so existing embeds keep pointing at the right tenant once your token endpoint starts signing with the new secret. Rotate if the secret may have leaked (e.g. it ended up in a client bundle by mistake) — every active session gets a 401 on its next request.

Scoping the widget to a project

Every widget session runs inside exactly one project — fixed by which widget key’s publicId you embed, not by anything in the JWT. Each project has its own widget key: switch your active project in the panel, then open Widget to see or generate that project’s key. There’s no project_id claim to sign, and nothing in the token can redirect a session to a different project — the boundary is which key you used. To run against a different project — a second store, a staging environment, a different customer workspace — switch to that project in the panel and embed its own key’s publicId. To narrow within a project’s sources, add a source_ids claim — a list of source IDs the session may query (list yours with GET /api/v1/sources):
This claim is signed by your server, so the visitor can’t change it — a tampered request can narrow the scope but never escape it. It also combines with the widget access policy you set in the panel (allowed sources, read-only) — whichever is stricter wins. If no sources are allowed there yet, the widget can’t reach any data regardless of what this claim says.
Building a multi-workspace product on top of CoreBase — one project per customer store, branch, or environment? Give each project its own widget key and embed with that project’s publicId. Sessions stay fully apart — separate sources, separate history, separate business context — because the boundary is which key you used, not a claim a visitor’s browser could see or tamper with.

Per-user message limits

You can cap how many messages each end-user may send — useful to stop a single visitor from burning through your LLM budget. Limits are per user (keyed by the sub you sign), not a single shared pool, and you set them as extra claims in the JWT — so you stay in control: change a user’s limit by signing their next token with new values. Add any of these claims (all optional — omit one and the platform default for that period applies, so no visitor is ever unlimited):
When a user hits a limit, the request is rejected with HTTP 429 and the widget shows a short message; the counter resets at the start of the next window. Voice turns count too — a spoken question is the same governed turn as a typed one.
On CoreBase Cloud these limits are enforced out of the box, and a sensible default applies to any period you don’t set — a single visitor can’t run up your bill just because you left a claim out. On a self-hosted deployment, per-user limit enforcement requires Redis to be configured — without it, the limits are accepted but not enforced.
To build a per-user usage dashboard, call GET /api/v1/usage with a cb_live_ token: it returns each end-user’s live daily / monthly / per_minute counts. Since you signed the limits, compute remaining = your_limit − used.

Scoping data per end-user

When the widget is embedded in a multi-user product, each person should see only their own records — their orders, their tickets, their account. CoreBase lets you enforce that in your own backend, using the identity you already signed into the JWT. On a connected REST / API source, turn on “Scope requests to the signed-in end-user” (when adding or editing the connection). It adds a header that CoreBase fills from the verified widget token on every request and forwards to your API — by default X-On-Behalf-Of: <the user's sub>; both the header name and the claim are configurable there. Under the hood it’s a claim-templated header — any custom header’s value can use {{claim.<name>}}, so the equivalent manual setup is:
Your API receives the trusted end-user identity on every call and returns only that user’s data — the same per-user filtering your app already does for its own pages. CoreBase never lets the model choose the identity; it always comes from the signed token.
Fail-closed by design. A source whose identity header can’t be resolved is hidden, not queried. So a templated source is reachable only from a valid end-user widget session — never from panel chat, the Developer API, or a scheduled agent run (none of which carry an end-user identity). Pair this with a read-only source for defense in depth.
{{claim.sub}} is the most common, but any top-level claim you sign into the token works. To scope by something other than the user ID — a branch or sub-tenant, say — add it as a top-level claim alongside sub (not nested inside user_attrs) and reference it as {{claim.branch}}:
The enforcement lives in the API you connect; CoreBase guarantees the identity it forwards is the one you signed.

Connected accounts

The section above scopes your own API by an identity you sign — useful when you already have a backend with per-user data. For third-party SaaS apps (Gmail, Jira, and 50+ others), there’s no header to template: the org either has one shared Nango connection, or it doesn’t have access at all. Connected accounts closes that gap — each visitor links their own account, and the assistant acts only on it. No claim, no template: the isolation is the OAuth grant itself, so it works identically across every provider with zero setup per app.
This is separate from — and stronger than — attribute-based scoping. A per-user Gmail connection can’t leak another visitor’s inbox even by mistake; there’s no shared token to misconfigure.

Enabling it

In the panel, open Widget → End-user connections, pick an app from the catalog, and enable it. This creates a template: no account is connected yet — it just makes the app available for visitors to connect their own. The template is automatically added to the widget’s source allowlist (Widget access), since an unlisted source is invisible to the widget by default.

What visitors see

When at least one app is available, a plug icon appears in the widget’s header. Tapping it opens a hosted Connected accounts page in a new tab, where the visitor authorizes their own account through the provider’s normal OAuth screen — the same flow as connecting any app to their calendar or contacts elsewhere. They can revisit that page any time to add another account or disconnect one they no longer want linked.
No CoreBase account required. The portal page isn’t a login — it opens with a short-lived, one-time code minted by the visitor’s existing widget session. Their identity is still the sub you signed into the widget JWT; nothing new to authenticate.

What the assistant can do with it

Once connected, the app behaves like any other source scoped to that visitor: the agent can query and act in it (same governance as org-wide connectors — read-only unless you enable writes). Search (retrieval into Unified Context) isn’t available for personal connections yet — only the always-on org-level connectors feed search today.

Customizing the portal URL

On a self-hosted deployment, the portal page lives on your own CoreBase instance, not corebasehq.com — point the option at it:

Changes and approvals

The widget assistant never changes a visitor’s data on its own. When it wants to make a change, the visitor gets a plain-language prompt with Confirm and Cancel buttons, and nothing runs until they choose. They never see internal names, SQL, or arguments — only what the action does. If one of your guardrail rules holds the action instead (say, refunds over $100), the visitor is not asked. It goes to your team, who decide under Widget → Approvals in the panel. The held action then runs the next time the visitor writes, and the assistant tells them the outcome. Writes are off by default. A widget can only change anything if the source allows writes and the widget key itself isn’t read-only.

Voice mode

Visitors can talk to the assistant instead of typing. The spoken answers come from the exact same governed assistant as text chat — same data access, same read-only and per-user scoping — so nothing about your governance changes. Voice is bring-your-own-key, like your LLM provider:
  1. In the panel, go to Settings → Voice.
  2. Add your Deepgram key (speech-to-text) and your ElevenLabs key (text-to-speech). Optionally pick a voice and model.
  3. Toggle Enable voice on. The page shows a readiness check (keys set + an LLM key configured) so you know it’s live.
Then turn it on in the embed:
A mic button appears next to the composer. Tapping it asks for microphone permission, then the visitor can speak; the assistant replies out loud. If voice isn’t configured yet, the button surfaces a friendly “not set up” message rather than failing silently.
Keys are stored encrypted (AES-GCM, per-tenant) and never returned to the browser — the widget requests a short-lived session, it never sees your Deepgram/ElevenLabs keys.
The voice runtime is loaded lazily, only the first time a visitor starts voice — so enabling it adds nothing to the widget’s load for everyone who never taps the mic. If your site sends a strict Content-Security-Policy, allow cdn.jsdelivr.net (script) and your CoreBase voice host (connect) so voice can initialize.

Security

  • Short-lived JWTs, rejected once expired and refreshed client-side via getAuthToken. CoreBase also refuses a token minted to last longer than 24 hours — a credential that sits in a browser is only as safe as its remaining lifetime. Sign for minutes, not days.
  • Nothing secret ships to the browser. publicId is an identifier, not a key; the signing secret stays on your server. A stolen token is scoped to the one user it was signed for, and expires.
  • The transcript is the server’s. The widget sends your visitor’s new message; the conversation the model sees is rebuilt from what CoreBase stored, so a tampered client can’t put words in the assistant’s mouth.
  • DOMPurify sanitization — markdown rendered from model output is sanitized against XSS before insertion.
  • TLS 1.2+ for all traffic. Data handling depends on your deployment mode — see Zero Raw Data Egress for what crosses the wire in cloud-direct, cloud-relay, self-hosted, and air-gap.
The widget renders in a shadow DOM. That’s style isolation — your CSS and the widget’s never touch each other — not a security boundary: scripts running on the page can reach into it, as they can with any element. Treat the widget as part of your own page, and keep third-party scripts on pages that host it to ones you trust.

Going live

  1. Test on staging with a separate publicId.
  2. Switch to your prod publicId once policies are configured.
  3. Monitor under Dashboard → Widget for query volume and errors.