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:
- A widget. An admin creates it under the Settings gear → Widgets. A
widget has a name; a channel,
textorvoice, fixed once created (make a new widget to change it); a default language for its visitors; a visitor-ID stability hint, whether theexternal_user_idyour 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. - An API client with the
widget_sessions:writepermission. Copy the client id and secret; the secret is shown exactly once. - An adopted methodology (Settings gear → Methodologies, or
GET /v1/adopted-methodologieswith thetenant_methodologies:readpermission). 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_idresumes the same visitor and their assessment. That is how expiry mid-conversation is handled, viaonSessionExpiredin 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
| Symptom | Likely cause |
|---|---|
401 invalid_client minting the token | Wrong client id or secret, or a stray newline or space pasted into the secret value. |
401 minting the session | Token expired, or the client lacks the widget_sessions:write permission. |
422 minting the session | Missing methodology_id, or an id that is not one of your adopted methodologies. |
origin_not_allowed in the browser console | The embedding page's origin is not in the widget's allowed-origins list (exact match, scheme and port included). |
| Widget mounts but won't start | Visitor JWT expired before mount; mint a fresh one per page load. |
404 Widget not found | Wrong WIDGET_ID, or the widget was disabled. |