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:
- 1Your server mints a short-lived embed token from the Confiqure API (your API key never reaches the browser).
- 2Your page hands that token to the
@confiqure/embedSDK, 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)
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
| Field | Type | Required | Default | Since | Meaning |
|---|---|---|---|---|---|
endUserHandle | string ≤255 | yes | — | v1 | Your 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.0 | A 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. |
ttlSeconds | positive int | no | 24 h | v1 | Token lifetime override. |
organizationId | string 1–64, [A-Za-z0-9_-] (first char alphanumeric) | no | none | v1+ | 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. |
openingContext | object | no | none | v1+ | 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. |
feedback | boolean | no | false | v1+ | Opt-in end-of-chat survey for this surface. Absent/false = no survey. |
attachments | object | no | attachments OFF | v1+ | 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}/bytes | 302 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
datavalue; do not hand-roll a split where half ridesdatainline and the rest comes through a follow-up tool. Over the cap is a fast400at 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. AUSERendpoint always stays private per member, even with anorganizationIdpresent. 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 withGET .../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 keyedDELETE .../data/{endUserHandle}/{confiqureKey}removes one record from it (soft-delete + theconfig.deletedcallback, same as a chat delete;404if the key is unknown or already gone), and the acting user is recorded as the actor (auditability: deletes/writes/consent never lose who acted). AUSERendpoint — or an ORG endpoint used before you mint anorganizationId— stays private to that handle. The reservedorg-*handle form is not accepted on the data API: addressing any endpoint with anorg-*handle returns400. (The original guidance that told hosts to re-address shared reads/writes toorg-{organizationId}is withdrawn — mintorganizationIdonce, 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 browserNode:
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 pageSend 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)
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
| Param | Type | Required | Meaning |
|---|---|---|---|
endUserHandle | string | one of endUserHandle/orgId | Roll up costs for this end user. |
orgId | string | one of endUserHandle/orgId | Roll up costs for this org (org-scoped config). |
after | date YYYY-MM-DD | no | Inclusive lower bound (start of day). |
before | date YYYY-MM-DD | no | Inclusive 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" }| Field | Type | Required | Meaning |
|---|---|---|---|
language | string, non-blank, ≤ 35 chars | yes | BCP-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.
datais 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 liveRECEIVINGprogress block in the chat stream that settles toRECEIVED · N items · savedorFAILED+ a retry control. Single request, flat timeout — no chunking.datais 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/datacarries 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.Toolclasses; the chat opens with that tool class loaded, after anyreferentKeysrecords. An unknown name is ignored, never an error. See Opening Context.open()withoutintent/referentKeys/toolClass/databehaves exactly likeinit()(chat.submissionisnull).open()supersedes the mint-timeopeningContext(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:
- 1the page's live call —
chat.setStyle()/chat.setTheme(); - 2the page's
open()/init()options (they ride the iframe URL aslook=,theme=,accent=); - 3the dashboard setting — a
widgetModeoflightordarkapplies when the page passed nothemeor'auto', andfollow_hostleaves the mode to the page; - 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)
| Option | Type | Default | Meaning |
|---|---|---|---|
target | selector | HTMLElement | required | Where the chat iframe mounts. |
token | string | — | The minted embed token. Provide this or tokenUrl. |
tokenUrl | string | — | Your server endpoint the SDK fetches a fresh token from. |
endUserHandle | string | — | 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. |
accent | string | — | 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. |
autoResize | boolean | true | Iframe follows content height. |
baseUrl | string | https://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. |
apiBaseUrl | string | https://api.confiqure.ai | The confiqure API origin (used for frontend-tools discovery). |
tools | Record<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. |
toolTimeoutMs | number | 120000 | Per-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
accentthe 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; anaccentyou pass at open wins over it, and a livesetStyle({ accent })wins over both. - The colour is validated on both sides.
accentmust be a hex of 3, 4, 6 or 8 digits or anrgb()/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 withgetComputedStyle(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 likesetTheme(): 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 afterdestroy()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 anaccent.
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-ifunmount) instead of callingchat.destroy()— nothing can defer a DOM removal the SDK does not perform. Calldestroy(), 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
expat mount and re-mints ≈2 minutes before it expires, through yourtokenUrl— 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'sexp, 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
401with 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
completewebhooks stay idempotent (already required of hosts), so a duplicate finalize is safe. - Requires the loader. The per-tab id is minted by the
@confiqure/embedloader (0.1.6+) in first-partysessionStorageand 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)
baseUrlvsapiBaseUrl: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
@Confiqureclasses by exact field name — the GET envelope'sdatamap 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 andX-Confiqure-Delivery-Id(already your dedupe key) plus a newX-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:
onCompleteis 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'serrors/warningsmaps instead of a held webhook. Newconfig.savedevent notifies you when a draft's fields change, coalesced per instance per turn, so you no longer have to wait foronCompleteto learn data moved. A smallconfig.bulk_deletedscope-delete also now fires the per-keyconfig.deletedevent alongside the aggregate. Full wire contract: /docs/api/callbacks. - 2026-07-14 — SDK 0.2.1:
open()/submitpayloads 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 —
openingContextdeprecated in favor ofconfiqure.open({intent, referentKeys, data}); accepted during the migration window only.
| Version / change | What changed |
|---|---|
embed 0.3.2 — look + the dashboard look | Five 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 opening | Breaking, 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 branding | The 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 exp | A 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 replies | A 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 files | An 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 channel | New 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-compaction | The 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-only | organizationId 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 resolution | On 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.7 | Loader 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}/language | New 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.6 | Per-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 claim | End-of-chat survey becomes opt-in per token; absent = no survey. |
token: attachments claims | Per-surface attachment capabilities, server-enforced; absent = attachments off. ⚠️ Behavior flip for existing tokens — coordinate mints before adopting the release. |
token: openingContext | Chat opens bound to the launching UI action. |
token: organizationId | Org-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. |