Leem

Widget

Widget de chat

Ajouter la bulle de chat à votre site : session côté serveur, balise script, outils dans la page, webhook, erreurs et CSP.

Le widget est une bulle de chat que vous ajoutez à votre site avec une balise <script>. leem fournit l'interface, l'historique des discussions et les limites anti-abus ; la réponse vient d'un ou de plusieurs agents API de votre organisation. Les personnes qui l'utilisent ne sont pas des membres leem : pas de compte, pas de siège, et jamais de clé API dans le navigateur.

Le principe

  1. Vous créez le widget dans la console (espace Développeurs, onglet Widgets) : équipe d'agents, apparence, domaines autorisés, support, limites par utilisateur, URL de webhook. Le widget est une option de la formule : sans elle, la section de la console l'explique et l'ouverture d'une session répond 403 widget_unavailable.
  2. Quand votre utilisateur est connecté, votre serveur demande une session à leem avec votre clé secrète : il reçoit un jeton court.
  3. Votre page charge le script et lui passe ce jeton. Le jeton est limité à ce widget et à cet utilisateur ; il ne donne accès à rien d'autre.

Le script et les routes widget/v1/* qu'il appelle ne font pas partie de l'API /v1 : seul ce qui suit est un contrat.

1. Ouvrir une session (votre serveur)

POST /v1/widgets/{widget}/sessions, avec une clé qui porte le droit runs. L'identifiant wgt_… du widget est affiché dans la console.

bash
curl https://leem-api.lcom-corp.com/v1/widgets/$WIDGET_ID/sessions -X POST \
  -H "Authorization: Bearer $LEEM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "user": { "id": "u_42", "name": "Rina A." }, "context": { "plan": "pro", "page": "/factures" } }'
json
{ "data": { "token": "...", "expires_at": "2026-10-31T11:00:00+00:00", "user": { "id": "u_42" }, "agents": ["agt_finance", "agt_rh"] } }
  • user.id (1 à 128 caractères) est votre identifiant : la même personne garde la même identité sur tous vos widgets. user.name est facultatif (120 caractères au plus).
  • agents (facultatif) est la liste des agents de l'équipe du widget auxquels cette personne a droit, voir « Équipe d'agents » ci-dessous. Sans ce champ, elle a accès à toute l'équipe. La réponse renvoie la liste retenue.
  • Aucun autre champ n'est accepté, ni à la racine ni dans user (400 unknown_parameter).
  • context est un objet libre, de 4 096 octets au plus une fois encodé en JSON, donné à l'agent comme information, jamais comme instruction, et repris dans le webhook de vos outils serveur. N'y mettez aucun secret.
  • Le jeton dure de l'ordre d'une heure. Transmettez-le à la page et ne le journalisez pas. La route n'est jamais rejouée par Idempotency-Key (409 idempotency_key_sensitive_replay).
js
// Server side only: the secret key never reaches the browser.
const res = await fetch("https://leem-api.lcom-corp.com/v1/widgets/" + widgetId + "/sessions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.LEEM_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ user: { id: String(currentUser.id), name: currentUser.name } }),
});
if (!res.ok) throw new Error(`Leem session refused: ${res.status} ${await res.text()}`);
const { data } = await res.json(); // data.token, data.expires_at

2. Charger le script (votre page)

Le script est servi par leem : https://leem.lcom-corp.com/widget/v1/leem-widget.js. Le bloc window.Leem = window.Leem || { q: [] } se place avant la balise du script : le script, chargé en async, rejoue à son arrivée ce que la page a mis dans la file q.

Variante A, simple : le script démarre seul depuis ses attributs.

html
<script>window.Leem = window.Leem || { q: [] };</script>
<script src="https://leem.lcom-corp.com/widget/v1/leem-widget.js"
  data-widget="wgt_..."
  data-token="TOKEN_FROM_YOUR_SERVER"
  data-api="https://leem-api.lcom-corp.com"
  async></script>

Variante B, programmatique, avec renouvellement du jeton : la balise ne porte aucun attribut de jeton.

html
<script>
  window.Leem = window.Leem || { q: [] };
  window.Leem.q.push([
    "init",
    {
      widget: "wgt_...",
      token: "TOKEN_FROM_YOUR_SERVER",
      apiUrl: "https://leem-api.lcom-corp.com",
      onTokenExpired: () => fetch("/leem/session").then((r) => r.text()), // returns a new token
    },
  ]);
</script>
<script src="https://leem.lcom-corp.com/widget/v1/leem-widget.js" async></script>

Dans la file, chaque entrée est [méthode, ...arguments]. Une fois le script chargé, window.Leem expose directement :

MéthodeEffet
Leem.init({ widget, token, apiUrl, onTokenExpired, tools, navigate })Démarre le widget (variante B). apiUrl est l'URL de l'API leem ; onTokenExpired renvoie une promesse d'un nouveau jeton. tools et navigate règlent les outils intégrés, voir la section « Outils intégrés » ci-dessous. Sans init, les attributs data-widget, data-token et data-api de la balise font la même chose.
Leem.setToken(token)Remplace le jeton, par exemple après une reconnexion.
Leem.open() / Leem.close()Ouvre ou ferme la fenêtre de discussion.
Leem.registerTool(name, fn)Déclare une fonction que l'agent peut appeler dans la page, voir ci-dessous.

Quand le jeton expire, le script appelle onTokenExpired : votre page demande alors un nouveau jeton à votre serveur (qui rappelle POST /v1/widgets/{widget}/sessions).

Équipe d'agents

Un widget est relié à une équipe de 1 à 10 agents API de votre organisation, composée dans la section Équipe de la console : pour chaque agent, un nom affiché (60 caractères au plus) et un avatar. Le premier de la liste est l'agent par défaut. Un widget à un seul agent fonctionne exactement comme avant.

Vos droits restent les vôtres. leem ne connaît pas les rôles de vos utilisateurs : votre serveur indique, à l'ouverture de la session, les agents auxquels la personne a droit. leem n'élargit jamais cette liste.

json
{ "user": { "id": "u_42" }, "agents": ["agt_finance", "agt_rh"] }
  • Chaque identifiant doit appartenir à l'équipe du widget, sans doublon ; une liste vide, un agent hors de l'équipe ou répété répond 400 invalid_parameter (agents ou agents.N).
  • Un seul agent autorisé : le widget lui parle directement, sans sélecteur ni coût supplémentaire.
  • Plusieurs agents autorisés : un sélecteur apparaît dans le widget. La personne choisit un agent précis, qu'elle joint directement sans surcoût, ou Automatique si la case « Proposer le mode Automatique » de la console est cochée (par défaut).
  • Automatique : un coordinateur géré par leem lit la demande, confie des sous-tâches aux agents autorisés, l'un après l'autre, puis répond en combinant leurs résultats. Le widget affiche la passation (« L'agent Finance prend le relais… »). Les fichiers et graphiques produits par un agent sollicité arrivent dans la réponse. Le nombre de sollicitations par message est plafonné.
  • En Automatique, le webhook et tool_outputs portent sur le run indiqué dans le corps du webhook, celui de la réponse en cours : rien ne change pour votre serveur.
  • Quand un agent sollicité attend le résultat d'un outil (page ou serveur), la réponse en cours attend avec lui, avec le même délai de 10 minutes qu'un run d'API. Au-delà, ou si le crédit vient à manquer, la sollicitation échoue et le coordinateur répond avec ce qu'il a déjà obtenu. Supprimer la discussion arrête aussi l'agent sollicité.
  • Chaque discussion reste attachée à l'agent choisi à sa création ; pour changer d'agent, la personne ouvre une nouvelle discussion. Une discussion dont l'agent n'est plus autorisé (liste de la session ou équipe modifiée, agent désactivé) reste lisible mais passe en lecture seule.
  • Un agent sollicité par le coordinateur garde ses outils client, ses outils serveur et ceux de la page, comme en mode direct. Restent interdits les outils retirés d'un widget (données leem, serveurs MCP). Lorsqu'il demande une action sur la page, la carte de confirmation nomme l'agent (« L'agent Finance veut … »). Les consignes de vos agents ne sont jamais transmises au coordinateur ; il ne voit que leurs noms affichés.
  • Facturation : chaque message consomme les crédits de l'organisation comme un run API. En Automatique, le coordinateur et chaque agent sollicité sont facturés comme des runs ; le coordinateur apparaît dans la consommation sous « Coordination du widget ».

3. Faire agir l'agent : outils navigateur et outils serveur

Le widget réutilise les outils client de l'agent. Le champ execution décide qui répond :

  • browser : la page déclare la fonction avec Leem.registerTool(name, fn). Le widget l'exécute et renvoie sa valeur à l'agent. leem n'a aucun accès libre à la page : seules les fonctions que vous déclarez existent. Fonction absente ou en échec : le modèle reçoit un résultat d'erreur. Une fonction qui ne répond pas en 30 secondes est abandonnée : le modèle reçoit l'erreur Timed out.
  • server (défaut) : leem appelle l'URL de webhook du widget (champ de la console) et votre serveur répond par POST /v1/runs/{run}/tool_outputs.

Un label (80 caractères au plus) sur l'outil est affiché pendant l'appel ; sans lui, « Action en cours ».

js
// Runs in the page; put it BEFORE the loader tag. The return value goes back to the agent.
window.Leem = window.Leem || { q: [] };
window.Leem.q.push([
  "registerTool",
  "open_invoice",
  async ({ number }) => {
    const invoice = await myApp.invoices.find(number);
    myApp.router.push(`/invoices/${invoice.id}`);
    return { opened: true, total: invoice.total };
  },
]);
// Once the script is loaded, Leem.registerTool(name, fn) can be called directly.

Le webhook d'un outil serveur

Pour un run de widget, le webhook run.requires_action est réduit à ce que vous devez exécuter. Il est signé comme tout webhook (même en-tête Leem-Signature, même secret de l'organisation, créé par POST /v1/webhook_secret).

json
{
  "id": "whd_01k8example0000000000000000",
  "type": "run.requires_action",
  "created_at": "2026-10-31T10:12:03+00:00",
  "data": {
    "run": { "id": "run_01k8example00000000000000", "status": "requires_action" },
    "widget": { "id": "wgt_01k8examplewidget000000000" },
    "user": { "id": "u_42", "name": "Rina A." },
    "context": { "plan": "pro", "page": "/factures" },
    "tool_calls": [
      { "id": "call_01k8examplecall00000000000", "name": "consulter_mes_factures", "arguments": { "mois": "2026-10" } }
    ]
  }
}
  • Agissez avec les droits de data.user.id. Le corps ne contient ni sortie, ni messages, ni coût, et seulement les appels server.
  • Répondez 200 tout de suite (leem attend 10 secondes), puis exécutez les outils et envoyez les résultats. Vérifiez res.ok : un refus de tool_outputs ne revient pas par le webhook.
  • Un run de widget n'envoie aucun webhook terminal (run.completed…), et aucun webhook quand le tour ne contient que des appels navigateur.
  • Tour mixte : si un tour contient des appels navigateur et serveur, leem attend les deux. Les résultats arrivent en plusieurs envois, chaque partie ne répondant qu'à ses appels ; le run reprend quand tous les appels ont un résultat.
  • Un outil server exige une URL de webhook sur le widget et un secret de webhook sur l'organisation. Sinon il est écarté du run, avec l'avertissement client_tool_unreachable.
js
async function answerRun(event) {
  const { run, user, context, tool_calls } = event.data;
  const tool_outputs = await Promise.all(
    tool_calls.map(async (call) => {
      try {
        const output = await runMyTool(call.name, call.arguments, { user, context });
        return { call_id: call.id, output: JSON.stringify(output) };
      } catch (e) {
        return { call_id: call.id, output: String(e), is_error: true };
      }
    }),
  );
  const res = await fetch(`https://leem-api.lcom-corp.com/v1/runs/${run.id}/tool_outputs`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.LEEM_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ tool_outputs }),
  });
  if (!res.ok) console.error(`Leem tool_outputs refused: ${res.status} ${await res.text()}`);
}

// rawBody: the request body as received, before any JSON parsing.
// Verify the signature first (see the Webhooks article), then:
export function handleLeemWebhook(rawBody) {
  const event = JSON.parse(rawBody);
  if (event.type === "run.requires_action") {
    answerRun(event).catch((e) => console.error(e)); // not awaited: the 200 goes out now
  }
  return 200;
}

La vérification de la signature (verifyLeemSignature) est fournie dans Webhooks ; appelez-la avant de lire le corps.

Outils retirés d'un widget

Un agent peut servir à la fois un widget et des runs API. Au lancement d'un run de widget, les outils qui donneraient accès aux données leem ou à vos serveurs MCP sont retirés, avec l'avertissement widget_tool_unavailable. Restent les outils client, la lecture des documents et les outils sans accès à vos données. La console prévient quand l'agent lié en porte.

Outils intégrés

Sans écrire de code, le widget peut donner à l'agent des outils qui agissent sur la page de votre site : lire, naviguer, remplir, cliquer et mettre en évidence. Ils se règlent par widget, dans la section Outils de la page de la console :

OutilEffet
Lire la pageL'agent voit le texte, les tableaux et les champs affichés. Il peut aussi attendre qu'un texte apparaisse ou qu'un chargement se termine, et lire le passage que la personne a sélectionné sur la page (un bandeau au-dessus de la zone de saisie le montre, avec une croix pour le retirer).
NaviguerL'agent ouvre une autre page de votre site (même origine).
Remplir des champsL'agent saisit des valeurs dans les champs de formulaire, y compris un formulaire entier d'un seul coup.
CliquerL'agent clique sur un bouton ou un lien.
Mettre en évidenceL'agent entoure un élément pour le montrer à l'utilisateur.
  • Liste des pages : pour « Naviguer », vous déclarez dans la console les pages (nom, chemin commençant par /, description facultative ; 100 au plus). Cette liste guide l'agent : elle lui dit quelles pages existent et à quoi elles servent. La navigation n'est pas limitée à ces chemins mais à votre propre domaine (même origine que la page courante), sans confirmation.
  • Confirmation des actions : seuls « Remplir des champs » (fill) et « Cliquer » (click) demandent confirmation ; « Lire la page » (y compris l'attente et la lecture de la sélection), « Naviguer » et « Mettre en évidence » n'en demandent jamais. Un formulaire complet est confirmé en une seule carte, qui liste les champs à remplir. Toujours demander (défaut) affiche une demande avant chaque remplissage ou clic ; Agir sans demander agit directement ; Laisser l'utilisateur choisir ajoute à la demande une case à cocher « Tout accepter automatiquement », prise en compte quand la personne clique sur « Autoriser ». Une demande restée sans réponse 5 minutes est annulée et l'agent en est informé. Ce choix est mémorisé dans le navigateur de la personne ; une fois « Tout accepter » choisi, l'entrée « Toujours demander avant d'agir » apparaît dans le menu ⋯ du widget pour le révoquer.
  • Ce qui n'est jamais lu ni modifié : les champs de mot de passe, les champs autocomplete cc-*, one-time-code, current-password et new-password, les zones marquées data-leem-ignore, les éléments masqués et le widget lui-même. Le texte de la page est transmis à l'agent comme donnée, jamais comme instruction.
  • Plein écran : le bouton « Agrandir » de l'en-tête (à gauche de « Réduire », absent sur téléphone) passe le widget en plein écran ; pendant que l'agent clique, remplit un champ ou un formulaire, met en évidence ou navigue, il revient en panneau pour que la personne voie la page, puis reprend le plein écran à la fin de la réponse ; ce choix est gardé le temps de la session du navigateur.
  • Zones à exclure : ajoutez data-leem-ignore à tout élément (et donc à son contenu) que l'agent ne doit ni lire ni toucher.
html
<div data-leem-ignore>Données sensibles : invisibles pour l'agent.</div>

Restreindre depuis le code

Le code de la page ne peut que retirer des outils activés dans la console, jamais en ajouter : l'option tools de Leem.init, ou l'attribut data-tools-off de la balise (noms séparés par des virgules). Les noms sont read_page, navigate, fill, click et highlight.

js
Leem.init({ widget: "wgt_...", token, apiUrl: "https://leem-api.lcom-corp.com", tools: { click: false, fill: false } });
html
<script src="https://leem.lcom-corp.com/widget/v1/leem-widget.js" data-widget="wgt_..." data-token="TOKEN_FROM_YOUR_SERVER" data-tools-off="click,fill" async></script>

Application monopage

Sans option, « Naviguer » fait une navigation normale du navigateur : le widget se rouvre de lui-même sur la même discussion une fois la page chargée, à condition que la nouvelle page charge le script dans les 2 minutes. Dans une application monopage, passez votre routeur pour éviter le rechargement :

js
Leem.init({ widget: "wgt_...", token, apiUrl: "https://leem-api.lcom-corp.com", navigate: (path) => router.push(path) });

Noms réservés

Les noms d'outils commençant par leem_ sont réservés aux outils intégrés : un Leem.registerTool("leem_...", fn) est ignoré, et un outil client d'agent ou de run portant un tel nom est refusé (422 client_tool_name_taken).

Cartes de capacités et suites

  • Cartes de capacités : à l'ouverture d'une nouvelle discussion, sous le message d'accueil, le widget montre 3 à 4 cartes (icône, titre, phrase, exemple cliquable) qui disent ce que l'agent peut faire. leem les génère à partir des consignes de l'agent par défaut, de ses outils, des outils de la page et de la liste des pages, quand l'un d'eux change, jamais à l'ouverture du widget. Dans la section Ce que l'assistant propose de la console, vous pouvez les modifier, les réordonner, les supprimer ou les régénérer ; une carte modifiée à la main n'est plus écrasée par une génération automatique. Sans carte, les suggestions de l'accueil restent affichées.
  • Suites proposées : quand l'agent propose une suite, il l'écrit dans sa réponse sous forme de liens cliquables, au fil du texte. Un clic envoie le texte du lien comme message ; les liens sont inactifs pendant qu'une réponse s'écrit. Aucune génération supplémentaire n'est nécessaire, et il n'y a rien à activer dans la console.
  • La génération des cartes consomme les crédits de l'organisation et apparaît dans la consommation sous « Suggestions du widget ». Crédit épuisé : rien n'est généré, sans erreur dans le widget.

Fichiers et limites

  • Un message compte 20 000 caractères au plus et liste 10 fichiers au plus.
  • L'utilisateur peut joindre des fichiers à son message (mêmes types et taille que Fichiers). Ils ne comptent pas dans le quota de stockage de l'organisation : chaque utilisateur du widget a son propre plafond, 100 fichiers et 200 Mo (400 storage_quota_exceeded ; supprimer d'anciennes discussions libère de la place). Il peut en envoyer 50 par jour (429 user_rate_limited). Un fichier joint mais jamais envoyé est effacé après 24 heures.
  • Dictée vocale : quand la dictée est disponible sur leem (elle ne se règle pas dans la console), un bouton micro apparaît dans la zone de saisie si le navigateur sait enregistrer ; le texte transcrit est inséré dans le message, jamais envoyé automatiquement. Le micro exige que la page hôte soit servie en HTTPS et ne bloque pas microphone dans sa Permissions-Policy (par exemple Permissions-Policy: microphone=(self)). Un enregistrement fait 10 Mo au plus. Chaque utilisateur est limité à 30 dictées par heure ; une dictée consomme les crédits de l'organisation. L'enregistrement n'est jamais conservé.
  • Les graphiques produits par l'agent s'affichent dans sa réponse et restent dans l'historique de la discussion. Ils apparaissent une fois le texte de la réponse terminé, à l'endroit où l'agent les place (sinon en fin de réponse), pour que le texte ne bouge pas pendant la lecture. Le module de graphiques est chargé depuis la même origine que le script du widget, au premier graphique affiché, depuis le même dossier https://leem.lcom-corp.com/widget/v1/ : une CSP qui autorise cette origine n'a besoin de rien de plus. Si votre CSP ne liste que le chemin exact du script ou repose sur des nonces, autorisez aussi ce dossier (le nonce du script est recopié automatiquement sur le module).
  • Les documents de connaissance de l'agent (/v1/agents/{agent}/documents) sont lus par chaque discussion, voir Agents et outils.
  • Chaque utilisateur est limité en messages par minute et par jour (réglage du widget dans la console) ; une réponse consomme les crédits de l'organisation, comme un run API.
  • Une seule réponse est produite à la fois par discussion.
  • Arrêter une réponse : pendant une réponse, le bouton d'envoi devient un bouton d'arrêt ; le texte déjà écrit reste affiché avec la mention « Réponse interrompue », seul le travail fait jusqu'à l'arrêt est facturé et, en Automatique, l'agent sollicité est arrêté lui aussi.
  • Le bouton de signalement du widget envoie un e-mail à l'adresse de support du widget ; sans adresse, il est masqué. Chaque utilisateur peut envoyer 5 signalements par jour.

Erreurs vues par le widget

Elles ont la forme habituelle (voir Erreurs et limites). Le widget les traduit pour l'utilisateur ; elles servent surtout à comprendre un comportement.

StatutcodeCas
401invalid_sessionJeton absent, invalide ou expiré : le widget appelle onTokenExpired.
403widget_unavailableWidget désactivé, aucun agent actif dans l'équipe, ou option absente de la formule.
403agent_not_allowedL'agent choisi (ou Automatique) n'est pas disponible pour cette session ; aucun message n'est créé.
403origin_not_allowedLe site n'est pas dans les domaines autorisés du widget.
429user_rate_limitedLimite de cet utilisateur atteinte : messages par minute ou par jour, envois de fichiers, signalements ou dictées.
429assistant_busyTrop de réponses simultanées sur l'organisation : réessayer dans un instant.
409reply_in_progressLa réponse précédente de cette discussion n'est pas terminée.
409support_unavailableSignalement demandé alors que le widget n'a pas d'adresse de support.
402insufficient_creditsCrédit de l'organisation épuisé.
403organization_lockedOrganisation verrouillée (facture impayée ou essai terminé).
402organization_suspendedOrganisation suspendue (renouvellement impayé).
503assistant_unavailableL'assistant est momentanément indisponible : réessayer plus tard.
400storage_quota_exceededPlafond de fichiers de cet utilisateur atteint (100 fichiers, 200 Mo).
413file_too_largeDictée : enregistrement de plus de 10 Mo.
422unsupported_mediaDictée : le fichier envoyé n'est pas un enregistrement audio.
422audio_rejectedDictée : enregistrement trop long ou illisible.
429dictation_busyDictée momentanément occupée : réessayer dans un instant.
503dictation_unavailableDictée momentanément indisponible.

Politique de sécurité de contenu (CSP)

Si votre site définit une CSP, autorisez :

  • script-src : l'origine du script, https://leem.lcom-corp.com (le script du widget et son module de graphiques sont servis depuis https://leem.lcom-corp.com/widget/v1/) ;
  • connect-src : l'URL de l'API leem, celle que vous donnez à data-api / apiUrl ;
  • style-src : le widget applique ses styles par l'API de feuilles de style du navigateur ; si votre politique la bloque, il se rabat sur une balise <style>, qui demande style-src 'unsafe-inline'.

Les domaines autorisés du widget (console) sont vérifiés par leem à chaque appel : listez-y chaque origine exacte (schéma, hôte, port) qui charge le script.

Effacer les données d'un utilisateur

DELETE /v1/widget_users/{id}, où {id} est le user.id donné à l'ouverture de session, encodé pour une URL. Sessions, discussions, messages et fichiers de cette personne sont effacés sur tous les widgets de l'organisation (RGPD, départ d'un employé). C'est irréversible ; un identifiant inconnu répond 404 resource_not_found. Les statistiques restent comptées par widget.

bash
curl https://leem-api.lcom-corp.com/v1/widget_users/u_42 -X DELETE -H "Authorization: Bearer $LEEM_API_KEY"