Leem

Agents et outils

Outils client

Déclarer des outils que votre serveur exécute : pause du run, résultats renvoyés, refus d'accès et délai.

Un outil client est une fonction que vous déclarez à leem mais que votre serveur exécute. Quand le modèle l'appelle, le run se met en pause (requires_action) et attend que vous lui renvoyiez le résultat ; il reprend ensuite là où il s'était arrêté. leem n'appelle jamais votre serveur et ne stocke aucun de vos identifiants : c'est toujours vous qui appelez leem.

Cas d'usage typique : une entreprise dont le serveur appelle leem pour chacun de ses employés, avec les droits propres à chacun. L'outil consulter_mes_livraisons s'exécute chez vous, avec la session de la personne concernée ; si elle n'a pas accès aux données demandées, vous répondez par un refus, et le modèle l'explique à l'utilisateur au lieu d'inventer une réponse.

Déclarer un outil

Un outil client se déclare sur l'agent (client_tools de POST/PATCH /v1/agents), où il reste, ou sur un seul run (client_tools de POST /v1/runs), en plus de ceux de l'agent. Chaque définition porte trois champs :

ChampRègle
name1 à 64 caractères parmi lettres, chiffres, _ et -. Unique (sans tenir compte de la casse) parmi les outils client du run, et différent de la clé d'un outil leem (422 client_tool_name_taken). Le préfixe leem_ est réservé aux outils intégrés du widget : un nom qui commence par leem_, sans tenir compte de la casse, est refusé de la même façon.
descriptionObligatoire, 1 024 caractères au plus. C'est ce que le modèle lit pour décider d'appeler l'outil : dites ce qu'il fait et quand l'utiliser.
parametersObligatoire. Un JSON Schema d'objet, 16 384 octets au plus, même sous-ensemble que output_schema (voir Sortie structurée). Sans argument : {"type": "object", "properties": {}}.

Bornes : 20 outils client par agent et 20 par run. Une définition invalide est refusée en 422 invalid_client_tool, avec param pointant le champ fautif (client_tools.0.name…). Sur PATCH, client_tools remplace la liste entière ; null la vide.

bash
curl https://leem-api.lcom-corp.com/v1/agents \
  -X POST \
  -H "Authorization: Bearer $LEEM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Assistant livraisons",
    "instructions": "Tu réponds aux questions sur les livraisons en utilisant les outils. Si un outil refuse l’accès, dis-le simplement.",
    "client_tools": [
      {
        "name": "consulter_mes_livraisons",
        "description": "Liste les livraisons de la personne connectée à une date donnée, selon ses droits.",
        "parameters": {
          "type": "object",
          "properties": { "date": { "type": "string", "description": "Date au format AAAA-MM-JJ." } },
          "required": ["date"]
        }
      }
    ]
  }'

L'agent renvoyé porte le même client_tools. Pour ajouter un outil le temps d'un seul run, passez client_tools à POST /v1/runs : un nom déjà déclaré sur l'agent est refusé (422 client_tool_name_taken).

Si, plus tard, leem ajoute à son catalogue un outil qui porte le même nom qu'un outil client déjà enregistré sur l'agent, l'agent n'est pas modifié : chaque run écarte l'outil client et le signale dans warnings par client_tool_name_conflict (renommez-le sur l'agent).

Où l'outil s'exécute : execution et label

Deux champs facultatifs s'ajoutent à la définition (version 1.4) :

ChampRègle
execution"server" (défaut) : votre serveur exécute l'outil, comme décrit plus bas. "browser" : le widget exécute dans la page la fonction enregistrée avec Leem.registerTool(name, fn). Chaque appel de required_action.tool_calls[] porte aussi execution.
label1 à 80 caractères. Libellé affiché par le widget pendant l'appel ; sans lui, « Action en cours ».

Sur un run API ordinaire (hors widget), un outil browser suit le chemin serveur : vous recevez l'appel et répondez par tool_outputs.

Résultats partiels dans un widget. Un run de widget peut mêler appels navigateur et appels serveur dans un même tour. Les résultats arrivent alors en plusieurs envois : chaque partie (le navigateur, votre serveur) ne répond qu'à ses propres appels, et le run reprend quand tous les appels ont un résultat. L'exigence « tous les résultats en une requête » ci-dessous vaut donc par partie pour un run de widget. Le webhook d'un run de widget ne liste que les appels server (voir Widget).

Si le widget n'a pas d'URL de webhook, ou l'organisation pas de secret de webhook, un outil server est écarté du run avec l'avertissement client_tool_unreachable.

Le cycle d'un run avec un outil client

  1. Vous lancez le run (POST /v1/runs, avec webhook_url ou en flux SSE).
  2. Le modèle appelle l'outil. leem exécute ses propres outils éventuels du même tour, puis passe le run en requires_action : required_action liste les appels à exécuter. Un webhook run.requires_action part (ou l'événement du même nom sur le flux SSE).
  3. Votre serveur exécute les appels, avec les droits de la personne concernée.
  4. Vous envoyez tous les résultats en une requête à POST /v1/runs/{run}/tool_outputs. Le run repasse en queued puis running et continue.
  5. Le modèle peut appeler d'autres outils (nouvelle pause) ou répondre. Un run peut donc se mettre en pause plusieurs fois.

Exemple complet

Un run sur l'agent ci-dessus, avec un webhook et un outil supplémentaire propre à cet appel :

bash
curl https://leem-api.lcom-corp.com/v1/runs \
  -X POST \
  -H "Authorization: Bearer $LEEM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent": "agt_01k6examplea0000000000000",
    "input": "Quelles sont mes livraisons de demain ?",
    "webhook_url": "https://exemple.com/webhooks/leem",
    "metadata": { "employe_id": "E-1042" },
    "client_tools": [
      {
        "name": "date_du_jour",
        "description": "Renvoie la date du jour au format AAAA-MM-JJ.",
        "parameters": { "type": "object", "properties": {} }
      }
    ]
  }'

Le modèle commence par date_du_jour. Le run se met en pause et votre webhook_url reçoit (signé comme les autres livraisons, voir Webhooks) :

json
{
  "id": "whd_01k6example0000000000000000",
  "type": "run.requires_action",
  "created_at": "2026-10-30T09:00:04+00:00",
  "data": {
    "run": {
      "id": "run_01k6exampler000000000000",
      "agent": "agt_01k6examplea0000000000000",
      "status": "requires_action",
      "metadata": { "employe_id": "E-1042" },
      "required_action": {
        "type": "submit_tool_outputs",
        "tool_calls": [
          { "id": "call_01k8examplecall00000000000", "name": "date_du_jour", "arguments": {} }
        ]
      },
      "output": { "json": null, "text": null, "files": [], "charts": [] },
      "error": null,
      "...": "..."
    }
  }
}

Chaque appel a un id (call_…), le name de l'outil et ses arguments, un objet censé suivre vos parameters. Validez-les de votre côté avant d'agir : le modèle peut s'écarter du schéma. GET /v1/runs/{run} renvoie le même required_action tant que le run attend (null sinon).

Votre serveur exécute l'appel et répond, pour tous les appels en attente à la fois :

bash
curl https://leem-api.lcom-corp.com/v1/runs/run_01k6exampler000000000000/tool_outputs \
  -X POST \
  -H "Authorization: Bearer $LEEM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tool_outputs": [
      { "call_id": "call_01k8examplecall00000000000", "output": "2026-10-30" }
    ]
  }'

La réponse est 202 avec le run (statut queued, required_action à null). Le modèle appelle alors consulter_mes_livraisons avec {"date": "2026-10-31"} : nouveau webhook run.requires_action, nouvel appel à tool_outputs, cette fois avec un résultat JSON :

bash
curl https://leem-api.lcom-corp.com/v1/runs/run_01k6exampler000000000000/tool_outputs \
  -X POST \
  -H "Authorization: Bearer $LEEM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tool_outputs": [
      {
        "call_id": "call_01k8examplecall00000001",
        "output": [
          { "tournee": "T-12", "client": "Boulangerie Rakoto", "creneau": "08:00-10:00" },
          { "tournee": "T-12", "client": "Pharmacie Centrale", "creneau": "10:30-12:00" }
        ]
      }
    ]
  }'

Le run reprend, le modèle rédige sa réponse et le webhook run.completed arrive avec le run terminé :

json
{
  "id": "whd_01k6example0000000000001",
  "type": "run.completed",
  "created_at": "2026-10-30T09:00:19+00:00",
  "data": {
    "run": {
      "id": "run_01k6exampler000000000000",
      "status": "succeeded",
      "required_action": null,
      "output": { "json": null, "text": "Demain, vous avez deux livraisons : la Boulangerie Rakoto entre 8 h et 10 h, puis la Pharmacie Centrale entre 10 h 30 et 12 h.", "files": [], "charts": [] },
      "steps": [
        { "tool": "date_du_jour", "label": "date_du_jour", "status": "succeeded", "duration_ms": 412 },
        { "tool": "consulter_mes_livraisons", "label": "consulter_mes_livraisons", "status": "succeeded", "duration_ms": 780 }
      ],
      "...": "..."
    }
  }
}

Dans steps, la duration_ms d'un outil client est le temps écoulé entre la pause et l'arrivée de votre résultat.

Le corps de tool_outputs

json
{ "tool_outputs": [ { "call_id": "call_...", "output": "...", "is_error": false } ] }
  • call_id : l'id d'un appel de required_action.
  • output : obligatoire, une chaîne ou n'importe quelle valeur JSON (objet, liste, nombre…), sauf null. Une valeur non textuelle est transmise au modèle sous forme de JSON. 100 000 octets au plus par résultat (mesurés en JSON quand ce n'est pas une chaîne) : résumez ou tronquez plutôt que d'envoyer un export entier.
  • is_error : booléen, false par défaut. true signale que l'outil a échoué ou refusé.

Les résultats d'une pause sont envoyés ensemble : exactement un résultat par appel en attente, dans l'ordre que vous voulez.

Refuser ou échouer : is_error

Quand l'outil ne peut pas répondre (la personne n'a pas le droit de voir ces données, votre base est injoignable, un argument est invalide), renvoyez is_error: true avec une explication courte :

json
{
  "tool_outputs": [
    { "call_id": "call_01k8examplecall00000001", "output": "Accès refusé : cette personne n'a pas accès aux livraisons de cette agence.", "is_error": true }
  ]
}

Le modèle reçoit ce message comme un échec de l'outil, pas comme une donnée : il l'explique à l'utilisateur ou essaie autre chose, mais n'invente pas de résultat. Le run n'échoue pas pour autant, et ce refus ne compte pas dans la limite d'échecs d'outils d'un run. Écrivez un message qui dit ce qui s'est passé sans révéler ce à quoi la personne n'a pas droit.

Le résultat d'un outil client est du contenu venu de l'extérieur : leem le présente au modèle comme non fiable (comme le résultat d'une recherche web). Ne renvoyez que des données, jamais de consignes pour le modèle.

Délai : 10 minutes

Un run en requires_action attend vos résultats 10 minutes au plus. Au-delà, il passe en failed avec l'erreur tool_outputs_timeout ; les fichiers et graphiques déjà produits sont conservés. Un POST /v1/runs/{run}/cancel pendant l'attente ferme le run immédiatement en cancelled.

Pendant l'attente :

  • le run ne consomme rien et ne compte pas dans la limite de runs simultanés (seuls queued et running comptent) ;
  • l'attente n'est pas décomptée de la durée maximale de 600 secondes d'un run, qui ne mesure que le temps de travail ;
  • la reprise vérifie votre crédit : s'il est épuisé, tool_outputs répond 402 insufficient_credits et le run continue d'attendre jusqu'au délai.

Suivre un run en pause

  • Webhook : run.requires_action part à chaque pause (voir Webhooks). Il porte les arguments des appels, qui peuvent contenir des données personnelles : comme le reste du run, ils sont caviardés après 30 jours.
  • Flux SSE : l'événement run.requires_action est suivi de la fermeture du flux tant que le run attend. Une fois vos résultats envoyés, reconnectez-vous avec Last-Event-ID (voir Flux d'événements (SSE)) pour lire la suite.
  • Interrogation : GET /v1/runs/{run} jusqu'à voir status valoir requires_action.

Erreurs de POST /v1/runs/{run}/tool_outputs

StatutcodeCas
400invalid_parameter, unknown_parameterCorps mal formé : tool_outputs n'est pas une liste, call_id ou output manquant, champ inconnu.
402insufficient_creditsCrédit épuisé : le run continue d'attendre.
409run_not_waitingLe run n'est pas en requires_action (déjà repris, terminé, annulé ou expiré). Ne renvoyez pas : lisez le run.
422unknown_tool_callUn call_id n'est pas en attente sur ce run.
422duplicate_tool_outputUn même call_id est répondu deux fois.
422missing_tool_outputsAu moins un appel en attente n'a pas de résultat ; le message les nomme.
422tool_output_too_largeUn résultat dépasse 100 000 octets.

Le détail est dans Erreurs et limites. Renvoyer deux fois les mêmes résultats est sans danger : le run n'est repris qu'une fois, et le second envoi reçoit 409 run_not_waiting (ou 422 unknown_tool_call si le run s'est déjà remis en pause avec de nouveaux appels).

Bonnes pratiques

  • Appliquez les droits chez vous, à chaque appel : leem ne connaît pas vos utilisateurs. Gardez de votre côté le lien entre un run et la personne concernée (metadata est fait pour cela).
  • Soyez rapide : répondez en quelques secondes, bien en deçà des 10 minutes.
  • Rendez vos écritures idempotentes : un modèle peut rappeler un outil. Préférez des outils de lecture.
  • Pas de secret dans output : ce que vous renvoyez est lu par le modèle et peut se retrouver dans sa réponse.