Integrar el widget
Lleva una evaluación de Wiselook directamente a tu propio sitio web. De principio a fin, el flujo es: aprovisionar un widget, conectar tu backend para que emita sesiones de visitante y colocar el bundle en tu página. Tienes un ejemplo completo y ejecutable en el paso 4.
1. Aprovisionar un widget
Necesitas tres cosas del portal de la organización:
- Un widget. Lo crea un administrador desde el engranaje de Configuración → Widgets.
Un widget tiene un nombre; un canal,
textovoice, que no cambia una vez creado (crea un widget nuevo para cambiarlo); un idioma predeterminado para sus visitantes; una indicación de estabilidad del ID de visitante, si elexternal_user_idque pasará tu backend es el mismo para una persona en todas sus visitas (stable), distinto en cada visita (ephemeral) o lo mejor posible (unknown); y sus orígenes permitidos: los orígenes exactos de las páginas que lo integrarán, uno por línea, solo esquema y host, p. ej.https://careers.example.com. Un widget sin orígenes permitidos queda inactivo. Ninguna página puede montarlo. La página de detalle del widget muestra su UUID (WIDGET_ID) y un código de integración listo para usar, y es donde se añaden o quitan orígenes y donde el widget se desactiva o se vuelve a activar. - Un cliente API con el permiso
widget_sessions:write. Copia el id y el secreto del cliente; el secreto solo se muestra una vez. - Una metodología adoptada (el engranaje de Configuración → Metodologías, o
GET /v1/adopted-methodologiescon el permisotenant_methodologies:read). Copia su UUID (METHODOLOGY_ID). La metodología se elige por sesión de visitante, no queda fijada en el widget, así que un mismo widget puede servir metodologías distintas a visitantes distintos si tu backend así lo decide.
2. Conecta tu backend: emite una sesión de visitante
El widget se ejecuta en el navegador de tu visitante y no puede guardar un secreto de larga duración. En su lugar, tu backend emite un JWT de visitante de corta duración por sesión y se lo entrega al widget. El navegador habla con tu backend, y tu backend guarda el secreto del cliente y llama a Wiselook. El secreto nunca llega al navegador.
Primero, un token client-credentials, como se describe en Autenticación:
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"])')
Después, la sesión de visitante:
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 es tu identificador estable y opaco del visitante (el id de usuario de tu
sistema de autenticación, el id de cliente de tu CRM). No pases datos personales como correos
electrónicos o nombres. Wiselook encuentra o crea el registro del visitante a partir de él, así
que las sesiones repetidas con el mismo identificador corresponden al mismo registro de
visitante, y is_new_visitor te dice en qué caso estás. methodology_id debe ser una de tus
metodologías adoptadas e indica la evaluación que ejecutará esta sesión. Campos opcionales:
locale, y un objeto metadata opaco que se guarda en el visitante para tus propios fines de
seguimiento.
El session_jwt es con lo que se monta el widget en el paso 3. Notas:
- El JWT de visitante caduca pasados unos 15 minutos y está ligado a los orígenes permitidos del widget, de modo que un token filtrado no puede reutilizarse desde otro sitio.
- Emite una sesión por visitante y por carga de página. El token client-credentials, en cambio, no es por visitante. Guárdalo en caché hasta que caduque (consulta el ejemplo de más abajo).
- Volver a emitir para el mismo
external_user_idretoma el mismo visitante y su evaluación. Así se gestiona la caducidad en mitad de la conversación, medianteonSessionExpireden el paso 3.
3. Integra el 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 representa tu propio endpoint de backend del paso 2. El widget lee su
configuración (metodología, canal, marca) de Wiselook al montarse, usando el JWT de visitante, y
después ejecuta la evaluación dentro de un Shadow DOM bajo target.
Merece la pena conectar onSessionExpired desde el primer día. Una conversación real puede
durar más que el JWT de 15 minutos. Cuando una llamada devuelve 401, el widget pide a tu
callback un JWT nuevo y, como tu backend vuelve a emitirlo para el mismo external_user_id, la
evaluación se retoma en lugar de empezar de nuevo.
Alternativa con etiqueta script
La página de detalle del widget en el portal muestra un código declarativo que, en su lugar, carga el bundle de inicialización automática:
<div id="wiselook-root"></div>
<script src="https://<WISELOOK_HOST>/widget/widget.iife.js"
data-session-jwt="<SESSION_JWT>"
data-target="#wiselook-root"></script>
Tu backend escribe el session_jwt recién emitido en data-session-jwt en cada carga de
página; un campo data-gateway-url opcional sustituye el gateway. Esta forma no tiene el
callback onSessionExpired. Si el JWT de visitante caduca en mitad de la conversación, el
widget avisa al visitante de que la sesión ha caducado y le pide que recargue la página, en
lugar de retomarla. Úsala para una primera integración rápida; usa mount() para cualquier cosa
en la que un visitante pueda pasar un buen rato.
4. Ejemplo completo: un backend Node mínimo
Todo lo anterior, listo para ejecutar: un servidor Node 20 + Express de unas 100 líneas que guarda en caché el token de acceso, emite una sesión de visitante por carga de página y sirve una página con el widget montado. Sin base de datos y sin paso de compilación.
Crea un directorio con estos cuatro archivos.
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 con los cinco valores del paso 1 (en producción, usa un almacén de secretos real en
lugar de un archivo):
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>
Los orígenes permitidos del widget deben incluir http://localhost:3000 para esta ejecución
local.
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>
Ejecútalo:
npm install
npm start
Abre http://localhost:3000: el widget se monta y aparece el saludo de la evaluación. El
secreto del cliente se quedó en el servidor; el navegador solo vio el JWT de visitante de corta
duración.
El mismo flujo funciona en cualquier lenguaje que hable HTTPS. La llamada del token es una concesión OAuth2 client-credentials estándar, y la emisión de la sesión es un POST normal con un token Bearer.
5. Qué hace el widget
- El canal de texto transmite las respuestas por SSE y las muestra token a token.
- El canal de voz abre una sesión WebRTC. El visitante habla con Claire, la agente de evaluación de IA de Wiselook, por voz.
Cuando la evaluación termina, la puntuación se calcula de forma asíncrona. El visitante ve un estado de finalización; los resultados están disponibles en el portal de la organización y en la API de Assessment.
Resolución de problemas
| Síntoma | Causa probable |
|---|---|
401 invalid_client al emitir el token | Id o secreto del cliente incorrectos, o un salto de línea o un espacio de más pegado en el valor del secreto. |
401 al emitir la sesión | El token ha caducado, o el cliente no tiene el permiso widget_sessions:write. |
422 al emitir la sesión | Falta methodology_id, o el id no es una de tus metodologías adoptadas. |
origin_not_allowed en la consola del navegador | El origen de la página que integra el widget no está en su lista de orígenes permitidos (coincidencia exacta, esquema y puerto incluidos). |
| El widget se monta pero no empieza | El JWT de visitante caducó antes de montarse; emite uno nuevo en cada carga de página. |
404 Widget not found | WIDGET_ID incorrecto, o el widget está desactivado. |