Skip to main content

Embed the widget

Put a Wiselook assessment directly on your own website. End-to-end the flow is: provision a widget, wire your backend to mint visitor sessions, drop the bundle on your page. A complete runnable example is in step 4.

1. Provision a widget​

You need three things from your tenant portal:

  1. A widget. An admin creates it under the Settings gear → Widgets. A widget has a name; a channel, text or voice, fixed once created (make a new widget to change it); a default language for its visitors; a visitor-ID stability hint, whether the external_user_id your backend will pass is the same for a person across visits (stable), different on every visit (ephemeral) or a best effort (unknown); and its allowed origins: the exact origins of the pages that will embed it, one per line, scheme and host only, e.g. https://careers.example.com. A widget with no allowed origins is inert. No page can mount it. The widget's detail page shows its UUID (WIDGET_ID) and a ready-made embed snippet, and is where origins are added or removed and the widget is disabled or re-enabled.
  2. An API client with the widget_sessions:write permission. Copy the client id and secret; the secret is shown exactly once.
  3. An adopted methodology (Settings gear → Methodologies, or GET /v1/adopted-methodologies with the tenant_methodologies:read permission). Copy its UUID (METHODOLOGY_ID). The methodology is chosen per visitor session, not fixed on the widget, so one widget can serve different methodologies to different visitors if your backend decides so.

2. Wire your backend: mint a visitor session​

The widget runs in your visitor's browser and can't hold a long-lived secret. Instead, your backend mints a short-lived visitor JWT per session and hands it to the widget. The browser talks to your backend, your backend holds the client secret and calls Wiselook. The secret never reaches the browser.

First, a client-credentials token, as described in Authentication:

TOKEN=$(curl -s -X POST https://$WISELOOK_HOST/auth/application/o/token/ \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET" \
--data-urlencode 'scope=widget_sessions:write' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')

Then the visitor session:

curl -s -X POST "https://$WISELOOK_HOST/v1/widgets/$WIDGET_ID/sessions" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"external_user_id": "user-42",
"methodology_id": "<METHODOLOGY_UUID>"
}'
{
"session_jwt": "eyJhbGciOiJSUzI1NiIs…",
"expires_at": "2026-09-11T10:37:22Z",
"tenant_end_user_id": "7d3c1f42-…",
"is_new_visitor": true
}

external_user_id is your stable, opaque identifier for the visitor (your auth system's user id, your CRM's customer id). Do not pass PII such as emails or names. Wiselook finds or creates the visitor record from it, so repeat sessions for the same identifier map to the same visitor record, and is_new_visitor tells you which case you hit. methodology_id must be one of your adopted methodologies and names the assessment this session will run. Optional fields: locale, and an opaque metadata object stored on the visitor for your own attribution.

The session_jwt is what the widget mounts with in step 3. Notes:

  • The visitor JWT expires after about 15 minutes and is bound to the widget's allowed origins, so a leaked token can't be replayed from another site.
  • Mint one session per visitor per page load. The client-credentials token, on the other hand, is not per-visitor. Cache it until expiry (see the example below).
  • Re-minting for the same external_user_id resumes the same visitor and their assessment. That is how expiry mid-conversation is handled, via onSessionExpired in step 3.

3. Embed the bundle​

<div id="wiselook-root" style="height: 600px"></div>

<script type="module">
import { mount } from 'https://<WISELOOK_HOST>/widget/widget.esm.js';

const { session_jwt } = await fetch('/wiselook/session')
.then((r) => r.json());

mount({
sessionJwt: session_jwt,
target: document.querySelector('#wiselook-root'),
gatewayUrl: 'https://<WISELOOK_HOST>',
onSessionExpired: async () => {
const { session_jwt } = await fetch('/wiselook/session')
.then((r) => r.json());
return session_jwt;
},
});
</script>

/wiselook/session stands for your own backend endpoint from step 2. The widget reads its configuration (methodology, channel, branding) from Wiselook at mount time using the visitor JWT, then runs the assessment inside a Shadow DOM under target.

onSessionExpired is worth wiring from day one. A real conversation can outlive the 15-minute JWT. When a call comes back 401, the widget asks your callback for a fresh JWT, and because your backend re-mints for the same external_user_id, the assessment resumes instead of starting over.

Script-tag alternative​

The widget's detail page in the portal shows a declarative snippet that loads the auto-initialising bundle instead:

<div id="wiselook-root"></div>
<script src="https://<WISELOOK_HOST>/widget/widget.iife.js"
data-session-jwt="<SESSION_JWT>"
data-target="#wiselook-root"></script>

Your backend renders the freshly minted session_jwt into data-session-jwt on every page load; an optional data-gateway-url overrides the gateway. This form has no onSessionExpired hook. If the visitor JWT expires mid-conversation the widget tells the visitor the session has expired and asks them to refresh the page, rather than resuming. Use it for a quick first embed; use mount() for anything a visitor might spend a while on.

4. Complete example: a minimal Node backend​

Everything above, runnable: a Node 20 + Express server of about 100 lines that caches the access token, mints a visitor session per page load, and serves a page with the widget mounted. No database, no build step.

Create a directory with these four files.

package.json

{
"name": "wiselook-widget-example",
"private": true,
"type": "module",
"scripts": { "start": "node server.js" },
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.21.2"
},
"engines": { "node": ">=20" }
}

.env with the five values from step 1 (for production, use a real secret store rather than a file):

WISELOOK_HOST=wiselook.com
CLIENT_ID=<your client id>
CLIENT_SECRET=<your client secret>
WIDGET_ID=<your widget UUID>
METHODOLOGY_ID=<your adopted methodology UUID>

The widget's allowed origins must include http://localhost:3000 for this local run.

server.js

import express from 'express';
import { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import 'dotenv/config';

const { WISELOOK_HOST, CLIENT_ID, CLIENT_SECRET, WIDGET_ID, METHODOLOGY_ID } =
process.env;
const BASE = `https://${WISELOOK_HOST}`;

// One client-credentials token serves every visitor mint.
// Cache it; refresh 60s before expiry.
let cachedToken = null;

async function getAccessToken() {
if (cachedToken && Date.now() < cachedToken.expires_at_ms) {
return cachedToken.access_token;
}
const resp = await fetch(`${BASE}/auth/application/o/token/`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: 'widget_sessions:write',
}),
});
if (!resp.ok) throw new Error(`token mint failed: ${resp.status}`);
const { access_token, expires_in } = await resp.json();
cachedToken = {
access_token,
expires_at_ms: Date.now() + (expires_in - 60) * 1000,
};
return access_token;
}

async function mintVisitorSession(externalUserId) {
const token = await getAccessToken();
const resp = await fetch(`${BASE}/v1/widgets/${WIDGET_ID}/sessions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
external_user_id: externalUserId,
methodology_id: METHODOLOGY_ID,
}),
});
if (!resp.ok) throw new Error(`session mint failed: ${resp.status}`);
return resp.json();
}

const app = express();
const page = await readFile('./index.html', 'utf8');

app.get('/', async (_req, res) => {
// Demo visitor: a fresh id per page load. In production, use your
// real user identifier here (opaque id, never PII).
const externalUserId = `demo-${randomUUID().slice(0, 8)}`;
const session = await mintVisitorSession(externalUserId);
res.type('html').send(
page
.replaceAll('{{SESSION_JWT}}', session.session_jwt)
.replaceAll('{{WISELOOK_HOST}}', WISELOOK_HOST)
.replaceAll('{{EXTERNAL_USER_ID}}', externalUserId),
);
});

// Re-mint for the widget's onSessionExpired callback. Same
// external_user_id resolves to the same visitor, so the assessment
// resumes. A real backend derives the id from its own session rather
// than trusting a query parameter.
app.get('/session', async (req, res) => {
const externalUserId = String(req.query.external_user_id || '');
const session = await mintVisitorSession(externalUserId);
res.json({ session_jwt: session.session_jwt });
});

app.listen(3000, () => console.log('http://localhost:3000'));

index.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Acme careers assessment</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 760px;
margin: 2rem auto; padding: 0 1rem; }
#wiselook-root { height: 600px; border: 1px solid #e5e7eb;
border-radius: 8px; overflow: hidden; }
</style>
</head>
<body>
<h1>Welcome to Acme careers</h1>
<div id="wiselook-root"></div>

<script type="module">
import { mount } from 'https://{{WISELOOK_HOST}}/widget/widget.esm.js';

mount({
sessionJwt: '{{SESSION_JWT}}',
target: document.querySelector('#wiselook-root'),
gatewayUrl: 'https://{{WISELOOK_HOST}}',
onSessionExpired: async () => {
const res = await fetch(
'/session?external_user_id={{EXTERNAL_USER_ID}}',
);
const { session_jwt } = await res.json();
return session_jwt;
},
});
</script>
</body>
</html>

Run it:

npm install
npm start

Open http://localhost:3000: the widget mounts and the assessment greeting appears. The client secret stayed on the server; the browser only ever saw the short-lived visitor JWT.

The same flow works in any language that can speak HTTPS. The token call is a standard OAuth2 client-credentials grant, and the session mint is a plain POST with a Bearer token.

5. What the widget does​

  • Text channel streams responses over SSE, rendering token-by-token.
  • Voice channel opens a WebRTC session. The visitor talks with Claire, Wiselook's AI assessment agent, by voice.

When the assessment completes, scoring runs asynchronously. The visitor sees a completion state; results are available through your tenant portal and the Assessment API.

Troubleshooting​

SymptomLikely cause
401 invalid_client minting the tokenWrong client id or secret, or a stray newline or space pasted into the secret value.
401 minting the sessionToken expired, or the client lacks the widget_sessions:write permission.
422 minting the sessionMissing methodology_id, or an id that is not one of your adopted methodologies.
origin_not_allowed in the browser consoleThe embedding page's origin is not in the widget's allowed-origins list (exact match, scheme and port included).
Widget mounts but won't startVisitor JWT expired before mount; mint a fresh one per page load.
404 Widget not foundWrong WIDGET_ID, or the widget was disabled.