Reference

The Confiqure embed token & SDK — canonical integration reference

This page is normative. Every claim, option, and event of the embed surface is documented here, and a release that changes any of them is incomplete until this page (including the changelog at the bottom) is updated in the same change set.

Embedding Confiqure is a two-step handshake:

  1. 1Your server mints a short-lived embed token from the Confiqure API (your API key never reaches the browser).
  2. 2Your page hands that token to the @confiqure/embed SDK, which frames the chat and wires lifecycle events + frontend tools back to you.

Widget branding. The chat header includes a small "Powered by Confiqure" attribution. The Confiqure logo links to confiqure.ai in a new tab, keeping your application and the chat open. The link uses a fixed destination and sends no referrer.


1. Minting the token (server-side)

POST https://api.confiqure.ai/api/{workspaceKey}/embed-tokens
Auth: Authorization: Bearer <your API key>.

A prod API key authorizes both the prod workspace and its sandbox sibling — mint against the workspace named in the URL ({workspaceKey} vs sb-{workspaceKey}); the token binds to that workspace, keeping sandbox and prod fully isolated.

Request body

FieldTypeRequiredDefaultSinceMeaning
endUserHandlestring ≤255yes—v1Your stable identifier for the end user. Per-user config is keyed by it. Must not begin with the reserved org- prefix (400 on reject — it is reserved for org-scoped storage handles).
~~configEnd~~———removed in annotation 3.0A chat is no longer bound to one endpoint. A mint that still sends configEnd is refused with 400 configEnd is no longer accepted; send openingContext (intent, referentKeys, data). The chat opens on what the screen names (referent records, a tool class) or finds what the user asks for.
ttlSecondspositive intno24 hv1Token lifetime override.
organizationIdstring 1–64, [A-Za-z0-9_-] (first char alphanumeric)nononev1+The end user's organization. When present, every org-scoped endpoint (the default) is shared across the org — see Org-scoped config below. Absent = every endpoint behaves per-user, exactly as before.
openingContextobjectnononev1+Superseded by confiqure.open() (SDK 0.2.0 — see §4): pass intent/referentKeys/data to open() instead; the mint goes back to identity/capability only. Still accepted for back-compat until existing integrations migrate, with the pre- behavior below: binds the chat open to the UI action that launched it (referent ownership + size validated at mint; 400 on reject; cap 16 KB serialized,; compacted to a size-reference after the chat saves it). Annotation 3.0: each referentKeys entry opens its record at session open, the last one current; optional toolClass (a @Confiqure.Tool name, e.g. "ListingsTool") opens that tool class too and is current (an unknown name is ignored, never an error). A resumed chat applies the context as well.
feedbackbooleannofalsev1+Opt-in end-of-chat survey for this surface. Absent/false = no survey.
attachmentsobjectnoattachments OFFv1+Per-surface attachment capabilities — see below.

attachments block

"attachments": {
  "enabled": true,                       // composer paperclip (file upload)
  "allowedTypes": ["image/*", ".csv"],   // MIME exact or type/*, and/or .ext; empty/omitted = no token narrowing
  "camera": true                         // mobile take-a-photo affordance (independent of `enabled`);
                                         // needs embed loader ≥ 0.1.7 on YOUR page — older loaders
                                         // hard-block the browser's camera prompt (see changelog)
}

Enforcement is server-side: the workspace's admin file_upload configuration is the ceiling; the token only ever narrows it. The widget hides affordances to match, but a direct upload outside the token's allowance is rejected by the API regardless. Absent block = no attachment affordances and no uploads accepted for this surface.

Image uploads become stored files

An image the end user attaches (paperclip, camera, or Ctrl/Cmd+V paste) is stored by confiqure — up to three images may ride one message (; a document is still one at a time), each becoming its own stored file — and when the chat saves it into a field the persisted value is a stable relative URL — "/files/f_…" — not a filename. String and List<String> image fields (e.g. an imageFileUrls list) therefore hold URLs your backend can actually fetch:

Endpoint (Bearer cqai_, same auth as the data API)Purpose
GET /api/{workspaceKey}/files/{fileId}Metadata (filename, mimeType, sizeBytes, provenance) + a fresh short-lived url to the bytes
GET /api/{workspaceKey}/files/{fileId}/bytes302 straight to a fresh signed byte URL (server-to-server convenience)
DELETE /api/{workspaceKey}/files/{fileId}Revoke — the stable URL 404s immediately and the workspace quota is credited

The bare GET {apiBase}/files/{fileId} (no auth — possession of the unguessable id is the authorization) also works in <img> tags and emails as a 302 to a short-lived signed URL.

Lifecycle rules worth knowing:

  • Persistence follows the save. An upload no saved value ever references is reclaimed after a few hours; the URL resolves only once the chat (or your data-API write) has saved it into a field, and keeps resolving until revoked.
  • Don't cache the resolved byte URL — it expires in minutes. Store the stable /files/{fileId} value and re-resolve when you need the bytes.
  • Overwrites auto-revoke. Saving a different value over a field that held a /files/… URL revokes the old file; the old URL 404s immediately after replacement.

openingContext — DEPRECATED (2026-07-14)

Deprecated — use confiqure.open({intent, referentKeys, data}) instead (section 4). Context that rides the mint transfers before the chat exists, so a large payload stalls a blank iframe with no surface to show progress on. confiqure.open() moves the same payload to after the chat opens, where the transfer renders as a first-class progress block and the conversation proceeds in parallel. openingContext is still accepted during the migration window, but new integrations must not use it, and it will be removed.

Bind the chat open to the UI action that launched it — the intent, any pre-selected referentKeys, and a flat data map (values are strings; intent ≤ 2000 chars, referentKeys ≤ 8, data ≤ 20 entries). The full field-by-field contract lives in the design doc, but two rules matter for sizing:

  • Put the whole payload in one place. The serialized-context cap is 16 KB (raised from 4 KB). A full bulk list — e.g. a 400-item watchlist the user just picked — fits in a single data value; do not hand-roll a split where half rides data inline and the rest comes through a follow-up tool. Over the cap is a fast 400 at mint (in your server logs), naming the limit. Under it, send it whole.
  • confiqure compacts it after the first save — you do nothing. The full context steers the opening turn; once the chat has saved the data, the platform automatically replaces the bulk block in the model's prompt with a size-reference (e.g. "asins: 401 items — persisted to /asin-discovery") and points the model at the saved instance for the rest of the session. Net effect: you send everything once, and the large payload stops costing tokens on every later turn without any host-side chunking.

Response

{ "token": "<signed JWT>", "jti": "<token id>", "expiresAt": "<ISO instant>" }

What's inside the JWT (informational — you never build it yourself)

typ=embed, workspaceId, workspaceKey, endUserHandle, optional source, apiKeyId, optional organizationId, and the capability claims feedback / attachments. (Annotation 3.0: a host token names no endpoint — pushHistoryId / configEnd appear only on confiqure's own staff test tokens.) Host-server-minted and signed — the browser can read it, but cannot forge or widen it.

Org-scoped config

Host systems are multi-user: an organization has an owner plus members. Some configuration is org-wide (suppliers, business model, repricer settings) — every member should see and edit ONE shared record — and some is personal (individual preferences, per-user credentials). Confiqure makes this a first-class, per-endpoint property.

  • The endpoint declares its scope, not the token: @Confiqure(dataScope = ORG) (the default) shares the record across the org; @Confiqure(dataScope = USER) opts an endpoint out so each member keeps a private record. Declared in source, never inferred.
  • You activate sharing by minting organizationId — and that is the ONLY place you ever send it. The mint is the single, signed declaration "this user belongs to this org." From then on the user→org association is confiqure's to maintain and consult: on an ORG endpoint the server stores and shares one record across the org internally, keyed off that binding, so all members converge on one instance. A USER endpoint always stays private per member, even with an organizationId present. The internal storage handle is not part of the host contract — you never see it, address it, or translate to it.
  • Graceful rollout. Until your mint starts sending organizationId, every endpoint behaves exactly as a per-user endpoint (byte-identical) — so you can deploy the endpoint changes first and turn on sharing later, per org, with zero migration.
  • Attribution. Lifecycle webhooks always carry the acting user's endUserHandle (never an org handle), and you never echo or supply an org id back on any call — derive the org from your own membership table, or fetch by key with GET .../objects/{confiqureKey}. Consent accepted by one member satisfies the gate org-wide; the audit row names the member who accepted.
  • Addressing data over the data API is by the end user's handle, always. You never translate a handle to an org form. On an ORG endpoint, GET/POST/DELETE .../data/{endUserHandle} resolves server-side to the user's org space — reads return the org record, writes land in it, a keyed DELETE .../data/{endUserHandle}/{confiqureKey} removes one record from it (soft-delete + the config.deleted callback, same as a chat delete; 404 if the key is unknown or already gone), and the acting user is recorded as the actor (auditability: deletes/writes/consent never lose who acted). A USER endpoint — or an ORG endpoint used before you mint an organizationId — stays private to that handle. The reserved org-* handle form is not accepted on the data API: addressing any endpoint with an org-* handle returns 400. (The original guidance that told hosts to re-address shared reads/writes to org-{organizationId} is withdrawn — mint organizationId once, then address by the user handle everywhere.)

Minting examples

Spring (Java):

record Attachments(boolean enabled, List<String> allowedTypes, boolean camera) {}
record MintRequest(String endUserHandle, Boolean feedback, Attachments attachments) {}
record MintResponse(String token, String jti, Instant expiresAt) {}

MintResponse minted = restClient.post()
    .uri("https://api.confiqure.ai/api/{ws}/embed-tokens", workspaceKey)
    .header("Authorization", "Bearer " + confiqureApiKey)   // server-side secret
    .body(new MintRequest(user.handle(), false,
        new Attachments(true, List.of("image/png", "image/jpeg"), true)))
    .retrieve()
    .body(MintResponse.class);
// hand minted.token() to your page; never expose confiqureApiKey to the browser

Node:

const res = await fetch(`https://api.confiqure.ai/api/${workspaceKey}/embed-tokens`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.CONFIQURE_API_KEY}`,
             'Content-Type': 'application/json' },
  body: JSON.stringify({
    endUserHandle: user.handle,
    attachments: { enabled: true, allowedTypes: ['image/png', 'image/jpeg'], camera: true }
  }),
})
const { token } = await res.json()   // pass to the SDK on your page

Send the attachments block on every mint (unchanged from 2.x): without it the widget shows no attach button, no paste and no ctrl+v.


2. Cost telemetry (host-facing)

GET https://api.confiqure.ai/api/{workspaceKey}/costs?endUserHandle=...|orgId=...&after&before

Auth: Authorization: Bearer <your API key> — same workspace API-key auth as the data API. The key's own workspace decides what it can see; a key can never pull another tenant's costs.

Returns confiqure's billable rate for AI usage (includes platform margin) in USD — not confiqure credits — for one end user or one org, rolled up over every chat call (and any atomic-agent call carrying a conversation, e.g. Summarizer/doc-analysis) in that scope.

Query parameters

ParamTypeRequiredMeaning
endUserHandlestringone of endUserHandle/orgIdRoll up costs for this end user.
orgIdstringone of endUserHandle/orgIdRoll up costs for this org (org-scoped config).
afterdate YYYY-MM-DDnoInclusive lower bound (start of day).
beforedate YYYY-MM-DDnoInclusive upper bound (end of day).

Exactly one of endUserHandle/orgId is required — both present or both absent is a 400. There is currently no whole-workspace rollup (omit both to get one).

Response

{
  "workspaceId": 42,
  "scope": "USER",
  "scopeKey": "user-42",
  "totals": { "calls": 5, "tokensIn": 1000, "tokensOut": 200, "cachedTokens": 800, "costUsd": 0.123456 },
  "byModel": [
    { "provider": "GOOGLE", "model": "gemini-3.1-flash-lite", "calls": 4, "tokensIn": 900,
      "tokensOut": 150, "cachedTokens": 800, "costUsd": 0.100000 }
  ]
}

costUsd is confiqure's billable rate for AI usage (includes platform margin) — the amount confiqure bills for this usage, cache-aware (). Build your own end-user pricing on top of it if you charge your users.


3. User language (host-facing)

PUT https://api.confiqure.ai/api/{workspaceKey}/users/{endUserHandle}/language — set it; GET the same path to read it back. Auth: Authorization: Bearer <your API key> — same workspace API-key auth as the data API. The key's own workspace is what's addressed; a key can never touch another tenant's users.

Set the end user's preferred language server-side so the embedded chat opens in that language from the first assistant message — no in-chat round trip. Use it when your dashboard already knows the locale (e.g. the user flips your language switcher): PUT for that user's handle and the next chat session greets and converses in the set language.

Request body (PUT)

{ "language": "tr-TR" }
FieldTypeRequiredMeaning
languagestring, non-blank, ≤ 35 charsyesBCP-47 tag (tr-TR, de) or a plain name (Turkish) — the assistant reads it as prose, so there is no BCP-47 whitelist. Trimmed; blank or > 35 chars is a 400.

The knowledge doc is created if the user has never chatted (you can pre-set a brand-new user), or updated in place otherwise — other stored knowledge (name, etc.) is preserved.

Response (PUT and GET)

{ "endUserHandle": "user-3", "language": "tr-TR" }

Idempotent; returns the effective stored value. GET returns "language": null when nothing has been set for that handle.

Last write wins — a host PUT overwrites the stored language; in-chat language learning may overwrite it later; the next PUT overwrites again. There is no precedence or source tracking. Language is per-member (user_knowledge is keyed by the real endUserHandle), never org-shared — so organizationId / org-scoped config does not affect it.

Scope: this is the whole feature — no mint-time language claim (new sessions already pick up the stored value) and no mid-session switch (an already-open session keeps its language and adopts the new one on its next session).


4. The SDK (@confiqure/embed)

import confiqure from '@confiqure/embed'

const chat = await confiqure.init({
  target: '#chat-panel',
  token,                            // from YOUR server (step 1)
})

Opening with context + data — confiqure.open() (— requires SDK ≥ 0.2.1)

0.2.1 data rule: every value in data must be plain, JSON-serializable data. The SDK normalizes via a JSON round-trip and throws a descriptive error on anything that can't survive it (class instances, functions, DOM nodes — and framework reactive proxies: unwrap Vue ref/reactive values with toRaw()/structuredClone-safe copies first). 0.2.0 dropped such payloads silently; 0.2.1 refuses loudly so you find out in development, not in a customer chat.

When a chat open is triggered by a UI action that carries context — a "Send to restocker" button with 500 selected items, a row's "configure this" action — use open() instead of init():

const chat = await confiqure.open({
  target: '#chat-panel',
  token,                                    // pure identity/capability — mint stays context-free
  intent: 'The user clicked "Send to restocker" on the discovery panel.',
  referentKeys: ['d20d07d7af15'],           // optional pre-selected instances (≤ 8, ownership-validated) — 3.0: each opens that record
  toolClass: 'ListingsTool',                // optional (embed ≥ 0.3.0) — opens the chat with this tool class loaded
  data: { restockList: [/* the real DTO shape, real field names */] }
})

try {
  const result = await chat.submission      // the hand-off's settled outcome
  console.log('delivered', result.confiqureKey, result.itemCount)
} catch (e) {
  console.error('submit rejected', e.result?.rejections)  // gate reasons, per field
}

How it behaves:

  • The session opens instantly, token-only — the chat paints immediately; no context ever delays (or rides) the open request.
  • data is a local hand-off, then one visible transfer. The payload crosses into the widget in page memory; every network byte then moves post-open through a single upload the end user can SEE — a live RECEIVING progress block in the chat stream that settles to RECEIVED · N items · saved or FAILED + a retry control. Single request, flat timeout — no chunking.
  • data is the real DTO shape keyed by your endpoint's real field names (exactly what the data API's GET returns) — no mapping, no stringifying. Every value passes the endpoint's full save-gate stack server-side and lands in the configuration draft. Max 10 MB serialized — over the cap is a clean rejection naming the limit; larger data belongs in the attachment pipeline.
  • The chat model never sees the payload. It receives a count-reference event ("host submitted restockList: 499 items — saved"), so a bulk hand-off neither stalls the first reply nor risks mistranscription — and a wrong shape rejects deterministically through chat.submission, in your console at click time, never as a model mistake three turns later.
  • The conversation runs parallel to the upload — the first question arrives while the transfer is still moving, and finalization waits for the transfer to land.
  • Trust note: page-JS intent/data carries the same trust level as the user typing into the chat, and is marked host-authored to the model.
  • toolClass (embed ≥ 0.3.0, annotation 3.0) names one of your @Confiqure.Tool classes; the chat opens with that tool class loaded, after any referentKeys records. An unknown name is ignored, never an error. See Opening Context.
  • open() without intent/referentKeys/toolClass/data behaves exactly like init() (chat.submission is null). open() supersedes the mint-time openingContext (still accepted for back-compat, scheduled for retirement).

Widget look

The chat ships five looks — each a full set of surfaces, text, borders, radii, shadows and input style, with a day and a night side: phosphor (Confiqure's own, the default), tairo (rounded controls, soft shadow), shadcn (flat, 6px radius, hairline borders), material (4px radius, elevation, underline inputs) and bootstrap (.375rem radius, the focus glow). Pick the one shaped like the kit your app is built with. The look, the colour mode and the accent are three separate choices: theme picks the day or night side (it has always meant the mode), and your accent sits on top of any look. A destructive action keeps its red and status colours keep their meaning in every look.

The dashboard setting needs no host code. Under Settings → chat widget look in the Confiqure dashboard, save widgetTheme (the look, default phosphor), widgetMode (follow_host, the default — your page decides light or dark, see Following your app's theme below — or light or dark) and widgetAccent (one colour, a hex of 3, 4, 6 or 8 digits or an rgb()/hsl() form; empty for none). The widget reads it every time it opens, so an app that embeds the chat with a bare open({ target, token }) already wears it. Live and sandbox are separate workspaces, so each keeps its own. The setting arrives with the chat's session, a moment after the first paint.

A page can override it with open({ look, theme, accent }) and, live, with chat.setStyle({ look?, theme?, accent? }) (embed ≥ 0.3.2 for look). For each of the three, the highest of these wins:

  1. 1the page's live call — chat.setStyle() / chat.setTheme();
  2. 2the page's open() / init() options (they ride the iframe URL as look=, theme=, accent=);
  3. 3the dashboard setting — a widgetMode of light or dark applies when the page passed no theme or 'auto', and follow_host leaves the mode to the page;
  4. 4for the mode only, the visitor's operating system.

An unknown look name is ignored with a console warning, and the chat checks every value again on its side before it paints anything.

Init options (complete)

OptionTypeDefaultMeaning
targetselector | HTMLElementrequiredWhere the chat iframe mounts.
tokenstring—The minted embed token. Provide this or tokenUrl.
tokenUrlstring—Your server endpoint the SDK fetches a fresh token from.
endUserHandlestring—Only with tokenUrl: forwarded to your token endpoint.
theme'light' | 'dark' | 'auto''auto'The mode the chat opens in. 'auto' follows the dashboard's mode when it is light or dark (see Widget look), else the visitor's OS — if your app has its own light/dark toggle, pass your current mode here and call chat.setTheme() when it flips.
look'phosphor' | 'tairo' | 'shadcn' | 'material' | 'bootstrap'the dashboard's (else 'phosphor')The chat's ready-made look (embed ≥ 0.3.2) — surfaces, radii, shadows and input style shaped like your app's UI kit; theme still picks its day or night side. Overrides the dashboard setting for this page. An unknown name is ignored with a console warning. Change it live with chat.setStyle(). See Widget look.
accentstring—Your app's one accent hue (embed ≥ 0.3.1) — a hex of 3/4/6/8 digits ('#6b5bff') or an rgb()/hsl() form. The chat paints its accented parts with it and keeps its own light/dark surfaces. Anything else is ignored with a console warning. Change it live with chat.setStyle(). See Your accent colour.
autoResizebooleantrueIframe follows content height.
baseUrlstringhttps://confiqure.ai⚠️ The confiqure PAGE origin that serves the chat iframe — never the API origin. Passing an api.* origin (or the same value as apiBaseUrl) throws at init — this guard exists because the confusion broke a production integration.
apiBaseUrlstringhttps://api.confiqure.aiThe confiqure API origin (used for frontend-tools discovery).
toolsRecord<string, ToolHandler>—Handlers for your browser operations — the @Confiqure.Browser methods of your @Confiqure.Tool classes, keyed "ToolClassName.operationName" (e.g. "ListingsTool.openProduct360"; embed ≥ 0.3.0). Missing handlers are warned about at init. See Frontend tool handlers.
toolTimeoutMsnumber120000Per-tool-call timeout before the call aborts and reports an error to the chat.

Lifecycle events (complete, with exact payloads)

chat
  .on('ready',    () => {})                                  // iframe loaded, session open
  .on('complete', ({ confiqureKeys }) => {})                 // flow finalized; string[] of instance keys —
                                                             // pull values via GET /objects/{key}
  .on('error',    ({ code, message }) => {})                 // both host-side (e.g. ready watchdog) and chat errors
  .on('closed',   ({ reason }) => {})                        // the chat closed; reason string
chat.destroy()                                               // unmount + abort in-flight tool handlers;
                                                             // flushes pending tool replies first (≤2s)
chat.setTheme('light')                                       // 'light' | 'dark' | 'auto' — recolour live,
                                                             // no reload (embed 0.2.3)
chat.setStyle({ look: 'shadcn', theme: 'dark', accent: '#6b5bff' })
                                                             // the look, the mode, the accent hue — any mix,
                                                             // live, no reload (embed 0.3.1; look 0.3.2)

Following your app's theme. theme: 'auto' follows the dashboard's mode when it is light or dark, else the visitor's operating system — not your app, so with the dashboard on follow_host a light app on a dark-mode computer shows a dark chat. If your app has its own toggle, open the chat in your current mode and forward every flip:

const chat = await confiqure.init({ target, token, theme: isDark() ? 'dark' : 'light' })
onYourThemeToggle((dark) => chat.setTheme(dark ? 'dark' : 'light'))

setTheme() recolours the open chat immediately — no reload, the conversation stays put. A call made before the chat is ready is applied as soon as it is; after destroy() it does nothing.

complete is the moment to act on the flow's result (swap your panel, refresh your data via the data API). No survey renders after completion unless your token opted in — your completion handling is never raced by one.

Your accent colour

Hand the chat your app's one accent hue: it fills the primary buttons and the send button with it, and tints links, focus rings and the agent mark with it. Pass it at open, and change the mode, the hue, or both live with chat.setStyle() (embed ≥ 0.3.1):

const chat = await confiqure.open({ target, token, theme: 'light', accent: '#6b5bff' })
onYourBrandChange((mode, hue) => chat.setStyle({ theme: mode, accent: hue }))   // either key, or both
  • The chat keeps its look's surfaces. Its light and dark backgrounds, text and hairlines stay those of the look per mode; only the accented parts take your hue, and hover and focus shades are derived from it. Links, focus rings and the agent mark are your hue blended toward the look's own text colour, so they stay readable on either background. The accent sits on top of any look. A confirmation on a card (consent, delete) keeps the look's neutral button, a destructive action keeps its red, and status colours keep their meaning. Without an accent the chat looks exactly as its look always has.
  • No code needed for a fixed hue. The dashboard's widgetAccent (Settings → chat widget look) paints the chat with no host code; an accent you pass at open wins over it, and a live setStyle({ accent }) wins over both.
  • The colour is validated on both sides. accent must be a hex of 3, 4, 6 or 8 digits or an rgb()/hsl() form (comma or space syntax). A named colour, var(--brand) or anything else is ignored with a console warning, and the chat checks the value again with the same rule before it paints anything. Surrounding whitespace is trimmed, so a value read with getComputedStyle(el).getPropertyValue('--brand') works as is. An alpha is accepted but dropped: the chat paints your hue opaque.
  • One hue serves both modes. Text on a filled button is white or near-black, whichever reads better on your hue. If your hue only reads well in one mode, send the mode and the hue together (setStyle({ theme, accent })).
  • setStyle() behaves like setTheme(): an invalid half is ignored with a warning while the valid half still applies, a call made before the chat is ready is applied as soon as it is, and after destroy() it does nothing. setTheme(mode) keeps working exactly as before.
  • An accent cannot be cleared live. setStyle() only changes it to another valid colour; to drop it, re-open the chat without an accent.

Frontend tool handlers

Keys are "ToolClassName.operationName" — the tool class's name and the @Confiqure.Browser method's name (annotation 3.0, embed ≥ 0.3.0):

tools: {
  'ListingsTool.openProduct360': async (input, ctx) => {
    // ctx: { input, endUserHandle?, conversationId, workspaceKey, progress(msg), signal }
    return { ok: true }
  }
}

Since 0.1.5, dispatch is reconnect-safe: a call issued while the page was backgrounded (mobile tab suspension) is re-delivered on stream reconnect and de-duplicated by sessionId — handlers never double-fire, and a dispatch to a page with no matching handler is NACKed so the backend can replay it later instead of stalling the turn.

Unmounting from inside a tool handler. A tool whose action is to tear the chat down — navigate away, close the modal, lift an onboarding lockdown — used to race, and lose, its own reply: the result is posted to the widget only after your handler resolves, so an already-removed iframe swallowed it and the chat stalled until the tool session expired (5 min). chat.destroy() now flushes first: it returns immediately, but the actual unmount is deferred while a handler is still running or a posted result has not yet been confirmed by the chat — hard-capped at 2s, and instant when nothing is in flight. A result that still cannot be delivered is a loud console.error, never a silent drop.

Two things this does not cover, both on your side:

  • Removing the container element yourself (a React/Vue v-if unmount) instead of calling chat.destroy() — nothing can defer a DOM removal the SDK does not perform. Call destroy(), or return from the handler before triggering the unmount.
  • Handlers that never resolve. Past the 2s cap the SDK gives up and reports the drop.

Token lifecycle: expiry, auto-refresh, reconnect

An embed token has a finite TTL — you choose it at mint (ttlSeconds), and it is typically under an hour. A chat panel, though, can sit open on a page far longer than that. The SDK therefore keeps the session's token alive for you; there is nothing to call and no new endpoint to build.

  • Proactive refresh. The SDK reads the token's exp at mount and re-mints ≈2 minutes before it expires, through your tokenUrl — the same call it made at load. The new token is handed to the running chat in place: the conversation, transcript and scroll position are untouched, and nothing is visible to the user. It re-schedules off each new token's exp, so a panel left open all day never expires. (On a deliberately short TTL the refresh is clamped to no earlier than halfway through the remaining life, so a 60-second test token refreshes once at ~30s rather than in a loop.)
  • Reactive recovery. If a call still lands on an expired token — a suspended laptop, a refresh that failed — the platform answers 401 with the body {"code":"TOKEN_EXPIRED"}. The chat then holds the user's message, asks the SDK for a fresh token, and retries that send exactly once. The message is delivered; the user sees a brief "Session expired — reconnecting…" line and their own bubble marked Sending… until it lands.
  • {"code":"AUTH_FAILED"} is the body for every other 401 (missing, malformed, mis-signed, wrong type, revoked). It is deliberately opaque — the wire never explains why a non-expired token failed — and it is never retried: re-minting cannot fix it.
  • When recovery is impossible, the chat says so plainly ("Session expired — please refresh the page") and retires the composer. It never renders a raw error, a request URL, or token material into the transcript.

Requirement — mount with tokenUrl, not a literal token, if you want this. Refreshing means minting, and only your server can mint. With tokenUrl (+ endUserHandle) the SDK refreshes silently. With a literal token there is no mint path: the SDK logs a clear console.error and the user is asked to reload. Your token endpoint should mint a fresh token per request (don't cache one for the user's whole day).

const chat = await confiqure.init({
  target: '#chat-panel',
  tokenUrl: '/api/confiqure-token',   // your server mints; the SDK re-calls it before exp
  endUserHandle: user.id
})

Version skew is safe both ways: an older widget ignores the refresh message, and a newer widget against an older loader falls back to the "please refresh" state after a short bounded wait instead of hanging.

Multi-tab behavior

Each browser tab is its own conversation space. Open the same endpoint in a second tab and it starts a fresh conversation — it is not joined to, and does not disturb, the chat running in the first tab. The new tab still opens knowing everything already saved (the agent's greeting reflects the user's stored config); it simply does not replay the other tab's in-flight transcript. Reloading (F5) within a tab resumes that tab's own chat as before.

  • Concurrent edits are last-write-wins. If two tabs edit the same record, the later save wins — this is the user's own doing across their own tabs; confiqure does not lock or merge. Every write still passes the endpoint's save gates, and complete webhooks stay idempotent (already required of hosts), so a duplicate finalize is safe.
  • Requires the loader. The per-tab id is minted by the @confiqure/embed loader (0.1.6+) in first-party sessionStorage and forwarded to the chat, so it survives iframe re-mounts and reloads within the tab and is fresh in a new tab. On an older loader (or any non-loader open) no tab id is sent and the pre-0.1.6 behavior applies unchanged — a returning user resumes their most recent live session regardless of which tab it was in. Nothing you mint or configure changes; upgrade the loader to adopt per-tab spaces.

5. Gotchas (earned the hard way)

  • baseUrl vs apiBaseUrl: baseUrl = the page origin framing the chat; apiBaseUrl = the API. The SDK throws at init if you cross them.
  • Field names are a binding contract: whatever the chat saves deserializes into your @Confiqure classes by exact field name — the GET envelope's data map matches your DTO verbatim.
  • Keys are system-minted: never construct a confiqureKey. Create keyless (the response returns the minted key), then patch by that key.

6. Changelog (token & SDK surface)

  • 2026-08-21 — bounded retry for onComplete: onComplete — the one callback event with no later self-healing signal — is now retried up to 3 total attempts (1 initial + 2 retries, backoff ~5s then ~20s) on a timeout, 5xx, or connect failure, off the request thread. Every other event (onStart, config.saved, onSessionInterrupted, onSessionTimeout, config.deleted, config.bulk_deleted) is unchanged — still a single best-effort POST. Retries carry the exact same payload and X-Confiqure-Delivery-Id (already your dedupe key) plus a new X-Confiqure-Delivery-Attempt: <1|2|3> header; the signature (when configured) is recomputed per attempt so a replay-window check doesn't reject a later retry as stale. Ack fast — a merely slow handler can now trigger a retry it didn't need. Full wire contract: /docs/api/callbacks.
  • 2026-07-27 — callback lifecycle redesign: onComplete is now a conversational signal (the user said they're done), not a data-readiness judgment — a record that doesn't quite fit your DTO shape still delivers, with its issues in the envelope's errors/warnings maps instead of a held webhook. New config.saved event notifies you when a draft's fields change, coalesced per instance per turn, so you no longer have to wait for onComplete to learn data moved. A small config.bulk_deleted scope-delete also now fires the per-key config.deleted event alongside the aggregate. Full wire contract: /docs/api/callbacks.
  • 2026-07-14 — SDK 0.2.1: open()/submit payloads are JSON-normalized with loud validation (throws on non-plain values instead of silently dropping them — the DataCloneError class of bug). Reinstall + clear your bundler's dep cache (Vite: node_modules/.vite) when upgrading.
  • 2026-07-14 — openingContext deprecated in favor of confiqure.open({intent, referentKeys, data}); accepted during the migration window only.
Version / changeWhat changed
embed 0.3.2 — look + the dashboard lookFive ready-made looks — phosphor (default), tairo, shadcn, material, bootstrap — each a full token set with a day and a night side. Pick one per page with open({ look }) (URL look=) or live with chat.setStyle({ look }); theme keeps meaning the mode and accent sits on top of any look. The workspace's saved look, mode and accent (Settings → chat widget look) now apply with no host code; the page's live call, then its open() options, then the dashboard setting, then (for the mode) the OS decide. An unknown look is ignored with a console warning. Additive: an older widget ignores look, and nothing changes for a workspace that keeps phosphor / follow_host / no accent. See Widget look.
embed 0.3.1 — accent + chat.setStyle()Pass your app's one accent hue at open (accent: '#6b5bff', a hex of 3/4/6/8 digits or an rgb()/hsl() form) and the chat fills its primary buttons and send button with it and tints its focus rings, links and agent mark, keeping its own light/dark surfaces (card confirmations stay neutral; alpha is dropped; an accent cannot be cleared live). chat.setStyle({ theme?, accent? }) changes the mode, the hue or both live. The colour is validated in the SDK and again in the chat; anything else is ignored with a console warning. Additive: setTheme() is unchanged, older widgets ignore the new message, and nothing changes for hosts that never pass an accent. See Your accent colour.
embed 0.3.0 — annotation 3.0 openingBreaking, ships with the 3.0 cutover. A token names no endpoint: the SDK mounts without a configEnd claim and opens the chat at the workspace root; the configEnd init option is removed and tokenUrl forwards only endUserHandle (the mint refuses configEnd). open() accepts toolClass — the chat opens with that tool class loaded. Browser-operation handlers are keyed "ToolClassName.operationName". See Opening Context.
Widget — linked brandingThe existing Confiqure logo in the chat header opens confiqure.ai in a new tab. The host page and conversation stay open; the link sends no referrer. Hosted widget change; no SDK option or version change.
embed 0.2.3 — chat.setTheme()Switch the chat between 'light', 'dark' and 'auto' live, from your own theme toggle — no reload. Pair it with the theme init option (your current mode at open). Additive: older widgets ignore the message, and nothing changes for hosts that never call it.
embed + token: the session no longer dies at expA chat left open past its token's TTL used to break silently — the next message got a bare 401, the raw request (JWT and all) rendered into the transcript, and the message was lost. Now: the SDK re-mints through your own tokenUrl ≈2 min before exp and swaps the token into the running chat invisibly; a call that still hits an expired token gets 401 {"code":"TOKEN_EXPIRED"}, and the widget holds the message, refreshes, and retries it once — nothing is dropped. Every other 401 is an opaque {"code":"AUTH_FAILED"} and is never retried. No raw error, URL, or token material can reach the transcript any more; an unrecoverable session shows "please refresh the page". Adopt by mounting with tokenUrl rather than a literal token — only your server can mint, so a literal token still cannot be refreshed. Skew-safe both directions. See Token lifecycle.
embed: destroy() flushes in-flight tool repliesA frontend tool whose action unmounts the widget no longer loses its own reply. chat.destroy() defers the actual teardown (≤2s, instant when nothing is in flight) until every running handler has replied and the chat has confirmed it forwarded the result to confiqure; a result that still can't be delivered is a console.error instead of a silent no-op. Additive and version-skew safe in both directions — an older widget simply doesn't confirm, and the SDK falls back to a short grace timeout. Removing the container yourself instead of calling destroy() is still your race to avoid.
host API: image uploads → stored filesAn attached image is persisted by confiqure; a saved image field holds a stable relative /files/{fileId} URL (not a dead filename). New host surface: GET/DELETE /api/{ws}/files/{fileId} (+ /bytes) to resolve metadata/bytes or revoke. Uploads never referenced by a save are reclaimed after a few hours. See Image uploads become stored files.
embed 0.2.0 — confiqure.open() + the submit channelNew top-level open({ token, intent, referentKeys, data }): the session opens instantly token-only, then the context auto-submits post-open — data moves as one visible transfer (live RECEIVING progress block → RECEIVED · N items · saved / FAILED + retry), passes the endpoint's save gates server-side (10 MB cap), and lands in the draft; the model gets a count-reference, never the payload. Outcome on chat.submission (a wrong shape REJECTS there, with per-field gate reasons). init() unchanged; mint-time openingContext is superseded (still accepted for back-compat until integrations migrate).
token: openingContext cap 4 KB → 16 KB + auto-compactionThe serialized-context cap is raised to 16384 chars so a host sends a whole bulk payload in one place (no more splitting a big list across inline data + an overflow tool). Over the cap still 400s at mint, naming the limit. Separately, the chat runtime now compacts the context to a count/size reference (e.g. "asins: 401 items — persisted to /endpoint") on the first turn AFTER the chat saves the data, so the large payload stops riding the prompt on every later turn — host-transparent, no adoption step.
data API: org id is mint-onlyorganizationId is transmitted in exactly ONE place — the embed-token mint — and nowhere else on the host-facing surface. No org query/body parameter on data-API calls, no org handle in paths, no org field to echo on webhooks or object fetches. The user→org binding is declared once at mint; the platform maintains and consults the registry. Exemption: GET /costs?orgId= (read-only cost telemetry, §2) is unaffected. The "re-address server calls to org-{id}" adoption step is withdrawn.
data API: server-side org resolutionOn an ORG-scoped endpoint (@Confiqure(dataScope=ORG), the default), the data API resolves a plain user-{id} handle to that user's org space server-side — reads/writes/lists land on the shared org record and the acting user is recorded as the actor. Hosts never translate handles. The reserved org-* handle form is now rejected (400) on the data API (it was previously the addressing convention). A user with no org mint on record, or a USER-scoped endpoint, is unchanged (private per handle).
embed 0.1.7Loader iframe now delegates camera via permissions-policy (allow="clipboard-write; camera") so a token minted with camera:true can drive an in-chat take-photo (getUserMedia); previously the affordance was hard-blocked by the browser regardless of the claim. Additive and default-safe — the allow attribute only permits the browser's own permission prompt, which the user still grants. Mic stays out until needed.
host API: PUT/GET /users/{handle}/languageNew host-facing per-user language endpoint — set/read the end user's preferred language server-side; a new chat session then opens in it. Same API-key auth as the data API; last-write-wins, per-member. See User language.
host API: GET /costs ()Host-facing cost telemetry endpoint — confiqure's billable rate for AI usage (USD, includes platform margin) by endUserHandle or orgId, same API-key auth as the data API. See Cost telemetry.
embed 0.1.6Per-tab conversation spaces: the loader mints a per-browser-tab id in first-party sessionStorage and forwards it so each tab is its own conversation (new tab = fresh chat that knows saved state; F5 still resumes). Additive — an older loader / null tab id keeps the previous single-conversation-per-user resume. See Multi-tab behavior.
embed 0.1.5 (2026-07-07)Reconnect-safe frontend-tool dispatch: no-handler NACK + sessionId dedupe on stream reconnect.
token: feedback claimEnd-of-chat survey becomes opt-in per token; absent = no survey.
token: attachments claimsPer-surface attachment capabilities, server-enforced; absent = attachments off. ⚠️ Behavior flip for existing tokens — coordinate mints before adopting the release.
token: openingContextChat opens bound to the launching UI action.
token: organizationIdOrg-scoped config — an ORG endpoint (the @Confiqure(dataScope=…) default) shares one record across the org under org-{organizationId}; USER opts out. Reserved org-* endUserHandle rejected. Behavior is byte-identical until you mint an organizationId, so it deploys safely ahead of adoption. Needs annotation 1.6.1.